summaryrefslogtreecommitdiff
path: root/tissue
diff options
context:
space:
mode:
Diffstat (limited to 'tissue')
-rw-r--r--tissue/commit.scm42
-rw-r--r--tissue/document.scm37
-rw-r--r--tissue/file-document.scm63
-rw-r--r--tissue/git.scm98
-rw-r--r--tissue/issue.scm127
-rw-r--r--tissue/search.scm41
-rw-r--r--tissue/skribilo.scm104
-rw-r--r--tissue/tissue.scm106
-rw-r--r--tissue/utils.scm28
-rw-r--r--tissue/web/dev.scm86
-rw-r--r--tissue/web/server.scm391
-rw-r--r--tissue/web/static.scm89
-rw-r--r--tissue/web/themes.scm42
-rw-r--r--tissue/web/themes/default.scm340
14 files changed, 981 insertions, 613 deletions
diff --git a/tissue/commit.scm b/tissue/commit.scm
index 3dfd45f..b910695 100644
--- a/tissue/commit.scm
+++ b/tissue/commit.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -39,7 +39,7 @@
(author-date #:getter doc:commit-author-date #:init-keyword #:author-date))
(define-method (document-id-term (commit <commit>))
- "Return the ID term for DOCUMENT."
+ "Return the ID term for COMMIT."
(string-append "Qcommit." (commit-hash commit)))
(define-method (document-boolean-terms (commit <commit>))
@@ -48,16 +48,9 @@
(string-append "A" (doc:commit-author commit))))
(define-method (document-recency-date (commit <commit>))
- "Return a date representing the recency of DOCUMENT"
+ "Return a date representing the recency of COMMIT."
(doc:commit-author-date commit))
-(define-method (document-snippet-source-text (commit <commit>))
- "Return the source text for COMMIT from which to extract a search
-result snippet."
- (commit-body
- (commit-lookup (current-git-repository)
- (string->oid (commit-hash commit)))))
-
(define-method (document-text (commit <commit>))
"Return the full text of COMMIT."
(commit-message
@@ -90,26 +83,6 @@ search results."
(newline port)
(newline port))))
-(define-method (document->sxml (commit <commit>) mset)
- "Render COMMIT, a <commit> object, to SXML. MSET is the xapian MSet
-object representing a list of search results."
- `(li (@ (class ,(string-append "search-result search-result-commit")))
- (a (@ (href ,(document-web-uri commit))
- (class "search-result-title"))
- ,(document-title commit))
- (div (@ (class "search-result-metadata"))
- (span (@ (class ,(string-append "document-type commit-document-type")))
- "commit")
- ,(string-append
- (format #f " authored ~a by ~a"
- (human-date-string (doc:commit-author-date commit))
- (doc:commit-author commit))))
- ,@(let ((snippet (document-sxml-snippet commit mset)))
- (if snippet
- (list `(div (@ (class "search-result-snippet"))
- ,@snippet))
- (list)))))
-
(define (repository-commits repository)
"Return a list of <commit> objects representing commits in
REPOSITORY."
@@ -119,7 +92,14 @@ REPOSITORY."
#:hash (oid->string (commit-id commit))
#:author (resolve-alias (signature-name (commit-author commit))
(%aliases))
- #:author-date (commit-author-date commit))
+ #:author-date (commit-author-date commit)
+ ;; The snippet source text excludes the
+ ;; first paragraph (i.e., the summary line)
+ ;; of the commit. Hence, we use commit-body.
+ #:snippet-source-text
+ (commit-body
+ (commit-lookup (current-git-repository)
+ (commit-id commit))))
result))
(list)
repository))
diff --git a/tissue/document.scm b/tissue/document.scm
index 48d82cc..1e55e67 100644
--- a/tissue/document.scm
+++ b/tissue/document.scm
@@ -43,12 +43,12 @@
document-snippet-source-text
document-snippet
print
- document-sxml-snippet
- document->sxml))
+ document-sxml-snippet))
(define (slot-set object slot-name value)
- "Set SLOT-NAME in OBJECT to VALUE. This is a purely functional setter
-that operates on a copy of OBJECT. It does not mutate OBJECT."
+ "Set @var{slot-name} in @var{object} to @var{value}. This is a purely
+functional setter that operates on a copy of @var{object}. It does not
+mutate @var{object}."
(let ((clone (shallow-clone object)))
(slot-set! clone slot-name value)
clone))
@@ -87,7 +87,8 @@ that operates on a copy of OBJECT. It does not mutate OBJECT."
(define (object->scm object)
"Convert GOOPS OBJECT to a serializable object."
(cond
- ((or (string? object)
+ ((or (symbol? object)
+ (string? object)
(number? object)
(boolean? object))
object)
@@ -107,7 +108,8 @@ that operates on a copy of OBJECT. It does not mutate OBJECT."
(define (scm->object scm)
"Convert serializable object SCM to a GOOPS object."
(cond
- ((or (string? scm)
+ ((or (symbol? scm)
+ (string? scm)
(number? scm)
(boolean? scm))
scm)
@@ -131,13 +133,14 @@ that operates on a copy of OBJECT. It does not mutate OBJECT."
(define-class <document> ()
(title #:accessor document-title #:init-keyword #:title)
- (web-uri #:accessor document-web-uri #:init-keyword #:web-uri))
+ (web-uri #:accessor document-web-uri #:init-keyword #:web-uri)
+ (snippet-source-text #:accessor document-snippet-source-text
+ #:init-keyword #:snippet-source-text))
(define-generic document-id-term)
(define-generic document-text)
(define-generic document-recency-date)
(define-generic print)
-(define-generic document->sxml)
(define-method (document-type (document <document>))
(string-trim-both (symbol->string (class-name (class-of document)))
@@ -172,21 +175,17 @@ and further text, increase-termpos! must be called before indexing."
(index-text! term-generator (document-text document))
term-generator))
-(define-method (document-snippet-source-text (document <document>))
- "Return the source text for DOCUMENT from which to extract a search
-result snippet."
- ;; Remove blank lines from document text.
- (string-join
- (remove string-blank?
- (string-split (document-text document)
- #\newline))
- "\n"))
-
(define (document-html-snippet document mset)
"Return snippet for DOCUMENT. MSET is the xapian MSet object
representing a list of search results."
(mset-snippet mset
- (document-snippet-source-text document)
+ ;; Remove blank lines from text.
+ (string-join
+ (remove string-blank?
+ (string-split
+ (document-snippet-source-text document)
+ #\newline))
+ "\n")
#:length 200
#:highlight-start "<b>"
#:highlight-end "</b>"
diff --git a/tissue/file-document.scm b/tissue/file-document.scm
index b910131..f389976 100644
--- a/tissue/file-document.scm
+++ b/tissue/file-document.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -38,6 +38,7 @@
file-document-created-date
file-document-last-updater
file-document-last-updated-date
+ commits-affecting-file
read-gemtext-document))
(define-class <file-document> (<document>)
@@ -58,7 +59,7 @@
(compose doc:commit-author-date last file-document-commits))
(define-method (document-type (document <file-document>))
- (next-method))
+ "document")
(define-method (document-id-term (document <file-document>))
"Return the ID term for DOCUMENT."
@@ -68,10 +69,14 @@
"Return a date representing the recency of DOCUMENT."
(file-document-last-updated-date document))
+(define (file-text file)
+ "Return the contents of text @var{file}."
+ (call-with-input-file file
+ get-string-all))
+
(define-method (document-text (document <file-document>))
"Return the full text of DOCUMENT."
- (call-with-file-in-git (current-git-repository) (file-document-path document)
- get-string-all))
+ (file-text (file-document-path document)))
(define-method (document-term-generator (document <file-document>))
"Return a term generator indexing DOCUMENT."
@@ -116,40 +121,25 @@ MSet object representing a list of search results."
(newline port)
(newline port))))
-(define-method (document->sxml (document <file-document>) mset)
- "Render DOCUMENT to SXML. MSET is the xapian MSet object representing
-a list of search results."
- `(li (@ (class "search-result search-result-document"))
- (a (@ (href ,(document-web-uri document))
- (class "search-result-title"))
- ,(document-title document))
- (div (@ (class "search-result-metadata"))
- (span (@ (class ,(string-append "document-type file-document-type")))
- "document")
- ,(string-append
- (format #f " created ~a by ~a"
- (human-date-string (file-document-created-date document))
- (file-document-creator document))
- (if (> (length (file-document-commits document))
- 1)
- (format #f ", last updated ~a by ~a"
- (human-date-string (file-document-last-updated-date document))
- (file-document-last-updater document))
- "")))
- ,@(let ((snippet (document-sxml-snippet document mset)))
- (if snippet
- (list `(div (@ (class "search-result-snippet"))
- ,@snippet))
- (list)))))
-
(define file-modification-table-for-current-repository
(memoize-thunk
(cut file-modification-table (current-git-repository))))
+(define (commits-affecting-file file)
+ "Return a list of commits affecting @var{file} in current repository."
+ (map (lambda (commit)
+ (make <commit>
+ #:author (resolve-alias (signature-name (commit-author commit))
+ (%aliases))
+ #:author-date (commit-author-date commit)))
+ (hashtable-ref (file-modification-table-for-current-repository)
+ file #f)))
+
(define (read-gemtext-document file)
- "Read gemtext document from FILE. Return a <file-document> object."
+ "Read gemtext document from @var{file} and return a
+@code{<file-document>} object."
(make <file-document>
- #:title (or (call-with-file-in-git (current-git-repository) file
+ #:title (or (call-with-input-file file
(lambda (port)
(port-transduce (tfilter-map (lambda (line)
;; The first level one
@@ -162,10 +152,5 @@ a list of search results."
;; Fallback to filename if document has no title.
file)
#:path file
- #:commits (map (lambda (commit)
- (make <commit>
- #:author (resolve-alias (signature-name (commit-author commit))
- (%aliases))
- #:author-date (commit-author-date commit)))
- (hashtable-ref (file-modification-table-for-current-repository)
- file #f))))
+ #:commits (commits-affecting-file file)
+ #:snippet-source-text (file-text file)))
diff --git a/tissue/git.scm b/tissue/git.scm
index 764fba2..70f0de9 100644
--- a/tissue/git.scm
+++ b/tissue/git.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -45,9 +45,9 @@
commit-author-date
git-tracked-file?
git-tracked-files
- call-with-file-in-git
file-modification-table
- clone-options))
+ clone-options
+ call-with-temporary-checkout))
;; We bind additional functions from libgit2 that are not already
;; bound in guile-git. TODO: Contribute them to guile-git.
@@ -93,7 +93,11 @@ directory."
(define (git-top-level)
"Return the top-level directory of the current git repository."
- (dirname (repository-directory (current-git-repository))))
+ (let ((repository-directory
+ (repository-directory (current-git-repository))))
+ (if (repository-bare? (current-git-repository))
+ repository-directory
+ (dirname repository-directory))))
(define (head-tree repository)
"Return tree of HEAD in REPOSITORY."
@@ -122,35 +126,15 @@ directory."
path)))
(define* (git-tracked-files #:optional (repository (current-git-repository)))
- "Return a list of all files and directories tracked in REPOSITORY. The
-returned paths are relative to the top-level directory of REPOSITORY
-and do not have a leading slash."
+ "Return a list of all files and directories tracked in
+@var{repository}. The returned paths are relative to the top-level
+directory of @var{repository} and do not have a leading slash."
(tree-list (head-tree repository)))
-(define (call-with-file-in-git repository path proc)
- "Call PROC on an input port reading contents of PATH. PATH may refer
-to a file on the filesystem or in REPOSITORY."
- (let ((file-path (if (absolute-file-name? path)
- ;; Treat absolute paths verbatim.
- path
- ;; Treat relative paths as relative to the
- ;; top-level of the git repository.
- (string-append (dirname (repository-directory repository))
- "/" path))))
- (if (file-exists? file-path)
- ;; If file exists on the filesystem, read it.
- (call-with-input-file file-path proc)
- ;; Else, read the file from the repository.
- (let* ((path-tree-entry (tree-entry-bypath (head-tree repository)
- path))
- (path-object (tree-entry->object repository path-tree-entry))
- (blob (blob-lookup repository (object-id path-object))))
- (call-with-port (open-bytevector-input-port (blob-content blob))
- proc)))))
-
-(define (commit-deltas repository commit)
- "Return the list of <diff-delta> objects created by COMMIT with
-respect to its first parent in REPOSITORY."
+(define (commit-file-changes repository commit)
+ "Return a list of pairs describing files modified by COMMIT with
+respect to its first parent in REPOSITORY. Each pair maps the old
+filename before COMMIT to the new filename after COMMIT."
(match (commit-parents commit)
((parent _ ...)
(let ((diff (diff-tree-to-tree repository
@@ -158,7 +142,9 @@ respect to its first parent in REPOSITORY."
(commit-tree commit))))
(diff-find-similar! diff)
(diff-fold (lambda (delta progress result)
- (cons delta result))
+ (cons (cons (diff-file-path (diff-delta-old-file delta))
+ (diff-file-path (diff-delta-new-file delta)))
+ result))
(lambda (delta binary result)
result)
(lambda (delta hunk result)
@@ -167,7 +153,9 @@ respect to its first parent in REPOSITORY."
result)
(list)
diff)))
- (() (list))))
+ (() (map (lambda (file)
+ (cons file file))
+ (tree-list (commit-tree commit))))))
(define (file-modification-table repository)
"Return a hashtable mapping files to the list of commits in REPOSITORY
@@ -176,25 +164,21 @@ that modified them."
(renames (make-hashtable string-hash string=?)))
(fold-commits
(lambda (commit _)
- (map (lambda (delta)
- ;; Map old filename to current filename if they are
- ;; different. Note that this manner of following renames
- ;; requires a linear git history and will not work with
- ;; branch merges.
- (unless (string=? (diff-file-path (diff-delta-old-file delta))
- (diff-file-path (diff-delta-new-file delta)))
- (hashtable-set! renames
- (diff-file-path (diff-delta-old-file delta))
- (diff-file-path (diff-delta-new-file delta))))
- (hashtable-update! result
- ;; If necessary, translate old
- ;; filename to current filename.
- (hashtable-ref renames
- (diff-file-path (diff-delta-old-file delta))
- (diff-file-path (diff-delta-old-file delta)))
- (cut cons commit <>)
- (list)))
- (commit-deltas repository commit)))
+ (map (match-lambda
+ ((old-file . new-file)
+ ;; Map old filename to current filename if they are
+ ;; different. Note that this manner of following renames
+ ;; requires a linear git history and will not work with
+ ;; branch merges.
+ (unless (string=? old-file new-file)
+ (hashtable-set! renames old-file new-file))
+ (hashtable-update! result
+ ;; If necessary, translate old
+ ;; filename to current filename.
+ (hashtable-ref renames old-file old-file)
+ (cut cons commit <>)
+ (list))))
+ (commit-file-changes repository commit)))
#f
repository)
result))
@@ -206,3 +190,13 @@ that modified them."
'bare
(if bare? 1 0))
clone-options))
+
+(define (call-with-temporary-checkout repository proc)
+ "Call PROC with a temporary checkout of REPOSITORY, and delete the
+checkout when PROC returns or exits non-locally."
+ (call-with-temporary-directory
+ (lambda (temporary-checkout)
+ (clone repository temporary-checkout)
+ (proc temporary-checkout))
+ ;; The system-dependent temporary directory
+ (dirname (tmpnam))))
diff --git a/tissue/issue.scm b/tissue/issue.scm
index 469b033..14bd75f 100644
--- a/tissue/issue.scm
+++ b/tissue/issue.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;; Copyright © 2022 Frederick Muriuki Muriithi <fredmanglis@gmail.com>
;;;
;;; This file is part of tissue.
@@ -27,7 +27,6 @@
#:use-module (ice-9 regex)
#:use-module (oop goops)
#:use-module (term ansi-color)
- #:use-module (git)
#:use-module (web uri)
#:use-module (xapian xapian)
#:use-module (tissue document)
@@ -48,8 +47,7 @@
print-issue
print-issue-to-gemtext
issues
- read-gemtext-issue
- index-issue))
+ read-gemtext-issue))
(define-class <issue> (<file-document>)
(assigned #:accessor issue-assigned #:init-keyword #:assigned)
@@ -58,18 +56,26 @@
(tasks #:accessor issue-tasks #:init-keyword #:tasks)
(completed-tasks #:accessor issue-completed-tasks #:init-keyword #:completed-tasks))
+(define-method (document-type (issue <issue>))
+ "issue")
+
(define-method (document-boolean-terms (issue <issue>))
"Return the boolean terms in ISSUE."
- (append (list (string-append "A" (file-document-creator issue))
- (string-append "XA" (file-document-last-updater issue))
- (string-append "XS" (if (issue-open? issue)
+ (append (list (string-append "XS" (if (issue-open? issue)
"open" "closed")))
- (map (cut string-append "XI" <>)
- (issue-assigned issue))
(map (cut string-append "K" <>)
(issue-keywords issue))
(next-method)))
+(define-method (document-term-generator (issue <issue>))
+ "Return a term generator indexing ISSUE."
+ (let ((term-generator (next-method)))
+ (index-text! term-generator (file-document-creator issue) #:prefix "A")
+ (index-text! term-generator (file-document-last-updater issue) #:prefix "XA")
+ (for-each (cut index-text! term-generator <> #:prefix "XI")
+ (issue-assigned issue))
+ term-generator))
+
(define-method (print (issue <issue>) mset port)
"Print ISSUE, an <issue> object, in search results."
(let ((number-of-posts (length (file-document-commits issue))))
@@ -160,89 +166,6 @@
(newline)
(newline)))
-(define (sanitize-string str)
- "Downcase STR and replace spaces with hyphens."
- (string-map (lambda (c)
- (case c
- ((#\space) #\-)
- (else c)))
- (string-downcase str)))
-
-(define-method (document->sxml (issue <issue>) mset)
- "Render ISSUE, an <issue> object, to SXML. MSET is the xapian MSet
-object representing a list of search results."
- `(li (@ (class ,(string-append "search-result search-result-issue "
- (if (issue-open? issue)
- "search-result-open-issue"
- "search-result-closed-issue"))))
- (a (@ (href ,(document-web-uri issue))
- (class "search-result-title"))
- ,(document-title issue))
- (ul (@ (class "tags"))
- ,@(map (lambda (tag)
- (let ((words (string-split tag (char-set #\- #\space))))
- `(li (@ (class
- ,(string-append "tag"
- (string-append " tag-" (sanitize-string tag))
- (if (not (null? (lset-intersection
- string=? words
- (list "bug" "critical"))))
- " tag-bug"
- "")
- (if (not (null? (lset-intersection
- string=? words
- (list "progress"))))
- " tag-progress"
- "")
- (if (not (null? (lset-intersection
- string=? words
- (list "chore"))))
- " tag-chore"
- "")
- (if (not (null? (lset-intersection
- string=? words
- (list "enhancement" "feature"))))
- " tag-feature"
- ""))))
- (a (@ (href ,(string-append
- "/search?query="
- (uri-encode
- ;; Quote tag if it has spaces.
- (string-append "tag:"
- (if (string-any #\space tag)
- (string-append "\"" tag "\"")
- tag))))))
- ,tag))))
- (issue-keywords issue)))
- (div (@ (class "search-result-metadata"))
- (span (@ (class ,(string-append "document-type issue-document-type "
- (if (issue-open? issue)
- "open-issue-document-type"
- "closed-issue-document-type"))))
- ,(if (issue-open? issue)
- "issue"
- "✓ issue"))
- ,(string-append
- (format #f " opened ~a by ~a"
- (human-date-string (file-document-created-date issue))
- (file-document-creator issue))
- (if (> (length (file-document-commits issue))
- 1)
- (format #f ", last updated ~a by ~a"
- (human-date-string (file-document-last-updated-date issue))
- (file-document-last-updater issue))
- "")
- (if (zero? (issue-tasks issue))
- ""
- (format #f "; ~a of ~a tasks done"
- (issue-completed-tasks issue)
- (issue-tasks issue)))))
- ,@(let ((snippet (document-sxml-snippet issue mset)))
- (if snippet
- (list `(div (@ (class "search-result-snippet"))
- ,@snippet))
- (list)))))
-
(define (hashtable-prepend! hashtable key new-values)
"Prepend NEW-VALUES to the list of values KEY is associated to in
HASHTABLE. Deduplicate the resulting list if necessary. If KEY is not
@@ -291,17 +214,23 @@ return #f."
(define (file-details port)
"Return a hashtable of details extracted from input PORT reading a
gemtext file."
- (let ((result (make-eq-hashtable)))
+ (let ((result (make-eq-hashtable))
+ (in-preformatted #f))
(port-transduce (tmap (lambda (line)
(cond
+ ;; Toggle preformatted state.
+ ((string=? "```" line)
+ (set! in-preformatted (not in-preformatted)))
+ ;; Ignore preformatted blocks.
+ (in-preformatted #t)
;; Checkbox lists are tasks. If the
;; checkbox has any character other
;; than space in it, the task is
;; completed.
- ((string-match "^\\* \\[(.)\\]" line)
+ ((string-match "^\\* \\[(.*)\\]" line)
=> (lambda (m)
(hashtable-update! result 'tasks 1+ 0)
- (unless (string=? (match:substring m 1) " ")
+ (unless (string-blank? (match:substring m 1))
(hashtable-update! result 'completed-tasks 1+ 0))))
((list-line->alist line)
=> (lambda (alist)
@@ -350,9 +279,10 @@ gemtext file."
result))
(define (read-gemtext-issue file)
- "Read issue from gemtext FILE. Return an <issue> object."
+ "Read issue from gemtext @var{file} and return an @code{<issue>}
+object."
(let* ((file-document (read-gemtext-document file))
- (file-details (call-with-file-in-git (current-git-repository) file
+ (file-details (call-with-input-file file
file-details))
;; Downcase keywords to make them
;; case-insensitive.
@@ -370,4 +300,5 @@ gemtext file."
#:open? (not (member "closed" all-keywords))
#:tasks (hashtable-ref file-details 'tasks 0)
#:completed-tasks (hashtable-ref file-details 'completed-tasks 0)
- #:commits (file-document-commits file-document))))
+ #:commits (file-document-commits file-document)
+ #:snippet-source-text (document-snippet-source-text file-document))))
diff --git a/tissue/search.scm b/tissue/search.scm
index bc60c19..b9feafc 100644
--- a/tissue/search.scm
+++ b/tissue/search.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -49,13 +49,13 @@ mapping field names to prefixes."
query-parser))
(define %prefixes
- '(("title" . "S")))
+ '(("title" . "S")
+ ("creator" . "A")
+ ("lastupdater" . "XA")
+ ("assigned" . "XI")))
(define %boolean-prefixes
'(("type" . "XT")
- ("creator" . "A")
- ("lastupdater" . "XA")
- ("assigned" . "XI")
("keyword" . "K")
("tag" . "K")
("is" . "XS")))
@@ -97,7 +97,7 @@ when PRED returns #f."
query))
(define* (search-fold proc initial db search-query
- #:key (offset 0) (maximum-items (database-document-count db)))
+ #:key (offset 0) (maximum-items 1000))
"Search xapian database DB using SEARCH-QUERY and fold over the
results using PROC and INITIAL.
@@ -110,20 +110,21 @@ first call.
OFFSET specifies the number of items to ignore at the beginning of the
result set. MAXIMUM-ITEMS specifies the maximum number of items to
return."
- (mset-fold (lambda (item result)
- (proc (call-with-input-string (document-data (mset-item-document item))
- (compose scm->object read))
- (MSetIterator-mset-get item)
- result))
- initial
- (enquire-mset (let* ((query (parse-query search-query))
- (enquire (enquire db query)))
- ;; Sort by recency date (slot 0) when
- ;; query is strictly boolean.
- (when (boolean-query? query)
- (Enquire-set-sort-by-value enquire 0 #t))
- enquire)
- #:maximum-items maximum-items)))
+ (let ((mset (enquire-mset (let* ((query (parse-query search-query))
+ (enquire (enquire db query)))
+ ;; Sort by recency date (slot 0) when
+ ;; query is strictly boolean.
+ (when (boolean-query? query)
+ (Enquire-set-sort-by-value enquire 0 #t))
+ enquire)
+ #:maximum-items maximum-items)))
+ (mset-fold (lambda (item result)
+ (proc (call-with-input-string (document-data (mset-item-document item))
+ (compose scm->object read))
+ mset
+ result))
+ initial
+ mset)))
(define* (search-map proc db search-query
#:key (offset 0) (maximum-items (database-document-count db)))
diff --git a/tissue/skribilo.scm b/tissue/skribilo.scm
new file mode 100644
index 0000000..8a0a929
--- /dev/null
+++ b/tissue/skribilo.scm
@@ -0,0 +1,104 @@
+;;; tissue --- Text based issue tracker
+;;; Copyright © 2023 Arun Isaac <arunisaac@systemreboot.net>
+;;;
+;;; This file is part of tissue.
+;;;
+;;; tissue is free software: you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation, either version 3 of the License, or
+;;; (at your option) any later version.
+;;;
+;;; tissue is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with tissue. If not, see <https://www.gnu.org/licenses/>.
+
+(define-module (tissue skribilo)
+ #:use-module (rnrs conditions)
+ #:use-module (rnrs exceptions)
+ #:use-module (srfi srfi-26)
+ #:use-module (ice-9 match)
+ #:use-module (oop goops)
+ #:use-module (skribilo ast)
+ #:use-module (skribilo evaluator)
+ #:use-module (skribilo reader)
+ #:use-module (tissue document)
+ #:use-module (tissue file-document)
+ #:use-module (tissue utils)
+ #:export (<skribilo-fragment>
+ skribilo-fragment-filename
+ skribilo-fragment-identifier
+ document-fragment))
+
+(define-class <skribilo-fragment> (<file-document>)
+ (identifier #:getter skribilo-fragment-identifier #:init-keyword #:identifier)
+ (reader-name #:getter skribilo-fragment-reader-name #:init-keyword #:reader-name))
+
+(define (document-node file identifier reader-name)
+ "Return @code{<markup>} object describing node identified by
+@var{identifier} in @var{file} read using reader named by
+@var{reader-name}."
+ (find1-down (lambda (node)
+ (and (is-a? node <markup>)
+ (markup-ident node)
+ (string=? (markup-ident node) identifier)))
+ (call-with-input-file file
+ (cut evaluate-ast-from-port <> #:reader (make-reader reader-name)))))
+
+(define (fragment-text file identifier reader-name)
+ "Return the full text of skribilo fragment in @var{file} identified by
+@var{identifier} using reader named by @var{reader-name}."
+ (call-with-output-string
+ (cut ast->text
+ (document-node file identifier reader-name)
+ <>)))
+
+(define* (document-fragment file identifier #:key (reader-name 'skribe))
+ "Return a @code{<skribilo-fragment>} object describing node identified
+by @var{identifier} in @var{file} read using reader named by
+@var{reader-name}."
+ (make <skribilo-fragment>
+ #:title (ast->string
+ (markup-option (document-node file identifier reader-name)
+ #:title))
+ #:path file
+ #:commits (commits-affecting-file file)
+ #:identifier identifier
+ #:reader-name reader-name
+ #:snippet-source-text (fragment-text file identifier reader-name)))
+
+(define-method (document-id-term (fragment <skribilo-fragment>))
+ "Return the ID term for skribilo @var{fragment}."
+ (string-append "Qskribilofragment."
+ (file-document-path fragment)
+ "#"
+ (skribilo-fragment-identifier fragment)))
+
+(define (ast->text node port)
+ "Serialize AST @var{node} into text suitable for indexing. Write
+output to @var{port}."
+ (cond
+ ((is-a? node <node>)
+ (for-each (match-lambda
+ ((_ . value)
+ (display (ast->string value) port)))
+ (node-options node))
+ (newline port)
+ (ast->text (node-body node) port))
+ ((string? node)
+ (display node port))
+ ((number? node)
+ (display (number->string node) port))
+ ((list? node)
+ (for-each (lambda (element)
+ (ast->text element port) port)
+ node))))
+
+(define-method (document-text (fragment <skribilo-fragment>))
+ "Return the full text of skribilo @var{fragment}."
+ (fragment-text (file-document-path fragment)
+ (skribilo-fragment-identifier fragment)
+ (skribilo-fragment-reader-name fragment)))
diff --git a/tissue/tissue.scm b/tissue/tissue.scm
index e7637b4..9180467 100644
--- a/tissue/tissue.scm
+++ b/tissue/tissue.scm
@@ -22,82 +22,96 @@
#:use-module (srfi srfi-71)
#:use-module (ice-9 match)
#:use-module (tissue git)
+ #:use-module (tissue web themes default)
#:export (tissue-configuration
tissue-configuration?
- tissue-configuration-project
tissue-configuration-aliases
tissue-configuration-indexed-documents
- tissue-configuration-web-css
+ tissue-configuration-web-search-renderer
tissue-configuration-web-files
gemtext-files-in-directory))
(define-record-type <tissue-configuration>
- (make-tissue-configuration project aliases indexed-documents
- web-css web-files)
+ (make-tissue-configuration aliases indexed-documents
+ web-search-renderer web-files)
tissue-configuration?
- (project tissue-configuration-project)
- (aliases tissue-configuration-aliases)
+ (aliases delayed-tissue-configuration-aliases)
(indexed-documents delayed-tissue-configuration-indexed-documents)
- (web-css tissue-configuration-web-css)
+ (web-search-renderer delayed-tissue-configuration-web-search-renderer)
(web-files delayed-tissue-configuration-web-files))
+(define tissue-configuration-aliases
+ (compose force delayed-tissue-configuration-aliases))
+
(define tissue-configuration-indexed-documents
(compose force delayed-tissue-configuration-indexed-documents))
+(define tissue-configuration-web-search-renderer
+ (compose force delayed-tissue-configuration-web-search-renderer))
+
(define tissue-configuration-web-files
(compose force delayed-tissue-configuration-web-files))
(define* (gemtext-files-in-directory #:optional directory)
- "Return a list of all gemtext files in DIRECTORY tracked in the
-current git repository. If DIRECTORY is #f, return the list of all
-gemtext files tracked in the current git repository regardless of
-which directory they are in."
+ "Return a list of all gemtext files in @var{directory} tracked in the
+current git repository. The returned paths are relative to the
+top-level directory of the current repository and do not have a
+leading slash.
+
+If @var{directory} is unspecified, return the list of all gemtext
+files tracked in the current git repository regardless of which
+directory they are in."
(filter (lambda (filename)
(and (or (not directory)
(string-prefix? directory filename))
(string-suffix? ".gmi" filename)))
(git-tracked-files (current-git-repository))))
-(define (pairify lst)
- "Return a list of pairs of successive elements of LST. For example,
-
-(pairify (list 1 2 3 4 5 6))
-=> ((1 . 2) (3 . 4) (5 . 6))"
- (match lst
- (() '())
- ((first second tail ...)
- (cons (cons first second)
- (pairify tail)))))
-
-(define-syntax tissue-configuration
+(define-syntax define-lazy
(lambda (x)
+ "Define function that lazily evaluates all its arguments."
(syntax-case x ()
- ((_ args ...)
- #`((lambda* (#:key project (aliases '())
- (indexed-documents (delay '()))
- web-css (web-files (delay '())))
- "PROJECT is the name of the project. It is used in the title of the
-generated web pages, among other places.
+ ((_ (name formal-args ...) body ...)
+ (with-syntax ((delayed-formal-args
+ (map (lambda (formal-arg)
+ (syntax-case formal-arg ()
+ ((name default-value)
+ #'(name (delay default-value)))
+ (x #'x)))
+ #'(formal-args ...))))
+ #`(define-syntax name
+ (lambda (x)
+ (with-ellipsis :::
+ (syntax-case x ()
+ ((_ args :::)
+ #`((lambda* delayed-formal-args
+ body ...)
+ #,@(map (lambda (arg)
+ (if (keyword? (syntax->datum arg))
+ arg
+ #`(delay #,arg)))
+ #'(args :::)))))))))))))
+
+(define-lazy (tissue-configuration #:key (aliases '()) (indexed-documents '())
+ (web-search-renderer (default-theme))
+ (web-files '()))
+ "Construct a <tissue-configuration> object. All arguments are
+evaluated lazily.
-ALIASES is a list of aliases used to refer to authors in the
+@var{aliases} is a list of aliases used to refer to authors in the
repository. Each element is in turn a list of aliases an author goes
by, the first of which is the canonical name of that author.
-INDEXED-DOCUMENTS is a list of <indexed-documents> objects
-representing documents to index.
+@var{indexed-documents} is a list of @code{<document>} objects (or
+objects of classes inheriting from @code{<document>}) representing
+documents to index.
-WEB-CSS is the path to a CSS stylesheet. It is relative to the
-document root and must begin with a /. If it is #f, no stylesheet is
-used in the generated web pages.
+@var{web-search-renderer} is a function that accepts two arguments---a
+@code{<search-page>} object describing the search page and a
+@code{<tissue-configuration>} object describing the project. It must
+return the rendered SXML.
-WEB-FILES is a list of <file> objects representing files to be written
-to the web output."
- (make-tissue-configuration project aliases
- indexed-documents web-css web-files))
- #,@(append-map (match-lambda
- ((key . value)
- (if (memq (syntax->datum key)
- (list #:indexed-documents #:web-files))
- #`(#,key (delay #,value))
- #`(#,key #,value))))
- (pairify #'(args ...))))))))
+@var{web-files} is a list of @code{<file>} objects representing files to be
+written to the web output."
+ (make-tissue-configuration aliases indexed-documents
+ web-search-renderer web-files))
diff --git a/tissue/utils.scm b/tissue/utils.scm
index 59c0b0a..14cc243 100644
--- a/tissue/utils.scm
+++ b/tissue/utils.scm
@@ -21,7 +21,9 @@
#:use-module (srfi srfi-19)
#:use-module (srfi srfi-26)
#:use-module (ice-9 filesystem)
+ #:use-module (ice-9 match)
#:use-module (ice-9 popen)
+ #:use-module (web uri)
#:export (string-blank?
string-contains?
string-remove-prefix
@@ -31,7 +33,9 @@
call-with-temporary-directory
call-with-output-pipe
get-line-dos-or-unix
- memoize-thunk))
+ memoize-thunk
+ query-parameters
+ query-string))
(define (string-blank? str)
"Return #t if STR contains only whitespace. Else, return #f."
@@ -120,3 +124,25 @@ ports) in that it also supports DOS line endings."
(unless result
(set! result (thunk)))
result)))
+
+(define (query-parameters query)
+ "Return an association list of query parameters in web QUERY string."
+ (if query
+ (map (lambda (parameter)
+ (match (string-split parameter #\=)
+ ((key value)
+ (cons (uri-decode key)
+ (uri-decode value)))))
+ (string-split query #\&))
+ '()))
+
+(define (query-string parameters)
+ "Return a query string for association list of PARAMETERS."
+ (string-join
+ (map (match-lambda
+ ((key . value)
+ (string-append (uri-encode key)
+ "="
+ (uri-encode value))))
+ parameters)
+ "&"))
diff --git a/tissue/web/dev.scm b/tissue/web/dev.scm
new file mode 100644
index 0000000..5ca7d16
--- /dev/null
+++ b/tissue/web/dev.scm
@@ -0,0 +1,86 @@
+;;; tissue --- Text based issue tracker
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
+;;;
+;;; This file is part of tissue.
+;;;
+;;; tissue is free software: you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation, either version 3 of the License, or
+;;; (at your option) any later version.
+;;;
+;;; tissue is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with tissue. If not, see <https://www.gnu.org/licenses/>.
+
+(define-module (tissue web dev)
+ #:use-module (rnrs io ports)
+ #:use-module (srfi srfi-1)
+ #:use-module (srfi srfi-26)
+ #:use-module (ice-9 filesystem)
+ #:use-module (web request)
+ #:use-module (web response)
+ #:use-module (web server)
+ #:use-module (web uri)
+ #:use-module (xapian xapian)
+ #:use-module (tissue tissue)
+ #:use-module (tissue utils)
+ #:use-module (tissue web server)
+ #:use-module (tissue web static)
+ #:export (start-dev-web-server))
+
+(define (handler request body xapian-index project-thunk)
+ "Handle web @var{request} with @var{body} and return two values---the
+response headers and body. See @code{start-dev-web-server} for
+documentation of @var{xapian-index} and @var{project-thunk}."
+ ;; The project configuration could have changed between requests and
+ ;; we want to read the latest configuration on each request. So, we
+ ;; require a thunk that loads the project configuration, rather than
+ ;; the project configuration itself.
+ (let ((project (project-thunk))
+ (path (uri-path (request-uri request))))
+ (log-request request)
+ (cond
+ ;; Files
+ ((any (lambda (web-file)
+ (cond
+ ((find (cut string=?
+ (string-append "/" (file-name web-file))
+ <>)
+ (try-paths path))
+ => (cut file <> (file-writer web-file)))
+ (else #f)))
+ (tissue-configuration-web-files project))
+ => (lambda (file)
+ (values `((content-type . ,(mime-type-for-extension
+ (file-name-extension (file-name file)))))
+ (call-with-values open-bytevector-output-port
+ (lambda (port get-bytevector)
+ ((file-writer file) port)
+ (get-bytevector))))))
+ ;; Search page. We look for the search page only after files
+ ;; because we want to let files shadow the search page if
+ ;; necessary.
+ ((member path (list "/" "/search"))
+ (search-handler request body xapian-index project))
+ ;; Not found
+ (else
+ (404-response request)))))
+
+(define (start-dev-web-server port xapian-index project-thunk)
+ "Start development web server listening on
+@var{port}. @var{xapian-index} is the path to the Xapian index to
+search in. @var{project} is a thunk that returns a
+@code{<tissue-configuration>} object describing the project."
+ (format (current-error-port)
+ "Tissue development web server listening at http://localhost:~a~%" port)
+ ;; Explicitly dereference the module and handler variable each time
+ ;; so as to support live hacking.
+ (run-server (cut (module-ref (resolve-module '(tissue web dev))
+ 'handler)
+ <> <> xapian-index project-thunk)
+ 'http
+ (list #:port port)))
diff --git a/tissue/web/server.scm b/tissue/web/server.scm
index fa26aa5..e8ee9eb 100644
--- a/tissue/web/server.scm
+++ b/tissue/web/server.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -25,6 +25,7 @@
#:use-module (srfi srfi-171)
#:use-module (ice-9 filesystem)
#:use-module (ice-9 match)
+ #:use-module (oop goops)
#:use-module (htmlprag)
#:use-module (sxml simple)
#:use-module ((system repl server) #:select (make-unix-domain-server-socket))
@@ -38,182 +39,24 @@
(case symbol
((parse-query) 'xapian:parse-query)
(else symbol))))
- #:use-module (tissue document)
#:use-module (tissue git)
#:use-module (tissue search)
+ #:use-module (tissue tissue)
#:use-module (tissue utils)
- #:export (start-web-server))
-
-(define %css
- "
-body {
- max-width: 1000px;
- margin: 0 auto;
-}
-
-form { text-align: center; }
-.search-filter {
- background-color: gray;
- color: white;
- padding: 0 0.2em;
-}
-
-.search-results-statistics {
- list-style: none;
- padding: 0;
-}
-.search-results-statistics li {
- display: inline;
- margin: 0.5em;
-}
-.search-results-statistics a { color: blue; }
-.current-search-type { font-weight: bold; }
-
-.search-results { padding: 0; }
-.search-result {
- list-style-type: none;
- padding: 0.5em;
-}
-.search-result a { text-decoration: none; }
-.document-type {
- font-variant: small-caps;
- font-weight: bold;
-}
-.search-result-metadata {
- color: dimgray;
- font-size: smaller;
-}
-.search-result-snippet { font-size: smaller; }
-
-.tags {
- list-style-type: none;
- padding: 0;
- display: inline;
-}
-.tag { display: inline; }
-.tag a {
- padding: 0 0.2em;
- color: white;
- background-color: blue;
- margin: auto 0.25em;
- font-size: smaller;
-}
-.tag-bug a { background-color: red; }
-.tag-feature a { background-color: green; }
-.tag-progress a, .tag-unassigned a {
- background-color: orange;
- color: black;
-}
-.tag-chore a {
- background-color: khaki;
- color: black;
-}")
-
-(define* (make-search-page results query css
- #:key
- page-uri-path page-uri-parameters
- matches
- matched-open-issues matched-closed-issues
- matched-documents matched-commits
- current-search-type)
- "Return SXML for a page with search RESULTS produced for QUERY.
-
-CSS is a URI to a stylesheet. PAGE-URI-PATH is the path part of the
-URI to the page. PAGE-URI-PARAMETERS is an association list of
-parameters in the query string of the URI of the page.
-
-MATCHES is the number of matches. MATCHED-OPEN-ISSUES,
-MATCHED-CLOSED-ISSUES, MATCHED-DOCUMENTS and MATCHED-COMMITS are
-respectively the number of open issues, closed issues, documents and
-commits matching the current query. CURRENT-SEARCH-TYPE is the type of
-document search results are being showed for."
- `(html
- (head
- (title "Tissue search")
- (style ,%css)
- ,@(if css
- (list `(link (@ (href "/style.css")
- (rel "stylesheet")
- (type "text/css"))))
- (list)))
- (body
- (form (@ (action "/search") (method "GET"))
- (input (@ (type "text")
- (name "query")
- (value ,query)
- (placeholder "Enter search query")))
- (input (@ (type "hidden")
- (name "type")
- (value ,(symbol->string current-search-type))))
- (input (@ (type "submit") (value "Search"))))
- (details (@ (class "search-hint"))
- (summary "Hint")
- (p "Refine your search with filters "
- ,@(append-map (lambda (filter)
- (list `(span (@ (class "search-filter"))
- ,filter)
- ", "))
- (list "type:issue"
- "type:document"
- "is:open"
- "is:closed"
- "title:git"
- "creator:mani"
- "lastupdater:vel"
- "assigned:muthu"
- "tag:feature-request"))
- "etc. Optionally, combine search terms with boolean
-operators "
- (span (@ (class "search-filter"))
- "AND")
- " and "
- (span (@ (class "search-filter"))
- "OR")
- ". See " (a (@ (href "https://xapian.org/docs/queryparser.html"))
- "Xapian::QueryParser Syntax")
- " for detailed documentation."))
- ,(let ((search-result-statistic
- (lambda (search-type format-string matches)
- `(li (a (@ (href ,(string-append
- page-uri-path
- "?"
- (query-string
- (acons "type" (symbol->string search-type)
- (alist-delete "type" page-uri-parameters)))))
- ,@(if (eq? search-type current-search-type)
- '((class "current-search-type"))
- '()))
- ,(format #f format-string matches))))))
- `(ul (@ (class "search-results-statistics"))
- ,(search-result-statistic 'all "~a All" matches)
- ,(search-result-statistic 'open-issue "~a open issues" matched-open-issues)
- ,(search-result-statistic 'closed-issue "~a closed issues" matched-closed-issues)
- ,(search-result-statistic 'document "~a documents" matched-documents)
- ,(search-result-statistic 'commit "~a commits" matched-commits)))
- (ul (@ (class "search-results"))
- ,@results))))
-
-(define (query-parameters query)
- "Return an association list of query parameters in web QUERY string."
- (if query
- (map (lambda (parameter)
- (match (string-split parameter #\=)
- ((key value)
- (cons (uri-decode key)
- (uri-decode value)))))
- (string-split query #\&))
- '()))
-
-(define (query-string parameters)
- "Return a query string for association list of PARAMETERS."
- (string-join
- (map (match-lambda
- ((key . value)
- (string-append (uri-encode key)
- "="
- (uri-encode value))))
- parameters)
- "&"))
+ #:use-module (tissue web themes)
+ #:export (log-request
+ mime-type-for-extension
+ try-paths
+ 404-response
+ search-handler
+ start-web-server))
+
+(define (log-request request)
+ "Log @var{request} to standard output."
+ (display (request-method request))
+ (display " ")
+ (display (uri->string (request-uri request)))
+ (newline))
(define %mime-types
'(("gif" image/gif)
@@ -227,6 +70,20 @@ operators "
("svg" image/svg+xml)
("txt" text/plain)))
+(define (mime-type-for-extension extension)
+ "Return the mime type for @var{extension}."
+ (or (assoc-ref %mime-types (if (string-null? extension)
+ extension
+ (string-remove-prefix "." extension)))
+ '(application/octet-stream)))
+
+(define (404-response request)
+ "Return a response and body for a 404 error corresponding to
+@var{request}."
+ (values (build-response #:code 404)
+ (string-append "Resource not found: "
+ (uri->string (request-uri request)))))
+
(define (matches db query filter)
"Return the number of matches in DB for QUERY filtering with FILTER
query. QUERY and FILTER are Xapian Query objects."
@@ -236,85 +93,78 @@ query. QUERY and FILTER are Xapian Query objects."
db (new-Query (Query-OP-FILTER) query filter))
#:maximum-items (database-document-count db))))
+(define (search-handler request body xapian-index project)
+ (let* ((parameters (query-parameters (uri-query (request-uri request))))
+ (search-query (or (assoc-ref parameters "query")
+ ""))
+ (search-type (match (assoc-ref parameters "type")
+ ((or "open-issue" "closed-issue" "commit" "document")
+ (string->symbol (assoc-ref parameters "type")))
+ (_ 'all)))
+ (filter-alist `((open-issue . ,(parse-query "type:issue AND is:open"))
+ (closed-issue . ,(parse-query "type:issue AND is:closed"))
+ (commit . ,(parse-query "type:commit"))
+ (document . ,(parse-query "type:document")))))
+ (values '((content-type . (text/html)))
+ (sxml->html
+ (call-with-database xapian-index
+ (lambda (db)
+ ((tissue-configuration-web-search-renderer project)
+ (let ((query (parse-query search-query)))
+ (make <search-page>
+ #:uri (request-uri request)
+ #:query search-query
+ #:type search-type
+ #:mset (enquire-mset
+ (let* ((query (new-Query (Query-OP-FILTER)
+ query
+ (or (assq-ref filter-alist search-type)
+ (Query-MatchAll))))
+ (enquire (enquire db query)))
+ ;; Sort by recency date (slot 0) when
+ ;; query is strictly boolean.
+ (when (boolean-query? query)
+ (Enquire-set-sort-by-value enquire 0 #t))
+ enquire)
+ #:offset 0
+ #:maximum-items 1000)
+ #:matches (matches db query (Query-MatchAll))
+ #:matched-open-issues (matches db query (assq-ref filter-alist 'open-issue))
+ #:matched-closed-issues (matches db query (assq-ref filter-alist 'closed-issue))
+ #:matched-documents (matches db query (assq-ref filter-alist 'document))
+ #:matched-commits (matches db query (assq-ref filter-alist 'commit)))))))))))
+
+(define (try-paths path)
+ "Return a list of candidate paths to look for @var{path}."
+ (if (string-suffix? "/" path)
+ ;; Try path/index.html.
+ (list (string-append path "index.html"))
+ ;; Try path and path.html.
+ (list path
+ (string-append path ".html"))))
+
(define (handler request body hosts)
"Handle web REQUEST with BODY and return two values---the response
headers and the body.
See `start-web-server' for documentation of HOSTS."
(let* ((path (uri-path (request-uri request)))
- (parameters (query-parameters (uri-query (request-uri request))))
(hostname (match (assq-ref (request-headers request) 'host)
((hostname . _) hostname)))
(host-parameters (or (assoc-ref hosts hostname)
(raise (condition
(make-message-condition "Unknown host")
- (make-irritants-condition hostname))))))
- (format #t "~a ~a\n"
- (request-method request)
- path)
+ (make-irritants-condition hostname)))))
+ (repository-directory (assq-ref host-parameters 'repository-directory)))
+ (log-request request)
(parameterize ((%current-git-repository
- (repository-open
- (assq-ref host-parameters 'repository-directory))))
+ (repository-open repository-directory)))
(cond
- ;; Search page
- ((member path (list "/" "/search"))
- (let* ((search-query (or (assoc-ref parameters "query")
- ""))
- (search-type (match (assoc-ref parameters "type")
- ((or "open-issue" "closed-issue" "commit" "document")
- (string->symbol (assoc-ref parameters "type")))
- (_ 'all)))
- (filter-alist `((open-issue . ,(parse-query "type:issue AND is:open"))
- (closed-issue . ,(parse-query "type:issue AND is:closed"))
- (commit . ,(parse-query "type:commit"))
- (document . ,(parse-query "type:document")))))
- (values '((content-type . (text/html)))
- (sxml->html
- (call-with-database (assq-ref host-parameters 'xapian-directory)
- (lambda (db)
- (let* ((query (parse-query search-query))
- (mset (enquire-mset
- (let* ((query (new-Query (Query-OP-FILTER)
- query
- (or (assq-ref filter-alist search-type)
- (Query-MatchAll))))
- (enquire (enquire db query)))
- ;; Sort by recency date (slot
- ;; 0) when query is strictly
- ;; boolean.
- (when (boolean-query? query)
- (Enquire-set-sort-by-value enquire 0 #t))
- enquire)
- #:offset 0
- #:maximum-items (database-document-count db))))
- (make-search-page
- (reverse
- (mset-fold (lambda (item result)
- (cons (document->sxml
- (call-with-input-string (document-data (mset-item-document item))
- (compose scm->object read))
- mset)
- result))
- '()
- mset))
- search-query
- (assq-ref host-parameters 'css)
- #:page-uri-path path
- #:page-uri-parameters parameters
- #:matches (matches db query (Query-MatchAll))
- #:matched-open-issues (matches db query (assq-ref filter-alist 'open-issue))
- #:matched-closed-issues (matches db query (assq-ref filter-alist 'closed-issue))
- #:matched-documents (matches db query (assq-ref filter-alist 'document))
- #:matched-commits (matches db query (assq-ref filter-alist 'commit))
- #:current-search-type search-type))))))))
;; Static files
((let ((file-path
(find file-exists?
- ;; Try path and path.html.
- (list (string-append (assq-ref host-parameters 'website-directory)
- "/" path)
- (string-append (assq-ref host-parameters 'website-directory)
- "/" path ".html")))))
+ (map (cut string-append (assq-ref host-parameters 'website-directory) <>)
+ (try-paths path)))))
(and file-path
;; Check that the file really is within the document
;; root.
@@ -322,16 +172,20 @@ See `start-web-server' for documentation of HOSTS."
(canonicalize-path file-path))
(canonicalize-path file-path)))
=> (lambda (file-path)
- (values `((content-type . ,(or (assoc-ref %mime-types (string-remove-prefix
- "." (file-name-extension file-path)))
- '(application/octet-stream))))
+ (values `((content-type . ,(mime-type-for-extension
+ (file-name-extension file-path))))
(call-with-input-file file-path
get-bytevector-all))))
+ ;; Search page. We look for the search page only after files
+ ;; because we want to let files shadow the search page if
+ ;; necessary.
+ ((member path (list "/" "/search"))
+ (search-handler request body
+ (assq-ref host-parameters 'xapian-directory)
+ (assq-ref host-parameters 'project)))
;; Not found
(else
- (values (build-response #:code 404)
- (string-append "Resource not found: "
- (uri->string (request-uri request)))))))))
+ (404-response request))))))
(define (start-web-server socket-address hosts)
"Start web server listening on SOCKET-ADDRESS.
@@ -356,24 +210,33 @@ list containing parameters for that host."
;; Unix socket
((= (sockaddr:fam socket-address) AF_UNIX)
(sockaddr:path socket-address))))
- (run-server (lambda (request body)
- ;; Explicitly dereference the module and handler
- ;; variable each time so as to support live hacking.
- ((module-ref (resolve-module '(tissue web server))
- 'handler)
- request body hosts))
- 'http
- (cond
- ;; IPv4 or IPv6 address
- ((or (= (sockaddr:fam socket-address) AF_INET)
- (= (sockaddr:fam socket-address) AF_INET6))
- (list #:family (sockaddr:fam socket-address)
- #:addr (sockaddr:addr socket-address)
- #:port (sockaddr:port socket-address)))
- ;; Unix socket
- ((= (sockaddr:fam socket-address) AF_UNIX)
- (let ((socket (make-unix-domain-server-socket
- #:path (sockaddr:path socket-address))))
- ;; Grant read-write permissions to all users.
- (chmod (sockaddr:path socket-address) #o666)
- (list #:socket socket))))))
+ (let ((unix-socket #f))
+ (dynamic-wind
+ (lambda ()
+ (when (= (sockaddr:fam socket-address) AF_UNIX)
+ (set! socket (make-unix-domain-server-socket
+ #:path (sockaddr:path socket-address)))
+ ;; Grant read-write permissions to all users.
+ (chmod (sockaddr:path socket-address) #o666)))
+ (cut run-server
+ (lambda (request body)
+ ;; Explicitly dereference the module and handler
+ ;; variable each time so as to support live hacking.
+ ((module-ref (resolve-module '(tissue web server))
+ 'handler)
+ request body hosts))
+ 'http
+ (cond
+ ;; IPv4 or IPv6 address
+ ((or (= (sockaddr:fam socket-address) AF_INET)
+ (= (sockaddr:fam socket-address) AF_INET6))
+ (list #:family (sockaddr:fam socket-address)
+ #:addr (sockaddr:addr socket-address)
+ #:port (sockaddr:port socket-address)))
+ ;; Unix socket
+ ((= (sockaddr:fam socket-address) AF_UNIX)
+ (list #:socket socket))))
+ (lambda ()
+ ;; Clean up socket file if Unix socket.
+ (when (= (sockaddr:fam socket-address) AF_UNIX)
+ (delete-file (sockaddr:path socket-address)))))))
diff --git a/tissue/web/static.scm b/tissue/web/static.scm
index 69a9d90..2b910cb 100644
--- a/tissue/web/static.scm
+++ b/tissue/web/static.scm
@@ -1,5 +1,5 @@
;;; tissue --- Text based issue tracker
-;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
;;;
;;; This file is part of tissue.
;;;
@@ -30,24 +30,22 @@
#:use-module (skribilo evaluator)
#:use-module (skribilo reader)
#:use-module (web uri)
+ #:use-module (git)
#:use-module (tissue git)
#:use-module (tissue issue)
#:use-module (tissue utils)
- #:export (%project-name
- file
+ #:export (file
file?
file-name
file-writer
replace-extension
copier
+ html-engine
gemtext-reader
gemtext-exporter
skribe-exporter
build-website))
-(define %project-name
- (make-parameter #f))
-
(define-record-type <file>
(file name writer)
file?
@@ -61,15 +59,15 @@ NEW-EXTENSION."
new-extension))
(define (exporter file proc)
- "Return a writer function that exports FILE using PROC. PROC is
-passed two arguments---the input port to read from and the output port
-to write to."
+ "Return a writer function that exports @var{file} using
+@var{proc}. @var{proc} is passed two arguments---the input port to
+read from and the output port to write to."
(lambda (out)
- (call-with-file-in-git (current-git-repository) file
+ (call-with-input-file file
(cut proc <> out))))
(define (copier file)
- "Return a writer function that copies FILE."
+ "Return a writer function that copies @var{file}."
(exporter file
(lambda (in out)
(port-transduce (tmap (cut put-bytevector out <>))
@@ -77,56 +75,63 @@ to write to."
get-bytevector-some
in))))
+(define (engine-custom-set engine key value)
+ "Set custom @var{key} of @var{engine} to @var{value}. This is a purely
+functional setter that operates on a copy of @var{engine}. It does not
+mutate @var{engine}."
+ (let ((clone (copy-engine (engine-ident engine) engine)))
+ (engine-custom-set! clone key value)
+ clone))
+
+(define* (html-engine #:key css)
+ "Return a new HTML engine.
+
+@var{css} is the URI to a CSS stylesheet. If it is @code{#f}, no
+stylesheet is included in the generated web pages."
+ (if css
+ (engine-custom-set (find-engine 'html)
+ 'css
+ (list css))
+ (find-engine 'html)))
+
(define (gemtext-reader)
"Return a skribilo reader for gemtext."
((reader:make (lookup-reader 'gemtext))
;; Relax the gemtext standard by joining adjacent lines.
#:join-lines? #t))
-(define* (gemtext-exporter file #:optional (reader (gemtext-reader)))
- "Return a writer function that exports FILE, a gemtext file."
- (exporter file
- (lambda (in out)
- (with-output-to-port out
- (cut evaluate-document
- (evaluate-ast-from-port in #:reader reader)
- (find-engine 'html))))))
+(define* (gemtext-exporter file #:key (reader (gemtext-reader))
+ (engine (html-engine)))
+ "Return a writer function that reads gemtext @var{file} using
+@var{reader} and exports it using @var{engine}."
+ (skribe-exporter file
+ #:reader reader
+ #:engine engine))
-(define* (skribe-exporter file #:optional (reader (make-reader 'skribe)))
- "Return a writer function that exports FILE, a skribe file."
+(define* (skribe-exporter file #:key (reader (make-reader 'skribe))
+ (engine (html-engine)))
+ "Return a writer function that reads skribe @var{file} using
+@var{reader} and exports it using @var{engine}."
(exporter file
(lambda (in out)
(with-output-to-port out
(cut evaluate-document
(evaluate-ast-from-port in #:reader reader)
- (find-engine 'html))))))
+ engine)))))
-(define (with-current-directory directory thunk)
- "Change current directory to DIRECTORY, execute THUNK and restore
-original current directory."
- (let ((previous-current-directory (getcwd)))
- (dynamic-wind (const #t)
- thunk
- (cut chdir previous-current-directory))))
-
-(define* (build-website repository-top-level output-directory css files
+(define* (build-website output-directory files
#:key (log-port (current-error-port)))
- "Export git repository with REPOSITORY-TOP-LEVEL to OUTPUT-DIRECTORY
-as a website.
-
-CSS is the path to a CSS stylesheet. If it is #f, no stylesheet is
-included in the generated web pages.
+ "Export git repository to OUTPUT-DIRECTORY as a website. The current
+directory must be the top level of the repository being exported.
FILES is a list of <file> objects representing files to be written to
the web output.
Log to LOG-PORT. When LOG-PORT is #f, do not log."
- ;; Set CSS.
- (when css
- (engine-custom-set! (find-engine 'html) 'css css))
;; Create output directory.
(make-directories output-directory)
- ;; Write each of the <file> objects.
+ ;; Move into a temporary clone of the git repository, and write each
+ ;; of the <file> objects.
(for-each (lambda (file)
(let ((output-file
(string-append output-directory "/" (file-name file))))
@@ -135,7 +140,5 @@ Log to LOG-PORT. When LOG-PORT is #f, do not log."
(newline log-port))
(make-directories (dirname output-file))
(call-with-output-file output-file
- (lambda (port)
- (with-current-directory repository-top-level
- (cut (file-writer file) port))))))
+ (cut (file-writer file) <>))))
files))
diff --git a/tissue/web/themes.scm b/tissue/web/themes.scm
new file mode 100644
index 0000000..648d4d5
--- /dev/null
+++ b/tissue/web/themes.scm
@@ -0,0 +1,42 @@
+;;; tissue --- Text based issue tracker
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
+;;;
+;;; This file is part of tissue.
+;;;
+;;; tissue is free software: you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation, either version 3 of the License, or
+;;; (at your option) any later version.
+;;;
+;;; tissue is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with tissue. If not, see <https://www.gnu.org/licenses/>.
+
+(define-module (tissue web themes)
+ #:use-module (srfi srfi-26)
+ #:use-module (oop goops)
+ #:export (<search-page>
+ search-page-uri
+ search-page-query
+ search-page-type
+ search-page-mset
+ search-page-matches
+ search-page-matched-open-issues
+ search-page-matched-closed-issues
+ search-page-matched-documents
+ search-page-matched-commits))
+
+(define-class <search-page> ()
+ (uri #:getter search-page-uri #:init-keyword #:uri)
+ (query #:getter search-page-query #:init-keyword #:query)
+ (type #:getter search-page-type #:init-keyword #:type)
+ (mset #:getter search-page-mset #:init-keyword #:mset)
+ (matches #:getter search-page-matches #:init-keyword #:matches)
+ (matched-open-issues #:getter search-page-matched-open-issues #:init-keyword #:matched-open-issues)
+ (matched-closed-issues #:getter search-page-matched-closed-issues #:init-keyword #:matched-closed-issues)
+ (matched-documents #:getter search-page-matched-documents #:init-keyword #:matched-documents)
+ (matched-commits #:getter search-page-matched-commits #:init-keyword #:matched-commits))
diff --git a/tissue/web/themes/default.scm b/tissue/web/themes/default.scm
new file mode 100644
index 0000000..10732ee
--- /dev/null
+++ b/tissue/web/themes/default.scm
@@ -0,0 +1,340 @@
+;;; tissue --- Text based issue tracker
+;;; Copyright © 2022, 2023 Arun Isaac <arunisaac@systemreboot.net>
+;;;
+;;; This file is part of tissue.
+;;;
+;;; tissue is free software: you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation, either version 3 of the License, or
+;;; (at your option) any later version.
+;;;
+;;; tissue is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with tissue. If not, see <https://www.gnu.org/licenses/>.
+
+(define-module (tissue web themes default)
+ #:use-module (srfi srfi-1)
+ #:use-module (ice-9 match)
+ #:use-module (oop goops)
+ #:use-module (web uri)
+ #:use-module (xapian xapian)
+ #:use-module (tissue commit)
+ #:use-module (tissue document)
+ #:use-module (tissue file-document)
+ #:use-module (tissue issue)
+ #:use-module (tissue utils)
+ #:use-module (tissue web themes)
+ #:export (default-theme
+ <search-page-head>
+ <search-page-header>
+ <search-page-form>
+ <search-page-result>
+ <search-page-footer>))
+
+(define-class <search-page-head> ())
+(define-class <search-page-header> ())
+(define-class <search-page-form> ())
+(define-class <search-page-result> ())
+(define-class <search-page-footer> ())
+
+(define %css
+ "
+body {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+form { text-align: center; }
+.search-filter {
+ background-color: gray;
+ color: white;
+ padding: 0 0.2em;
+}
+
+.search-results-statistics {
+ list-style: none;
+ padding: 0;
+}
+.search-results-statistics li {
+ display: inline;
+ margin: 0.5em;
+}
+.search-results-statistics a { color: blue; }
+.current-search-type { font-weight: bold; }
+
+.search-results { padding: 0; }
+.search-result {
+ list-style-type: none;
+ padding: 0.5em;
+}
+.search-result a { text-decoration: none; }
+.document-type {
+ font-variant: small-caps;
+ font-weight: bold;
+}
+.search-result-metadata {
+ color: dimgray;
+ font-size: smaller;
+}
+.search-result-snippet { font-size: smaller; }
+
+.tags {
+ list-style-type: none;
+ padding: 0;
+ display: inline;
+}
+.tag { display: inline; }
+.tag a {
+ padding: 0 0.2em;
+ color: white;
+ background-color: blue;
+ margin: auto 0.25em;
+ font-size: smaller;
+}
+.tag-bug a { background-color: red; }
+.tag-feature a { background-color: green; }
+.tag-progress a, .tag-unassigned a {
+ background-color: orange;
+ color: black;
+}
+.tag-chore a {
+ background-color: khaki;
+ color: black;
+}")
+
+(define* (default-theme #:key (title "tissue issue tracker") css)
+ "Return a generic function that renders a page using the default
+theme.
+
+@var{title} is the title to use in the head of the HTML. @var{css} is
+a URI to a CSS stylesheet to link to. If it is @code{#f}, no
+stylesheet is linked to."
+ (add-method! render-sxml
+ (make <method>
+ #:specializers (list <search-page-head> <search-page>)
+ #:procedure (make-head-renderer title css)))
+ render-sxml)
+
+(define-method (render-sxml (page <search-page>))
+ "Return SXML for @var{page}, a @code{<search-page>}."
+ `(html
+ ,(render-sxml (make <search-page-head>) page)
+ (body
+ ,(render-sxml (make <search-page-header>) page)
+ ,(render-sxml (make <search-page-form>) page)
+ ,(render-sxml (make <search-page-result>) page)
+ ,(render-sxml (make <search-page-footer>) page))))
+
+(define (make-head-renderer title css)
+ (lambda (_ page)
+ `(head
+ (title ,title)
+ (style ,%css)
+ ,@(if css
+ (list `(link (@ (href ,css)
+ (rel "stylesheet")
+ (type "text/css"))))
+ (list)))))
+
+(define-method (render-sxml (header <search-page-header>) (page <search-page>))
+ `(div))
+
+(define-method (render-sxml (form <search-page-form>) (page <search-page>))
+ `(div
+ (form (@ (action "/search") (method "GET"))
+ (input (@ (type "text")
+ (name "query")
+ (value ,(search-page-query page))
+ (placeholder "Enter search query")))
+ (input (@ (type "hidden")
+ (name "type")
+ (value ,(symbol->string (search-page-type page)))))
+ (input (@ (type "submit") (value "Search"))))
+ (details (@ (class "search-hint"))
+ (summary "Hint")
+ (p "Refine your search with filters "
+ ,@(append-map (lambda (filter)
+ (list `(span (@ (class "search-filter"))
+ ,filter)
+ ", "))
+ (list "type:issue"
+ "type:document"
+ "is:open"
+ "is:closed"
+ "title:git"
+ "creator:mani"
+ "lastupdater:vel"
+ "assigned:muthu"
+ "tag:feature-request"))
+ "etc. Optionally, combine search terms with boolean operators "
+ (span (@ (class "search-filter"))
+ "AND")
+ " and "
+ (span (@ (class "search-filter"))
+ "OR")
+ ". See " (a (@ (href "https://xapian.org/docs/queryparser.html"))
+ "Xapian::QueryParser Syntax")
+ " for detailed documentation."))))
+
+(define-method (render-sxml (result <search-page-result>) (page <search-page>))
+ (define (search-result-statistic search-type format-string matches)
+ `(li (a (@ (href ,(string-append
+ (uri-path (search-page-uri page))
+ "?"
+ (query-string
+ (acons "type" (symbol->string search-type)
+ (alist-delete "type"
+ (query-parameters
+ (uri-query (search-page-uri page))))))))
+ ,@(if (eq? search-type (search-page-type page))
+ '((class "current-search-type"))
+ '()))
+ ,(format #f format-string matches))))
+
+ `(div
+ (ul (@ (class "search-results-statistics"))
+ ,(search-result-statistic 'all "~a All" (search-page-matches page))
+ ,(search-result-statistic 'open-issue "~a open issues" (search-page-matched-open-issues page))
+ ,(search-result-statistic 'closed-issue "~a closed issues" (search-page-matched-closed-issues page))
+ ,(search-result-statistic 'document "~a documents" (search-page-matched-documents page))
+ ,(search-result-statistic 'commit "~a commits" (search-page-matched-commits page)))
+ (ul (@ (class "search-results"))
+ ,@(reverse
+ (mset-fold (lambda (item result)
+ (cons (render-sxml
+ (call-with-input-string (document-data (mset-item-document item))
+ (compose scm->object read))
+ page)
+ result))
+ '()
+ (search-page-mset page))))))
+
+(define-method (render-sxml (document <file-document>) (page <search-page>))
+ `(li (@ (class "search-result search-result-document"))
+ (a (@ (href ,(document-web-uri document))
+ (class "search-result-title"))
+ ,(document-title document))
+ (div (@ (class "search-result-metadata"))
+ (span (@ (class ,(string-append "document-type file-document-type")))
+ "document")
+ ,(string-append
+ (format #f " created ~a by ~a"
+ (human-date-string (file-document-created-date document))
+ (file-document-creator document))
+ (if (> (length (file-document-commits document))
+ 1)
+ (format #f ", last updated ~a by ~a"
+ (human-date-string (file-document-last-updated-date document))
+ (file-document-last-updater document))
+ "")))
+ ,@(let ((snippet (document-sxml-snippet document (search-page-mset page))))
+ (if snippet
+ (list `(div (@ (class "search-result-snippet"))
+ ,@snippet))
+ (list)))))
+
+(define (sanitize-string str)
+ "Downcase STR and replace spaces with hyphens."
+ (string-map (lambda (c)
+ (case c
+ ((#\space) #\-)
+ (else c)))
+ (string-downcase str)))
+
+(define-method (render-sxml (issue <issue>) (page <search-page>))
+ `(li (@ (class ,(string-append "search-result search-result-issue "
+ (if (issue-open? issue)
+ "search-result-open-issue"
+ "search-result-closed-issue"))))
+ (a (@ (href ,(document-web-uri issue))
+ (class "search-result-title"))
+ ,(document-title issue))
+ (ul (@ (class "tags"))
+ ,@(map (lambda (tag)
+ (let ((words (string-split tag (char-set #\- #\space))))
+ `(li (@ (class
+ ,(string-append "tag"
+ (string-append " tag-" (sanitize-string tag))
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "bug" "critical"))))
+ " tag-bug"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "progress"))))
+ " tag-progress"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "chore"))))
+ " tag-chore"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "enhancement" "feature"))))
+ " tag-feature"
+ ""))))
+ (a (@ (href ,(string-append
+ "/search?query="
+ (uri-encode
+ ;; Quote tag if it has spaces.
+ (string-append "tag:"
+ (if (string-any #\space tag)
+ (string-append "\"" tag "\"")
+ tag))))))
+ ,tag))))
+ (issue-keywords issue)))
+ (div (@ (class "search-result-metadata"))
+ (span (@ (class ,(string-append "document-type issue-document-type "
+ (if (issue-open? issue)
+ "open-issue-document-type"
+ "closed-issue-document-type"))))
+ ,(if (issue-open? issue)
+ "issue"
+ "✓ issue"))
+ ,(string-append
+ (format #f " opened ~a by ~a"
+ (human-date-string (file-document-created-date issue))
+ (file-document-creator issue))
+ (if (> (length (file-document-commits issue))
+ 1)
+ (format #f ", last updated ~a by ~a"
+ (human-date-string (file-document-last-updated-date issue))
+ (file-document-last-updater issue))
+ "")
+ (if (zero? (issue-tasks issue))
+ ""
+ (format #f "; ~a of ~a tasks done"
+ (issue-completed-tasks issue)
+ (issue-tasks issue)))))
+ ,@(let ((snippet (document-sxml-snippet issue (search-page-mset page))))
+ (if snippet
+ (list `(div (@ (class "search-result-snippet"))
+ ,@snippet))
+ (list)))))
+
+(define-method (render-sxml (commit <commit>) (page <search-page>))
+ `(li (@ (class ,(string-append "search-result search-result-commit")))
+ (a (@ (href ,(document-web-uri commit))
+ (class "search-result-title"))
+ ,(document-title commit))
+ (div (@ (class "search-result-metadata"))
+ (span (@ (class ,(string-append "document-type commit-document-type")))
+ "commit")
+ ,(string-append
+ (format #f " authored ~a by ~a"
+ (human-date-string (doc:commit-author-date commit))
+ (doc:commit-author commit))))
+ ,@(let ((snippet (document-sxml-snippet commit (search-page-mset page))))
+ (if snippet
+ (list `(div (@ (class "search-result-snippet"))
+ ,@snippet))
+ (list)))))
+
+(define-method (render-sxml (footer <search-page-footer>) (page <search-page>))
+ `(div))