"
+ "BLah italic bold ened "
+ " still < bold
But not done yet..."))
+ `(*TOP*
+ (html (head (title) (title "whatever"))
+ (body (a (@ (href "url")) "link")
+ (p (@ (align "center"))
+ (ul (@ (compact) (style "aa"))))
+ (p "BLah"
+ (*COMMENT* " comment ")
+ " "
+ (i " italic " (b " bold " (tt " ened ")))
+ " still < bold "))
+ (p " But not done yet..."))))
+
+ (test (html->xexp "")
+ `(*TOP*
+ (*PI* xml "version=\"1.0\" encoding=\"UTF-8\"")))
+
+ (test (html->xexp "")
+ `(*TOP* (*PI* php "php_info(); ")))
+ (test (html->xexp "xexp "xexp " blort ?>")
+ `(*TOP*
+ (*PI* foo "bar ? baz > blort ")))
+
+ (test (html->xexp "x")
+ `(*TOP* (*PI* foo "b") "x"))
+ (test (html->xexp "x")
+ `(*TOP* (*PI* foo "") "x"))
+ (test (html->xexp "x")
+ `(*TOP* (*PI* foo "") "x"))
+ (test (html->xexp "x")
+ `(*TOP* (*PI* foo "") "x"))
+ (test (html->xexp "x")
+ `(*TOP* (*PI* f "") "x"))
+ (test (html->xexp "?>x")
+ `(*TOP* (*PI* #f "") "x"))
+ (test (html->xexp ">x")
+ `(*TOP* (*PI* #f ">x")))
+
+ (test (html->xexp "blort")
+ `(*TOP* (foo (@ (bar "baz")) "blort")))
+ (test (html->xexp "blort")
+ `(*TOP* (foo (@ (bar "baz")) "blort")))
+ (test (html->xexp "blort")
+ `(*TOP* (foo (@ (bar "baz'>blort")))))
+ (test (html->xexp "xexp "")
+ `(*TOP* ,(string (integer->char 151)))
+ #:id 'once-again-converting-character-references-above-126-to-string)
+
+ (test (html->xexp "ab
")
+ `(*TOP* (ul (li "a" (p "b"))))
+ #:id 'p-element-can-be-child-of-li-element)
+
+ (test-section 'unterminated-named-character-entity-references
+
+ (test-section 'old-school-names
+ (test 'amp (html->xexp "a&z
") '(*TOP* (p "a&z")))
+ (test 'apos (html->xexp "a&aposz
") '(*TOP* (p "a'z")))
+ (test 'lt (html->xexp "a<z
") '(*TOP* (p "axexp "a>z
") '(*TOP* (p "a>z")))
+ (test 'quot (html->xexp "a"z
") '(*TOP* (p "a\"z"))))
+
+ (test-section 'new-school-names
+ (test 'rarr (html->xexp "a&rarrz
") '(*TOP* (p "a&rarrz"))))
+
+ (test-section 'unrecognized-names
+ (test 'a-t-and-t
+ (html->xexp "AT&T Bell Labs
")
+ '(*TOP* (p "AT&T Bell Labs")))))
+
+ (test-section 'terminated-old-school-named-character-entity-references
+ (test 'amp (html->xexp "a&z
") '(*TOP* (p "a&z")))
+ (test 'apos (html->xexp "a'z
") '(*TOP* (p "a'z")))
+ (test 'lt (html->xexp "a<z
") '(*TOP* (p "axexp "a>z
") '(*TOP* (p "a>z")))
+ (test 'quot (html->xexp "a"z
") '(*TOP* (p "a\"z"))))
+
+ (test-section 'parent-constraints-should-permit-p-inside-blockquote
+ (test 'basic
+ (html->xexp "AB
CD
E F ")
+ `(*TOP* (x "A"
+ (blockquote (p "B")
+ "C"
+ (p "D")
+ "E")
+ "F")))
+ (test 'initial-sorawee-porncharoenwase-example
+ (html->xexp
+ "
")
+ '(*TOP* (blockquote (tr (td (blockquote (p)))
+ (div))))))
+
+ (test-section 'p-elem-can-be-child-of-details-elem
+
+ (test 'initial-jacder-example-modified
+ (html->xexp "")
+ '(*TOP* (div (details (p "text in details"))))))
+
+ ;; TODO: 2022-01-22 Go through latest HTML elements and add them to parent constraints
+ ;; in the ancient quirks handling from over 20 years ago.
+
+ (test-section 'p-elem-cannot-be-child-of-p-elem
+
+ (test 'initial-simpson-example
+ (html->xexp "foo
bar")
+ '(*TOP* (html (p "foo") (p "bar"))))
+
+ (test 'non-p-parent-in-which-p-shouldnt-nest
+ (html->xexp "foobar")
+ '(*TOP* (html (xyz "foo") (p "bar"))))
+
+ (test 'p-shouldnt-nest-even-without-html-element
+ (html->xexp "
foo
bar")
+ '(*TOP* (p "foo") (p "bar"))
+ #:fail "see code comment about parent-constraints when no top elements"))
+
+ (test-section 'break-the-world-with-h-elem-mid-90s-handling
+
+ (test 'initial
+ (html->xexp "
foo
bar ")
+ '(*TOP* (html (p "foo") (h3 "bar")))))
+
+ (test-section 'twrpme-h2-in-li-elements
+
+ (test 'simple
+ (html->xexp "My Header Item My Non-Header Item ")
+ '(*TOP* (html (body (ul (li (h2 "My Header Item"))
+ (li "My Non-Header Item"))))))
+
+ (test 'simon-budinsky-example
+ (html->xexp
+ (string-append
+ ""
+ ""
+ ""
+ ""
+ ""
+ "Mar 10, 2022 "
+ ""
+ " "
+ ""
+ "Mar 10, 2022 "
+ ""
+ " "
+ " "
+ ""
+ ""))
+ '(*TOP* (html (head)
+ (body (ul (@ (class "post-list"))
+ (li (span (@ (class "post-meta"))
+ "Mar 10, 2022")
+ (h2 (a (@ (class "post-link")
+ (href "/site/update/2022/03/10/twrp-3.6.1-released.html"))
+ "TWRP 3.6.1 Released")))
+
+ (li (span (@ (class "post-meta"))
+ "Mar 10, 2022")
+ (h2 (a (@ (class "post-link")
+ (href "/site/update/2022/03/10/twrp-3.6.1-released.html"))
+ "TWRP 3.6.1 Released")))))))))
+
+ (test-section 'area-in-span-in-microformats
+
+ (test 'area-from-jacob-hall
+ (html->xexp "")
+ '(*TOP* (div (span (area)))))
+
+ (test 'object-from-jacob-hall
+ (html->xexp "Jane Doe
")
+ '(*TOP* (div (object "Jane Doe")))))
+
+ (test-section 'bugs-from-cadence
+
+ (test 'figcaption-as-parent-of-p
+ (html->xexp " words
")
+ '(*TOP* (figcaption " "
+ (p (@ (class "caption")) "words")
+ (img (@ (src "nowhere"))))))
+
+ (test 'attribute-html-entities-1
+ (html->xexp " ")
+ '(*TOP* (img (@ (src "https://example.com/images?src=Blah.jpg&width=150")))))
+
+ (test 'attribute-html-entities-2
+ (html->xexp "https://example.com/phpbb/viewtopic.php?f=31&t=1125 ")
+ '(*TOP* (a (@ (href "https://example.com/phpbb/viewtopic.php?f=31&t=1125"))
+ "https://example.com/phpbb/viewtopic.php?f=31&t=1125"))))
+
+ ;; TODO: Document this.
+ ;;
+ ;; (define html-1 "")
+ ;; (define shtml (html->xexp html-1))
+ ;; shtml
+ ;; (define html-2 (shtml->html shtml))
+ ;; html-2
+ )
+
+(doc history
+
+ (#:planet 12:0 #:date "2023-01-28"
+ (itemlist
+ (item "Within attributes, HTML entities like & are now decoded.")
+ (item "A "
+ (code "p")
+ " element can now appear as a child of a "
+ (code "figcaption")
+ " element.")))
+
+ (#:planet 11:0 #:date "2022-07-19"
+ (itemlist
+ (item "An "
+ (code "object")
+ " element is no longer considered always-empty. Incrementing major
+version again, because this could break parses.")))
+
+ (#:planet 10:0 #:date "2022-07-19"
+ (itemlist
+ (item "To pass a \"microformats\" test suite ("
+ (hyperlink "https://github.com/microformats/tests/blob/master/tests/microformats-v2/h-card/impliedname.html#L11"
+ "impliedname.html")
+ "), an "
+ (code "area")
+ " element can now be a child of a "
+ (code "span")
+ " element. In the future, we might be even more flexible about where "
+ (code "span")
+ " elements are permitted. (Thanks to Jacob Hall for
+discussing.)")))
+
+ (#:planet 9:0 #:date "2022-04-16"
+ (itemlist
+ (item "Header elements may once again appear as children of "
+ (code "li")
+ " elements (which we broke in the previous version), as we see how far we can stretch a 20 year-old hack for invalid HTML. (Thanks for Simon Budinsky for reporting.)")))
+
+ (#:planet 8:0 #:date "2022-04-03"
+ (itemlist
+ (item "The original \"H\" elements ("
+ (code "h1")
+ ", "
+ (code "h2")
+ ", etc.) now are parsed with \"parent constraints\" for
+handling invalid HTML, to accommodate a need to parse mid-1990s HTML in which "
+ (code "")
+ " was used as a separator or terminator, rather than a
+start delimeter. There is a chance that change this will break a real-world
+scraper or other tool.")))
+
+ (#:planet 7:1 #:date "2022-04-02"
+ (itemlist
+ (item "Include a test case #:fail that was unsaved in DrRacket.")))
+
+ (#:planet 7:0 #:date "2022-04-02"
+ (itemlist
+
+ (item "Fixed a quirks-handling bug in which "
+ (code "p")
+ " elements would be (directly or indirectly) nested under other "
+ (code "p")
+ " elements in cases in which there was no "
+ (code "body")
+ " element, but there was an "
+ (code "html")
+ " element. (Thanks to Jonathan Simpson for reporting.)")))
+
+ (#:planet 6:1 #:date "2022-01-22"
+ (itemlist
+ (item "Permit "
+ (code "details")
+ " element to be parent of "
+ (code "p")
+ " element in quirks handling. (Thanks to Jacder for reporting.)")))
+
+ (#:planet 6:0 #:date "2018-05-22"
+ (itemlist
+ (item "Fix to permit "
+ (code "p")
+ " elements as children of "
+ (code "blockquote")
+ " elements. Incrementing major version number because
+this is a breaking change of 17 years, but seems an appropriate change for
+modern HTML, and fixes a particular real-world problem. (Thanks to
+Sorawee Porncharoenwase for reporting.)")))
+
+ (#:planet 5:0 #:date "2018-05-15"
+ (itemlist
+ (item "In a breaking change of handing invalid HTML, most named
+character entity references that are invalid because (possibly among multiple
+reasons) they are not terminated by semicolon, now are treated as literal
+strings (including the ampersand), rather than as named character entites. For
+example, parser input string "
+ (racket "
A&B Co.
")
+ " will now parse as "
+ (racket (p "A&B Co."))
+ " rather than as "
+ (racket (p "A" (& B) " Co."))
+ ". (Thanks for Greg Hendershott for suggesting this, and discussing.)")
+ (item "For support of historical quirks handling, five early
+HTML named character entities (specifically, "
+ (tt "amp")
+ ", "
+ (tt "apos")
+ ", "
+ (tt "lt")
+ ", "
+ (tt "gt")
+ ", "
+ (tt "quot")
+ ") do not need to be terminated with a semicolon, and
+will even be recognized if followed immediately by an alphabetic. For
+example, "
+ (racket "
a<z
")
+ " will now parse as "
+ (racket (p "amime-type (-> bytes? bytes?)]
+ [mime-type->ext (-> bytes? bytes?)]))
+
+(define-runtime-path mime.types-path "mime.types")
+
+(define ls
+ (call-with-input-file mime.types-path
+ (λ (in) (for/list ([line (in-lines in)]
+ #:when (not (regexp-match? #rx"^ *($|#)" line)))
+ (match line
+ [(regexp #rx"^([^ ]+) +(.+)$" (list _ mime ext))
+ (cons (string->bytes/utf-8 ext) (string->bytes/utf-8 mime))]
+ [(regexp #rx"^ *#") (void)]
+ [_ (log-warning "mime-types: failed to parse line ~s" line)])))))
+
+(define forward-hash (make-immutable-hash ls))
+(define reverse-hash (make-immutable-hash (map (λ (x) (cons (cdr x) (car x))) ls)))
+
+(define (ext->mime-type ext-in)
+ (define ext (regexp-replace #rx"^\\." ext-in #""))
+ (hash-ref forward-hash ext))
+
+(define (mime-type->ext m-in)
+ (define m (regexp-replace #rx";.*" m-in #""))
+ (hash-ref reverse-hash m))
diff --git a/lib/mime.types b/lib/mime.types
new file mode 100644
index 0000000..4ae48d8
--- /dev/null
+++ b/lib/mime.types
@@ -0,0 +1,90 @@
+text/html html
+text/css css
+application/xml xml
+text/xml xml
+image/gif gif
+image/jpeg jpeg
+application/javascript js
+text/javascript js
+application/atom+xml atom
+application/rss+xml rss
+
+text/mathml mml
+text/plain txt
+text/x-component htc
+
+image/png png
+image/tiff tiff
+image/vnd.wap.wbmp wbmp
+image/x-icon ico
+image/vnd.microsoft.icon ico
+image/x-jng jng
+image/x-ms-bmp bmp
+image/svg+xml svg
+image/webp webp
+image/avif avif
+
+application/font-woff2 woff2
+application/acad woff2
+font/woff2 woff2
+application/font-woff woff
+font/woff woff
+application/x-font-ttf ttf
+application/x-font-truetype ttf
+application/x-truetype-font ttf
+font/ttf ttf
+application/font-sfnt ttf
+font/sfnt ttf
+application/vnd.oasis.opendocument.formula-template otf
+application/x-font-opentype otf
+application/vnd.ms-opentype otf
+font/otf otf
+application/java-archive jar
+application/json json
+application/mac-binhex40 hqx
+application/msword doc
+application/pdf pdf
+application/postscript ps
+application/rtf rtf
+application/vnd.apple.mpegurl m3u8
+application/vnd.ms-excel xls
+application/vnd.ms-fontobject eot
+application/vnd.ms-powerpoint ppt
+application/vnd.wap.wmlc wmlc
+application/vnd.google-earth.kml+xml kml
+application/vnd.google-earth.kmz kmz
+application/x-7z-compressed 7z
+application/x-cocoa cco
+application/x-java-archive-diff jardiff
+application/x-java-jnlp-file jnlp
+application/x-makeself run
+application/x-perl pl
+application/x-rar-compressed rar
+application/x-redhat-package-manager rpm
+application/x-sea sea
+application/x-shockwave-flash swf
+application/x-stuffit sit
+application/x-tcl tcl
+application/x-x509-ca-cert pem
+application/x-xpinstall xpi
+application/xhtml+xml xhtml
+application/xspf+xml xspf
+application/zip zip
+application/gzip gz
+
+audio/midi mid midi kar
+audio/mpeg mp3
+audio/ogg ogg
+audio/x-m4a m4a
+audio/x-realaudio ra
+
+video/mp2t ts
+video/mp4 mp4
+video/mpeg mpeg
+video/quicktime mov
+video/webm webm
+video/x-flv flv
+video/x-m4v m4v
+video/x-mng mng
+video/x-ms-wmv wmv
+video/x-msvideo avi
diff --git a/src/pure-utils.rkt b/lib/pure-utils.rkt
similarity index 69%
rename from src/pure-utils.rkt
rename to lib/pure-utils.rkt
index eb730ec..ff8b933 100644
--- a/src/pure-utils.rkt
+++ b/lib/pure-utils.rkt
@@ -4,13 +4,19 @@
; call the updater on the dictionary key only if it has that key
alist-maybe-update
; update a value only if a condition succeeds on it
- u)
+ u
+ ; like string-join, but for lists
+ list-join
+ u-counter)
(module+ test
(require "typed-rackunit.rkt"))
+(define u-counter (box 0))
+
(: alist-maybe-update (∀ (A B) ((Listof (Pairof A B)) A (B -> B) -> (Listof (Pairof A B)))))
(define (alist-maybe-update alist key updater)
+ (set-box! u-counter (add1 (unbox u-counter)))
(map (λ ([p : (Pairof A B)])
(if (eq? (car p) key)
(cons (car p) (updater (cdr p)))
@@ -24,7 +30,16 @@
(: u (∀ (A) ((A -> Any) (A -> A) A -> A)))
(define (u condition updater value)
+ (set-box! u-counter (add1 (unbox u-counter)))
(if (condition value) (updater value) value))
(module+ test
(check-equal? (u (λ ([x : Integer]) (< x 5)) (λ ([x : Integer]) (* x -1)) 4) -4)
(check-equal? (u (λ ([x : Integer]) (< x 5)) (λ ([x : Integer]) (* x -1)) 8) 8))
+
+(: list-join (∀ (A B) (A (Listof B) -> (Listof (U A B)))))
+(define (list-join element ls)
+ (if (pair? (cdr ls))
+ (list* (car ls) element (list-join element (cdr ls)))
+ (list (car ls))))
+(module+ test
+ (check-equal? (list-join "h" '(2 3 4 5)) '(2 "h" 3 "h" 4 "h" 5)))
diff --git a/lib/syntax.rkt b/lib/syntax.rkt
new file mode 100644
index 0000000..650e2f5
--- /dev/null
+++ b/lib/syntax.rkt
@@ -0,0 +1,148 @@
+#lang racket/base
+(require (for-syntax racket/base syntax/location))
+
+(provide
+ ; help make a nested if. if/in will gain the same false form of its containing if/out.
+ if/out
+ ; cond, but values can be defined between conditions
+ cond/var
+ ; wrap sql statements into lambdas so they can be executed during migration
+ wrap-sql
+ ; get the name of the file that contains the currently evaluating form
+ this-directory
+ this-file
+ ; replacement for define-runtime-path
+ anytime-path)
+
+(module+ test
+ (require rackunit)
+ (define (check-syntax-equal? s1 s2)
+ (check-equal? (syntax->datum s1)
+ (syntax->datum s2))))
+
+;; actual transforming goes on in here.
+;; it's in a submodule so that it can be required in both levels, for testing
+
+(module transform racket/base
+ (require racket/list)
+
+ (provide
+ transform-if/out
+ transform/out-cond/var)
+
+ (define (transform-if/out stx)
+ (define tree (cdr (syntax->datum stx))) ; condition true false
+ (define else (cddr tree)) ; the else branch cons cell
+ (define result
+ (let walk ([node tree])
+ (cond
+ ; normally, node should be a full cons cell (a pair) but it might be something else.
+ ; situation: reached the end of a list, empty cons cell
+ [(null? node) node]
+ ; situation: reached the end of a list, cons cdr was non-list
+ [(symbol? node) node]
+ ; normal situation, full cons cell
+ ; -- don't go replacing through nested if/out
+ [(and (pair? node) (eq? 'if/out (car node))) node]
+ ; -- replace if/in
+ [(and (pair? node) (eq? 'if/in (car node)))
+ (append '(if) (walk (cdr node)) else)]
+ ; recurse down pair head and tail
+ [(pair? node) (cons (walk (car node)) (walk (cdr node)))]
+ ; something else that can't be recursed into, so pass it through
+ [#t node])))
+ (datum->syntax stx (cons 'if result)))
+
+ (define (transform/out-cond/var stx)
+ (define tree (transform-cond/var (cdr (syntax->datum stx))))
+ (datum->syntax
+ stx
+ tree))
+
+ (define (transform-cond/var tree)
+ (define-values (els temp) (splitf-at tree (λ (el) (and (pair? el) (not (eq? (car el) 'var))))))
+ (define-values (vars rest) (splitf-at temp (λ (el) (and (pair? el) (eq? (car el) 'var)))))
+ (if (null? rest)
+ `(cond ,@els)
+ `(cond
+ ,@els
+ [#t
+ (let* ,(for/list ([var vars])
+ (cdr var))
+ ,(transform-cond/var rest))]))))
+
+;; the syntax definitions and their tests go below here
+
+(require 'transform (for-syntax 'transform))
+
+(define-syntax (wrap-sql stx)
+ ; the arguments
+ (define xs (cdr (syntax->list stx)))
+ ; wrap each argument
+ (define wrapped (map (λ (xe) ; xe is the syntax of an argument
+ (if (list? (car (syntax->datum xe)))
+ ; it's a list of lists (a list of sql migration steps)
+ ; return instead syntax of a lambda that will call everything in xe
+ (datum->syntax stx `(λ () ,@xe))
+ ; it's just a single sql migration step
+ ; return instead syntax of a lambda that will call xe
+ (datum->syntax stx `(λ () ,xe))))
+ xs))
+ ; since I'm returning *code*, I need to return the form (list ...) so that runtime makes a list
+ (datum->syntax stx `(list ,@wrapped)))
+
+(define-syntax (if/out stx)
+ (transform-if/out stx))
+(module+ test
+ (check-syntax-equal? (transform-if/out #'(if/out (condition 1) (if/in (condition 2) (do-yes)) (do-no)))
+ #'(if (condition 1) (if (condition 2) (do-yes) (do-no)) (do-no)))
+ (check-equal? (if/out #t (if/in #t 'yes) 'no) 'yes)
+ (check-equal? (if/out #f (if/in #t 'yes) 'no) 'no)
+ (check-equal? (if/out #t (if/in #f 'yes) 'no) 'no)
+ (check-equal? (if/out #f (if/in #f 'yes) 'no) 'no))
+
+(define-syntax (this-directory stx)
+ (datum->syntax stx (syntax-source-directory stx)))
+
+(define-syntax (this-file stx)
+ (datum->syntax stx (build-path (or (syntax-source-directory stx) 'same) (syntax-source-file-name stx))))
+
+(module+ test
+ (require racket/path)
+ (check-equal? (file-name-from-path (this-file)) (build-path "syntax.rkt")))
+
+(define-syntax (cond/var stx)
+ (transform/out-cond/var stx))
+(module+ test
+ (check-syntax-equal? (transform/out-cond/var #'(cond/def [#f 0] (var d (* a 2)) [(eq? d 8) d] [#t "not 4"]))
+ #'(cond
+ [#f 0]
+ [#t
+ (let* ([d (* a 2)])
+ (cond
+ [(eq? d 8) d]
+ [#t "not 4"]))])))
+
+;;; Replacement for define-runtime-path that usually works well and doesn't include the files/folder contents into the distribution.
+;;; When running from source, should always work appropriately.
+;;; When running from a distribution, (current-directory) is treated as the root.
+;;; Usage:
+;;; * to-root : Path-String * relative path from the source file to the project root
+;;; * to-dest : Path-String * relative path from the root to the desired file/folder
+(define-syntax (anytime-path stx)
+ (define-values (_ to-root to-dest) (apply values (syntax->list stx)))
+ (define source (syntax-source stx))
+ (unless (complete-path? source)
+ (error 'anytime-path "syntax source has no directory: ~v" stx))
+ (datum->syntax
+ stx
+ `(let* ([source ,source]
+ [dir-of-source (path-only source)]
+ [_ (unless (path? dir-of-source) (error 'anytime-path "syntax source has no directory: ~v" ,source))]
+ [syntax-to-root (build-path dir-of-source ,to-root)]
+ [root (if (directory-exists? syntax-to-root)
+ ;; running on the same filesystem it was compiled on, i.e. it's running the source code out of a directory, and the complication is the intermediate compilation
+ syntax-to-root
+ ;; not running on the same filesystem, i.e. it's a distribution. we assume that the current working directory is where the executable is, and treat this as the root.
+ (current-directory))])
+ (simple-form-path (build-path root ,to-dest)))))
diff --git a/lib/thread-utils.rkt b/lib/thread-utils.rkt
new file mode 100644
index 0000000..66e2b4c
--- /dev/null
+++ b/lib/thread-utils.rkt
@@ -0,0 +1,71 @@
+#lang racket/base
+(require "../src/data.rkt"
+ "xexpr-utils.rkt")
+
+(provide
+ thread-values)
+
+(module+ test
+ (require rackunit))
+
+(define (thread-values . thunks)
+ (parameterize-break #t
+ (define the-exn (box #f))
+ (define original-thread (current-thread))
+ (define (break e)
+ (when (box-cas! the-exn #f e)
+ (break-thread original-thread))
+ (sleep 0))
+ (define-values (threads channels)
+ (for/fold ([ts null]
+ [chs null]
+ #:result (values (reverse ts) (reverse chs)))
+ ([th thunks])
+ (define ch (make-channel))
+ (define t
+ (thread (λ ()
+ (with-handlers ([exn? break])
+ (channel-put ch (th))))))
+ (values (cons t ts) (cons ch chs))))
+ (apply
+ values
+ (with-handlers ([exn:break? (λ (_)
+ (for ([t threads]) (kill-thread t))
+ (if (unbox the-exn)
+ (raise (unbox the-exn))
+ (error 'thread-values "a thread broke, but without reporting its exception")))])
+ (for/list ([ch channels])
+ (channel-get ch))))))
+
+(module+ test
+ ; check that they actually execute concurrently
+ (define ch (make-channel))
+ (check-equal? (let-values ([(a b)
+ (thread-values
+ (λ ()
+ (begin
+ (channel-put ch 'a)
+ (channel-get ch)))
+ (λ ()
+ (begin0
+ (channel-get ch)
+ (channel-put ch 'b))))])
+ (list a b))
+ '(b a))
+ ; check that it assigns the correct value to the correct variable
+ (check-equal? (let-values ([(a b)
+ (thread-values
+ (λ () (sleep 0) 'a)
+ (λ () 'b))])
+ (list a b))
+ '(a b))
+ ; check that exceptions are passed to the original thread, and other threads are killed
+ ;; TODO: if the other thread was making an HTTP request, could it be left stuck open by the kill?
+ (check-equal? (let* ([x "!"]
+ [res
+ (with-handlers ([exn:fail:user? (λ (e) (exn-message e))])
+ (thread-values
+ (λ () (sleep 0) (set! x "?") (println "this side effect should not happen"))
+ (λ () (raise-user-error "catch me"))))])
+ (string-append res x))
+ "catch me!"))
diff --git a/lib/tree-updater.rkt b/lib/tree-updater.rkt
new file mode 100644
index 0000000..109c875
--- /dev/null
+++ b/lib/tree-updater.rkt
@@ -0,0 +1,330 @@
+#lang racket/base
+(require racket/dict
+ racket/function
+ racket/match
+ racket/string
+ "pure-utils.rkt"
+ "url-utils.rkt"
+ "xexpr-utils.rkt")
+
+(provide
+ preprocess-html-wiki
+ update-tree-wiki)
+
+(define (preprocess-html-wiki html)
+ (regexp-replace* #rx"(<(?:td|figcaption)[^>]*?>\n?)(?:|[ \t]*?(.*?)
)"
+ html (λ (whole first-tag [contents #f])
+ (if (eq? (string-ref whole 1) #\f) ;; figcaption
+ (string-append first-tag "" contents " ")
+ (string-append first-tag "")))))
+
+(module+ test
+ (check-equal? (preprocess-html-wiki "\nHey ")
+ " \nHey ")
+ (check-equal? (preprocess-html-wiki " Caption text.
")
+ "Caption text. "))
+
+(module+ test
+ (require rackunit
+ "html-parsing/main.rkt")
+ (define wiki-document
+ '(*TOP*
+ (div (@ (class "mw-parser-output"))
+ (aside (@ (role "region") (class "portable-infobox pi-theme-wikia pi-layout-default"))
+ (h2 (@ (class "pi-item pi-title") (data-source "title"))
+ "Infobox Title")
+ (figure (@ (class "pi-item pi-image") (data-source "image"))
+ (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image image-thumbnail") (title ""))
+ (img (@ (src "https://static.wikia.nocookie.net/nice-image-thumbnail.png") (class "pi-image-thumbnail")))))
+ (div (@ (class "pi-item pi-data") (data-source "description"))
+ (h3 (@ (class "pi-data-label"))
+ "Description")
+ (div (@ (class "pi-data-value"))
+ "Mystery infobox!")))
+ (div (@ (data-test-collapsesection) (class "collapsible collapsetoggle-inline collapsed"))
+ (i (b "This section is hidden for dramatic effect."))
+ (div (@ (class "collapsible-content"))
+ (p "Another page link: "
+ (a (@ (data-test-wikilink) (href "https://test.fandom.com/wiki/Another_Page") (title "Another Page"))
+ "Another Page"))))
+ (figure (@ (class "thumb tnone"))
+ (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image") (data-test-figure-a))
+ (img (@ (src "data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D")
+ (data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
+ (class "thumbimage lazyload"))))
+ (noscript
+ (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image"))
+ (img (@ (src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
+ (data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
+ (class "thumbimage")))))
+ (figcaption "Test figure!"))
+ (iframe (@ (src "https://example.com/iframe-src")))
+ (div (@ (class "reviews"))
+ (header "GameSpot Expert Reviews"))
+ (div (@ (data-test-ampersand) (class "mw-collapsible-content"))
+ (& ndash))))))
+
+(define (updater wikiname #:strict-proxy? [strict-proxy? #f])
+ ;; precompute wikiurl regex for efficency
+ (define wikiurl-regex (pregexp (format "^https://(~a)\\.fandom\\.com(/wiki/.*)$" px-wikiname)))
+ ;; precompute link replacement string for efficiency
+ (define wiki-substitution (format "/~a\\1" wikiname))
+
+ (define classlist-updater
+ (compose1
+ ; uncollapse all navbox items (bottom of page mass navigation)
+ (curry u
+ (λ (classlist) (and ; removed due to scoping, would improve peformance (eq? element-type 'table)
+ (member "navbox" classlist)
+ (member "collapsed" classlist)))
+ (λ (classlist) (filter (curry (negate equal?) "collapsed") classlist)))
+ ; uncollapse portable-infobox sections
+ (curry u
+ (λ (classlist) (and ; removed due to scoping, would improve performance (eq? element-type 'section)
+ (member "pi-collapse" classlist)))
+ (λ (classlist) (filter (λ (v)
+ (and (not (equal? v "pi-collapse-closed"))
+ (not (equal? v "pi-collapse"))))
+ classlist)))
+ ; generic: includes article sections and tables, probably more
+ (curry u
+ (λ (classlist) (and (member "collapsible" classlist)
+ (member "collapsed" classlist)))
+ (λ (classlist) (filter (curry (negate equal?) "collapsed") classlist)))))
+
+ (define ((string-replace-curried from to) str)
+ (string-replace str from to))
+
+ (define class-updater
+ (compose1
+ (string-replace-curried " collapsed" "")
+ (string-replace-curried "pi-collapse-closed" "")
+ (string-replace-curried "pi-collapse" "")))
+
+ (define (cardimage-class-updater c)
+ (string-append c " bw-updated-cardtable-cardimage"))
+
+ (define attributes-updater
+ (compose1
+ ; uncollapsing
+ #;(curry attribute-maybe-update 'class
+ (λ (class) (string-join (classlist-updater (string-split class " ")) " ")))
+ (curry attribute-maybe-update 'class class-updater)
+ ; audio buttons - sample: hearthstone/wiki/Diablo_(Duels_hero)#Sounds
+ (curry u
+ (λ (v) (has-class? "ext-audiobutton" v))
+ (λ (v) (dict-set (dict-remove v 'hidden) 'controls '(""))))
+ ; yet more uncollapsing - sample: warframe/wiki/Amp_(Ability)
+ (curry u
+ (λ (v) (and (dict-has-key? v 'id)
+ (string-prefix? (car (dict-ref v 'id)) "mw-customcollapsible")))
+ (λ (v) (dict-set v 'style "display:block")))
+ ; change links to stay on the same wiki
+ (curry attribute-maybe-update 'href
+ (λ (href)
+ ((compose1
+ (λ (href) (regexp-replace #rx"^(/wiki/.*)$" href wiki-substitution))
+ (λ (href) (regexp-replace wikiurl-regex href "/\\1\\2")))
+ href)))
+ ; add noreferrer to a.image
+ (curry u
+ (λ (v) (and #;(eq? element-type 'a)
+ (has-class? "image" v)))
+ (λ (v) (dict-update v 'rel (λ (s)
+ (list (string-append (car s) " noreferrer")))
+ '(""))))
+ ; proxy images from inline styles, if strict_proxy is set
+ (curry u
+ (λ (v) strict-proxy?)
+ (λ (v) (attribute-maybe-update
+ 'style
+ (λ (style)
+ (regexp-replace #rx"url\\(['\"]?(.*?)['\"]?\\)" style
+ (λ (whole url)
+ (string-append
+ "url("
+ (u-proxy-url url)
+ ")")))) v)))
+ ; and also their links, if strict_proxy is set
+ (curry u
+ (λ (v)
+ (and strict-proxy?
+ #;(eq? element-type 'a)
+ (or (has-class? "image-thumbnail" v)
+ (has-class? "image" v))))
+ (λ (v) (attribute-maybe-update 'href u-proxy-url v)))
+ ; proxy images from src attributes, if strict_proxy is set
+ (curry u
+ (λ (v) strict-proxy?)
+ (λ (v) (attribute-maybe-update 'src u-proxy-url v)))
+ ; don't lazyload images
+ (curry u
+ (λ (v) (dict-has-key? v 'data-src))
+ (λ (v) (attribute-maybe-update 'src (λ (_) (car (dict-ref v 'data-src))) v)))
+ ; don't use srcset - TODO: use srcset?
+ (λ (v) (dict-remove v 'srcset))))
+
+ (define (children-updater attributes children)
+ ; more uncollapsing - sample: bandori/wiki/BanG_Dream!_Wikia
+ ((λ (children)
+ (u
+ (λ (v) (has-class? "mw-collapsible-content" attributes))
+ (λ (v) (for/list ([element v])
+ (u (λ (element) (element-is-element? element))
+ (λ (element)
+ `(,(car element)
+ (@ ,@(attribute-maybe-update 'style (λ (a) (regexp-replace #rx"display: *none" a "display:inline")) (bits->attributes element)))
+ ,@(filter element-is-content? (cdr element))))
+ element)))
+ children))
+ ; wrap blinking animated images in a slot so they can be animated with CSS
+ ((λ (children)
+ (u
+ (λ (v) (and (has-class? "animated" attributes)
+ ((length v) . > . 1)))
+ (λ (v)
+ `((span (@ (class "animated-slot__outer") (style ,(format "--steps: ~a" (length v))))
+ (span (@ (class "animated-slot__inner"))
+ ,@v))))
+ children))
+ children)))
+
+ (define (updater element element-type attributes children)
+ ;; replace whole element?
+ (cond
+ ; wrap tables in a div.table-scroller
+ [(and (eq? element-type 'table)
+ (has-class? "wikitable" attributes)
+ (not (dict-has-key? attributes 'data-scrolling)))
+ `(div
+ ((class "table-scroller"))
+ ((,element-type (@ (data-scrolling) ,@attributes)
+ ,@children)))]
+ ; HACK for /yugioh/wiki/Pot_of_Greed: move card images above tables
+ [(and (eq? element-type 'table)
+ (has-class? "cardtable" attributes)
+ (not (has-class? "bw-updated-cardtable-cardimage" attributes)))
+ (define (is-cardimage? t a c) (and (eq? t 'td)
+ (has-class? "cardtable-cardimage" a)))
+ (define cardimage ((query-selector is-cardimage? element)))
+ (if (not cardimage)
+ (list element-type attributes children)
+ (let ([new-cardtable (update-tree
+ (λ (e t a c)
+ (if (is-cardimage? t a c)
+ return-no-element
+ (list t a c)))
+ `(,element-type
+ (@ ,(attribute-maybe-update 'class cardimage-class-updater attributes))
+ ,@children))])
+ (list 'div null (list cardimage new-cardtable))))]
+ ; exclude empty figcaptions
+ [(and (eq? element-type 'figcaption)
+ (or (eq? (length (filter element-is-element? children)) 0)
+ ((query-selector (λ (element-type attributes children)
+ (eq? element-type 'use))
+ element))))
+ return-no-element]
+ ; exclude infobox items that are videos, and gallery items that are videos
+ [(and (or (has-class? "pi-item" attributes)
+ (has-class? "wikia-gallery-item" attributes))
+ ((query-selector (λ (element-type attributes children)
+ (has-class? "video-thumbnail" attributes))
+ element)))
+ return-no-element]
+ ; exclude the invisible brackets after headings
+ [(and (eq? element-type 'span)
+ (has-class? "mw-editsection" attributes))
+ return-no-element]
+ ; display a link instead of an iframe
+ [(eq? element-type 'iframe)
+ (define src (car (dict-ref attributes 'src null)))
+ `(a
+ ((class "iframe-alternative") (href ,src))
+ (,(format "Embedded media: ~a" src)))]
+ ; remove noscript versions of images because they are likely lower quality than the script versions
+ [(and (eq? element-type 'noscript)
+ (match children
+ ; either the noscript has a.image as a first child...
+ [(list (list 'a (list '@ a-att ...) _)) (has-class? "image" a-att)]
+ ; or the noscript has img as a first child
+ [(list (list 'img _)) #t]
+ [_ #f]))
+ return-no-element]
+ ; remove gamespot reviews/ads
+ [(has-class? "reviews" attributes)
+ return-no-element]
+ ; remove customcollapsible customtoggle buttons - sample: warframe/wiki/Amp_(Ability)
+ [(and (dict-has-key? attributes 'class) (regexp-match? #rx"^mw-customtoggle-[^ ]* button-c$" (car (dict-ref attributes 'class))))
+ return-no-element]
+ [#t
+ (list element-type
+ ;; attributes
+ (attributes-updater #; element-type attributes)
+ ;; children
+ (children-updater attributes children))]))
+
+ updater)
+
+(define (update-tree-wiki tree wikiname #:strict-proxy? [strict-proxy? #f])
+ (update-tree (updater wikiname #:strict-proxy? strict-proxy?) tree))
+
+(module+ test
+ (define transformed
+ (update-tree-wiki wiki-document "test" #:strict-proxy? #t))
+ ; check that wikilinks are changed to be local
+ (check-equal? (get-attribute 'href (bits->attributes
+ ((query-selector
+ (λ (t a c) (dict-has-key? a 'data-test-wikilink))
+ transformed))))
+ "/test/wiki/Another_Page")
+ ; check that a.image has noreferrer
+ (check-equal? (get-attribute 'rel (bits->attributes
+ ((query-selector
+ (λ (t a c) (and (eq? t 'a)
+ (has-class? "image" a)))
+ transformed))))
+ " noreferrer")
+ ; check that article collapse sections become uncollapsed
+ (check-equal? (get-attribute 'class (bits->attributes
+ ((query-selector
+ (λ (t a c) (dict-has-key? a 'data-test-collapsesection))
+ transformed))))
+ "collapsible collapsetoggle-inline")
+ ; check that iframes are gone
+ (check-false ((query-selector (λ (t a c) (eq? t 'iframe)) transformed)))
+ (check-equal? (let* ([alternative ((query-selector (λ (t a c) (has-class? "iframe-alternative" a)) transformed))]
+ [link ((query-selector (λ (t a c) (eq? t 'a)) alternative))])
+ (get-attribute 'href (bits->attributes link)))
+ "https://example.com/iframe-src")
+ ; check that images are proxied
+ (check-equal? (get-attribute 'src (bits->attributes
+ ((query-selector
+ (λ (t a c) (eq? t 'img))
+ transformed))))
+ "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image-thumbnail.png")
+ ; check that links to images are proxied
+ (check-equal? (get-attribute 'href (bits->attributes
+ ((query-selector
+ (λ (t a c) (and (eq? t 'a) (has-class? "image-thumbnail" a)))
+ transformed))))
+ "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image.png")
+ (check-equal? (get-attribute 'href (bits->attributes
+ ((query-selector
+ (λ (t a c) (member '(data-test-figure-a) a))
+ transformed))))
+ "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image.png")
+ ; check that noscript images are removed
+ (check-equal? ((query-selector (λ (t a c) (eq? t 'noscript)) transformed)) #f)
+ ; check that gamespot reviews/ads are removed
+ (check-equal? ((query-selector (λ (t a c) (has-class? "reviews" a)) transformed)) #f)
+ ; check that (& x) sequences are not broken
+ (check-equal? ((query-selector (λ (t a c) (dict-has-key? a 'data-test-ampersand)) transformed))
+ '(div (@ (data-test-ampersand) (class "mw-collapsible-content"))
+ (& ndash)))
+ ; benchmark
+ (when (file-exists? "../storage/Frog.html")
+ (with-input-from-file "../storage/Frog.html"
+ (λ ()
+ (define tree (html->xexp (current-input-port)))
+ (time (length (update-tree-wiki tree "minecraft")))))))
diff --git a/src/typed-rackunit.rkt b/lib/typed-rackunit.rkt
similarity index 100%
rename from src/typed-rackunit.rkt
rename to lib/typed-rackunit.rkt
diff --git a/src/url-utils.rkt b/lib/url-utils.rkt
similarity index 60%
rename from src/url-utils.rkt
rename to lib/url-utils.rkt
index b70b245..3fb4310 100644
--- a/src/url-utils.rkt
+++ b/lib/url-utils.rkt
@@ -1,6 +1,6 @@
#lang typed/racket/base
(require racket/string
- "config.rkt"
+ typed/net/url-structs
"pure-utils.rkt")
(require/typed web-server/http/request-structs
[#:opaque Header header?])
@@ -10,14 +10,21 @@
px-wikiname
; make a query string from an association list of strings
params->query
+ ; custom percent encoding (you probably want params->query instead)
+ percent-encode
+ ; sets for custom percent encoding
+ path-set urlencoded-set filename-set
; make a proxied version of a fandom url
u-proxy-url
; check whether a url is on a domain controlled by fandom
is-fandom-url?
- ; prints "out: "
- log-outgoing
; pass in a header, headers, or something useless. they'll all combine into a list
- build-headers)
+ build-headers
+ ; try to follow wikimedia's format for which characters should be encoded/replaced in page titles for the url
+ page-title->path
+ ; path/param eats semicolons into params, which need to be fixed back into semicolons
+ fix-semicolons-url-path
+ fix-semicolons-url)
(module+ test
(require "typed-rackunit.rkt"))
@@ -26,14 +33,20 @@
;; https://url.spec.whatwg.org/#urlencoded-serializing
-(define urlencoded-set '(#\! #\' #\( #\) #\~ ; urlencoded set
- #\$ #\% #\& #\+ #\, ; component set
- #\/ #\: #\; #\= #\@ #\[ #\\ #\] #\^ #\| ; userinfo set
- #\? #\` #\{ #\} ; path set
- #\ #\" #\# #\< #\> ; query set
- ; c0 controls included elsewhere
- ; higher ranges included elsewhere
- ))
+(define path-set '(#\; ; semicolon is part of the userinfo set in the URL standard, but I'm putting it here
+ #\? #\` #\{ #\} ; path set
+ #\ #\" #\# #\< #\> ; query set
+ ; c0 controls included elsewhere
+ ; higher ranges included elsewhere
+ ))
+(define urlencoded-set (append
+ '(#\! #\' #\( #\) #\~ ; urlencoded set
+ #\$ #\% #\& #\+ #\, ; component set
+ #\/ #\: #\= #\@ #\[ #\\ #\] #\^ #\| ; userinfo set
+ )
+ path-set))
+
+(define filename-set '(#\< #\> #\: #\" #\/ #\\ #\| #\? #\* #\# #\~ #\&))
(: percent-encode (String (Listof Char) Boolean -> Bytes))
(define (percent-encode value set space-as-plus)
@@ -81,11 +94,6 @@
(λ ([v : String]) (string-append "/proxy?" (params->query `(("dest" . ,url)))))
url))
-(: log-outgoing (String -> Void))
-(define (log-outgoing url-string)
- (when (config-true? 'log_outgoing)
- (printf "out: ~a~n" url-string)))
-
(: build-headers ((U Header (Listof Header) False Void) * -> (Listof Header)))
(define (build-headers . fs)
(apply
@@ -98,3 +106,24 @@
[(header? f) (list f)]
[(pair? f) f]))
fs)))
+
+(: page-title->path (String -> Bytes))
+(define (page-title->path title)
+ (percent-encode (regexp-replace* " " title "_") path-set #f))
+
+(: fix-semicolons-url-path ((Listof Path/Param) -> (Listof Path/Param)))
+(define (fix-semicolons-url-path pps)
+ (for/list ([pp pps])
+ (define path (path/param-path pp))
+ (if (or (null? (path/param-param pp))
+ (symbol? path))
+ pp
+ ;; path/param does have params, which need to be fixed into a semicolon.
+ (path/param
+ (string-append path ";" (string-join (path/param-param pp) ";"))
+ null))))
+
+(: fix-semicolons-url (URL -> URL))
+(define (fix-semicolons-url orig-url)
+ (struct-copy url orig-url [path (fix-semicolons-url-path (url-path orig-url))]))
+
diff --git a/src/xexpr-utils.rkt b/lib/xexpr-utils.rkt
similarity index 90%
rename from src/xexpr-utils.rkt
rename to lib/xexpr-utils.rkt
index f5ef3cf..e1ac957 100644
--- a/src/xexpr-utils.rkt
+++ b/lib/xexpr-utils.rkt
@@ -86,15 +86,16 @@
; "element" is a real element with a type and everything (non-string, non-attributes)
(define (element-is-element? element)
- (and (element-is-bits? element) (not (element-is-xattributes? element))))
+ (and (element-is-bits? element) (not (eq? (car element) '&))(not (element-is-xattributes? element))))
(module+ test
(check-true (element-is-element? '(span "hi")))
(check-false (element-is-element? '(@ (alt "Cute cat."))))
- (check-false (element-is-element? "hi")))
+ (check-false (element-is-element? "hi"))
+ (check-false (element-is-element? '(& ndash))))
-; "element content" is a real element or a string
+; "element content" is a real element or a string or a (& x) sequence
(define (element-is-content? element)
- (or (string? element) (element-is-element? element)))
+ (or (string? element) (element-is-element? element) (and (pair? element) (eq? (car element) '&))))
(module+ test
(check-true (element-is-content? '(span "hi")))
(check-false (element-is-content? '(@ (alt "Cute cat."))))
@@ -129,7 +130,7 @@
(λ (element-type attributes children)
(equal? (get-attribute name attributes) value)))
-(define (query-selector selector element)
+(define (query-selector selector element #:include-text? [include-text? #f])
(generator
()
(let loop ([element element])
@@ -140,7 +141,9 @@
[(equal? element-type '*DECL*) #f]
[(equal? element-type '@) #f]
[#t
- (when (selector element-type attributes children)
+ (when (if include-text?
+ (selector element-type attributes children (filter string? (cdr element)))
+ (selector element-type attributes children))
(yield element))
(for ([child children]) (loop child))]))
#f))
@@ -188,7 +191,9 @@
'(body "Hey" (& nbsp) (a (@ (href "/"))))))
(define (has-class? name attributes)
- (and (member name (string-split (or (get-attribute 'class attributes) "") " ")) #t))
+ ;; splitting without specifying separator or splitting on #px"\\s+" makes
+ ;; string-split use a faster whitespace-specialized implementation.
+ (and (member name (string-split (or (get-attribute 'class attributes) "") #px"\\s+")) #t))
(module+ test
(check-true (has-class? "red" '((class "yellow red blue"))))
(check-false (has-class? "red" '((class "yellow blue"))))
diff --git a/misc/download-wiki-names.rkt b/misc/download-wiki-names.rkt
index 96a8f9a..14e281f 100644
--- a/misc/download-wiki-names.rkt
+++ b/misc/download-wiki-names.rkt
@@ -4,7 +4,7 @@
racket/string
json
net/http-easy
- html-parsing
+ "../lib/html-parsing/main.rkt"
"../src/xexpr-utils.rkt"
"../src/url-utils.rkt")
diff --git a/req.rktd b/req.rktd
new file mode 100644
index 0000000..e2d2fc2
--- /dev/null
+++ b/req.rktd
@@ -0,0 +1 @@
+((local (".")))
diff --git a/src/application-globals.rkt b/src/application-globals.rkt
index a0ff8af..26cd6ae 100644
--- a/src/application-globals.rkt
+++ b/src/application-globals.rkt
@@ -1,23 +1,27 @@
#lang racket/base
-(require racket/list
+(require racket/file
+ racket/list
+ racket/runtime-path
racket/string
json
(prefix-in easy: net/http-easy)
+ html-parsing
html-writing
web-server/http
+ web-server/http/bindings
"config.rkt"
"data.rkt"
- "niwa-data.rkt"
+ "extwiki-data.rkt"
+ "extwiki-generic.rkt"
"static-data.rkt"
- "pure-utils.rkt"
- "xexpr-utils.rkt"
- "url-utils.rkt")
+ "../lib/syntax.rkt"
+ "../lib/pure-utils.rkt"
+ "../lib/xexpr-utils.rkt"
+ "../lib/url-utils.rkt")
(provide
; headers to always send on all http responses
always-headers
- ; timeout durations for http-easy requests
- timeouts
; generates a consistent footer
application-footer
; generates a consistent template for wiki page content to sit in
@@ -27,12 +31,18 @@
(module+ test
(require rackunit
- html-writing))
+ html-writing
+ "test-utils.rkt"))
(define always-headers
(list (header #"Referrer-Policy" #"same-origin") ; header to not send referers to fandom
(header #"Link" (string->bytes/latin-1 link-header))))
-(define timeouts (easy:make-timeout-config #:lease 5 #:connect 5))
+
+(define-runtime-path path-static "../static")
+(define theme-icons
+ (for/hasheq ([theme '(default light dark)])
+ (values theme
+ (html->xexp (file->string (build-path path-static (format "icon-theme-~a.svg" theme)) #:mode 'binary)))))
(define (application-footer source-url #:license [license-in #f])
(define license (or license-in license-default))
@@ -48,75 +58,130 @@
"Documentation and more information"))
(p
(a (@ (href "https://lists.sr.ht/~cadence/breezewiki-discuss"))
- "Discussions / Bug reports / Feature requests"))
+ "Chat / Bug reports / Feature requests"))
+ ,(if (config-member? 'promotions::indie_wiki_buddy "footer")
+ `(p
+ (a (@ (href "https://getindie.wiki/"))
+ "Get Indie Wiki Buddy browser extension - be redirected to BreezeWiki every time!"))
+ "")
,(if (config-true? 'instance_is_official)
`(p ,(format "This instance is run by the ~a developer, " (config-get 'application_name))
(a (@ (href "https://cadence.moe/contact"))
- "Cadence."))
+ "Cadence")
+ ".")
`(p
,(format "This unofficial instance is based off the ~a source code, but is not controlled by the code developer." (config-get 'application_name)))))
,(if source-url
`(div (p "This page displays proxied content from "
- (a (@ (href ,source-url) (rel "noreferrer")) ,source-url)
+ (a (@ (href ,source-url) (rel "nofollow noreferrer")) ,source-url)
,(format ". Text content is available under the ~a license, " (license^-text license))
- (a (@ (href ,(license^-url license))) "see license info.")
+ (a (@ (href ,(license^-url license)) (rel "nofollow")) "see license info.")
" Media files may have different copying restrictions.")
(p ,(format "Fandom is a trademark of Fandom, Inc. ~a is not affiliated with Fandom." (config-get 'application_name))))
`(div (p "Text content on wikis run by Fandom is available under the Creative Commons Attribution-Share Alike License 3.0 (Unported), "
- (a (@ (href "https://www.fandom.com/licensing")) "see license info.")
+ (a (@ (href "https://www.fandom.com/licensing") (rel "nofollow")) "see license info.")
" Media files and official Fandom documents have different copying restrictions.")
(p ,(format "Fandom is a trademark of Fandom, Inc. ~a is not affiliated with Fandom." (config-get 'application_name))))))))
;; generate a notice with a link if a fandom wiki has a replacement as part of NIWA or similar
;; if the wiki has no replacement, display nothing
-(define (niwa-notice wikiname title)
- (define ind (findf (λ (item) (member wikiname (first item))) niwa-data))
- (if ind
- (let* ([search-page (format "/Special:Search?~a"
- (params->query `(("search" . ,title)
- ("go" . "Go"))))]
- [go (if (string-suffix? (third ind) "/")
- (regexp-replace #rx"/$" (third ind) (λ (_) search-page))
- (let* ([joiner (second (regexp-match #rx"/(w[^./]*)/" (third ind)))])
- (regexp-replace #rx"/w[^./]*/.*$" (third ind) (λ (_) (format "/~a~a" joiner search-page)))))])
- `(aside (@ (class "niwa__notice"))
- (h1 (@ (class "niwa__header")) ,(second ind) " has its own website separate from Fandom.")
- (a (@ (class "niwa__go") (href ,go)) "Read " ,title " on " ,(second ind) " →")
- (div (@ (class "niwa__cols"))
- (div (@ (class "niwa__left"))
- (p "Most major Nintendo wikis are part of the "
- (a (@ (href "https://www.niwanetwork.org/about/")) "Nintendo Independent Wiki Alliance")
- " and have their own wikis off Fandom. You can help this wiki by "
- (a (@ (href ,go)) "visiting it directly."))
- (p ,(fifth ind))
- (div (@ (class "niwa__divider")))
- (p "Why are you seeing this message? Fandom refuses to delete or archive their copy of this wiki, so that means their pages will appear high up in search results. Fandom hopes to get clicks from readers who don't know any better.")
- (p (@ (class "niwa__feedback")) (a (@ (href "https://www.kotaku.com.au/2022/10/massive-zelda-wiki-reclaims-independence-six-months-before-tears-of-the-kingdom/")) "More info") " / " (a (@ (href "https://docs.breezewiki.com/Reporting_Bugs.html")) "Feedback on this notice?")))
- (div (@ (class "niwa__right"))
- (img (@ (class "niwa__logo") (src ,(format "https://www.niwanetwork.org~a" (fourth ind)))))))))
- ""))
+(define (extwiki-notice wikiname title req user-cookies)
+ (define xt (findf (λ (item) (member wikiname (extwiki^-wikinames item))) extwikis))
+ (cond/var
+ [xt
+ (let* ([seen? (member wikiname (user-cookies^-notices user-cookies))]
+ [aside-class (if seen? "niwa__notice niwa--seen" "niwa__notice")]
+ [group (hash-ref extwiki-groups (extwiki^-group xt))]
+ [search-page (format "/Special:Search?~a"
+ (params->query `(("search" . ,title)
+ ("go" . "Go"))))]
+ [go (if (string-suffix? (extwiki^-home xt) "/")
+ (regexp-replace #rx"/$" (extwiki^-home xt) (λ (_) search-page))
+ (let* ([joiner (second (regexp-match #rx"/(w[^./]*)/" (extwiki^-home xt)))])
+ (regexp-replace #rx"/w[^./]*/.*$" (extwiki^-home xt) (λ (_) (format "/~a~a" joiner search-page)))))]
+ [props (extwiki-props^ go)])
+ (cond
+ [(eq? (extwiki^-banner xt) 'default)
+ `(aside (@ (class ,aside-class))
+ (h1 (@ (class "niwa__header")) ,(extwiki^-name xt) " has its own website separate from Fandom.")
+ (a (@ (class "niwa__go") (href ,go)) "Read " ,title " on " ,(extwiki^-name xt) " →")
+ (div (@ (class "niwa__cols"))
+ (div (@ (class "niwa__left"))
+ (p ,((extwiki^-description xt) props))
+ (p ,((extwiki-group^-description group) props))
+ (p "This wiki's core community has largely migrated away from Fandom. You should "
+ (a (@ (href ,go)) "go to " ,(extwiki^-name xt) " now!"))
+ (p (@ (class "niwa__feedback"))
+ ,@(add-between
+ `(,@(for/list ([link (extwiki-group^-links group)])
+ `(a (@ (href ,(cdr link))) ,(car link)))
+ "This notice is from BreezeWiki"
+ (a (@ (rel "nofollow")
+ (class "niwa__got-it")
+ (href ,(user-cookies-setter-url/add-notice req user-cookies wikiname)))
+ "OK, got it"))
+ " / ")))
+ (div (@ (class "niwa__right"))
+ (img (@ (class "niwa__logo") (src ,(extwiki^-logo xt)))))))]
+ [(eq? (extwiki^-banner xt) 'parallel)
+ `(aside (@ (class "niwa__parallel"))
+ (h1 (@ (class "niwa__header-mini"))
+ "See also "
+ (a (@ (href ,go)) ,(extwiki^-name xt)))
+ (p "This topic has multiple communities of editors, some active on the Fandom wiki, others active on " ,(extwiki^-name xt) ".")
+ (p "For thorough research, be sure to check both communities since they may have different information!")
+ (p (@ (class "niwa__feedback"))
+ ,@(add-between
+ `(,@(for/list ([link (extwiki-group^-links group)])
+ `(a (@ (href ,(cdr link))) ,(car link)))
+ "This notice is from BreezeWiki"
+ (a (@ (href "https://docs.breezewiki.com/Reporting_Bugs.html")) "Feedback?"))
+ " / ")))]
+ [(eq? (extwiki^-banner xt) 'empty)
+ `(aside (@ (class "niwa__notice niwa__notice--alt"))
+ (h1 (@ (class "niwa__header")) "You will be redirected to " ,(extwiki^-name xt) ".")
+ (p (@ (style "position: relative; top: -12px;")) "This independent wiki community has its own site separate from Fandom.")
+ (a (@ (class "niwa__go") (href ,go)) "Take me there! →")
+
+ (p (@ (class "niwa__feedback") (style "text-align: left"))
+ ,@(add-between
+ `(,@(for/list ([link (extwiki-group^-links group)])
+ `(a (@ (href ,(cdr link))) ,(car link)))
+ "This notice is from BreezeWiki")
+ " / ")))]))]
+ (var fetched-callback (get-redirect-content wikiname))
+ [fetched-callback
+ (fetched-callback title)]
+ [#t ""]))
(define (generate-wiki-page
content
+ #:req req
#:source-url source-url
#:wikiname wikiname
#:title title
#:head-data [head-data-in #f]
- #:siteinfo [siteinfo-in #f])
+ #:siteinfo [siteinfo-in #f]
+ #:user-cookies [user-cookies-in #f]
+ #:online-styles [online-styles #t])
(define siteinfo (or siteinfo-in siteinfo-default))
(define head-data (or head-data-in ((head-data-getter wikiname))))
- (define (required-styles origin)
- (map (λ (dest-path)
- (define url (format dest-path origin))
- (if (config-true? 'strict_proxy)
- (u-proxy-url url)
- url))
- '(#;"~a/load.php?lang=en&modules=skin.fandomdesktop.styles&only=styles&skin=fandomdesktop"
- #;"~a/load.php?lang=en&modules=ext.gadget.dungeonsWiki%2CearthWiki%2Csite-styles%2Csound-styles&only=styles&skin=fandomdesktop"
- #;"~a/load.php?lang=en&modules=site.styles&only=styles&skin=fandomdesktop"
- ; combine the above entries into a single request for potentially extra speed - fandom.com doesn't even do this!
- "~a/wikia.php?controller=ThemeApi&method=themeVariables"
- "~a/load.php?lang=en&modules=skin.fandomdesktop.styles%7Cext.fandom.PortableInfoboxFandomDesktop.css%7Cext.fandom.GlobalComponents.CommunityHeaderBackground.css%7Cext.gadget.site-styles%2Csound-styles%7Csite.styles&only=styles&skin=fandomdesktop")))
+ (define user-cookies (or user-cookies-in (user-cookies-getter req)))
+ (define origin (format "https://~a.fandom.com" wikiname))
+ (define required-styles
+ (cond
+ [online-styles
+ (define styles
+ (list
+ (format "~a/wikia.php?controller=ThemeApi&method=themeVariables&variant=~a" origin (user-cookies^-theme user-cookies))
+ (format "~a/load.php?lang=en&modules=site.styles%7Cskin.fandomdesktop.styles%7Cext.fandom.PortableInfoboxFandomDesktop.css%7Cext.fandom.GlobalComponents.CommunityHeaderBackground.css%7Cext.fandom.photoGallery.gallery.css%7Cext.gadget.site-styles%2Csound-styles&only=styles&skin=fandomdesktop" origin)))
+ (if (config-true? 'strict_proxy)
+ (map u-proxy-url styles)
+ styles)]
+ [#t
+ (list
+ (format "/archive/~a/styles/themeVariables-~a.css" wikiname (user-cookies^-theme user-cookies))
+ (format "/archive/~a/styles/site.css" wikiname))]))
`(*TOP*
(*DECL* DOCTYPE html)
(html
@@ -128,7 +193,7 @@
(config-get 'application_name)))
,@(map (λ (url)
`(link (@ (rel "stylesheet") (type "text/css") (href ,url))))
- (required-styles (format "https://~a.fandom.com" wikiname)))
+ required-styles)
(link (@ (rel "stylesheet") (type "text/css") (href ,(get-static-url "main.css"))))
(script "const BWData = "
,(jsexpr->string (hasheq 'wikiname wikiname
@@ -136,15 +201,33 @@
,(if (config-true? 'feature_search_suggestions)
`(script (@ (type "module") (src ,(get-static-url "search-suggestions.js"))))
"")
+ (script (@ (type "module") (src ,(get-static-url "countdown.js"))))
+ (script (@ (defer) (src ,(get-static-url "tabs.js"))))
(link (@ (rel "icon") (href ,(u (λ (v) (config-true? 'strict_proxy))
(λ (v) (u-proxy-url v))
(head-data^-icon-url head-data))))))
- (body (@ (class ,(head-data^-body-class head-data)))
+ (body (@ (class ,(head-data^-body-class head-data) " bw-tabs-nojs"))
+ ,(let ([extension-eligible?
+ (cond/var
+ [(not req) #f]
+ [(not (config-member? 'promotions::indie_wiki_buddy "banner")) #f]
+ (var ua-pair (assq 'user-agent (request-headers req)))
+ [(not ua-pair) #f]
+ (var ua (string-downcase (cdr ua-pair)))
+ ;; everyone pretends to be chrome, so we do it in reverse
+ ;; this excludes common browsers that don't support the extension
+ [#t (and (not (string-contains? ua "edge/"))
+ (not (string-contains? ua "mobile")))])])
+ (if extension-eligible?
+ `(div (@ (class "bw-top-banner"))
+ (div (@ (class "bw-top-banner-rainbow"))
+ "Try " (a (@ (href "https://getindie.wiki/") (target "_blank")) "our affiliated browser extension") " - redirect to BreezeWiki automatically!\n"))
+ ""))
(div (@ (class "main-container"))
(div (@ (class "fandom-community-header__background tileHorizontally header")))
(div (@ (class "page"))
(main (@ (class "page__main"))
- ,(niwa-notice wikiname title)
+ ,(extwiki-notice wikiname title req user-cookies)
(div (@ (class "custom-top"))
(h1 (@ (class "page-title")) ,title)
(nav (@ (class "sitesearch"))
@@ -154,7 +237,23 @@
(label (@ (for "bw-search-input")) "Search ")
(div (@ (id "bw-pr-search-input"))
(input (@ (type "text") (name "q") (id "bw-search-input") (autocomplete "off"))))
- (div (@ (class "bw-ss__container") (id "bw-pr-search-suggestions"))))))
+ (div (@ (class "bw-ss__container") (id "bw-pr-search-suggestions"))))
+ (div (@ (class "bw-theme__select"))
+ (span (@ (class "bw-theme__main-label")) "Page theme")
+ (span (@ (class "bw-theme__items"))
+ ,@(for/list ([theme '(default light dark)])
+ (define class
+ (if (equal? theme (user-cookies^-theme user-cookies))
+ "bw-theme__item bw-theme__item--selected"
+ "bw-theme__item"))
+ `(a (@ (rel "nofollow")
+ (href ,(user-cookies-setter-url
+ req
+ (struct-copy user-cookies^ user-cookies
+ [theme theme]))) (class ,class))
+ (span (@ (class "bw-theme__icon-container"))
+ ,(hash-ref theme-icons theme))
+ ,(format "~a" theme)))))))
(div (@ (id "content") #;(class "page-content"))
(div (@ (id "mw-content-text"))
,content))
@@ -164,6 +263,7 @@
(parameterize ([(config-parameter 'strict_proxy) "true"])
(generate-wiki-page
'(template)
+ #:req test-req
#:source-url ""
#:title "test"
#:wikiname "test")))
@@ -178,11 +278,11 @@
page))))
"/proxy?dest=https%3A%2F%2Ftest.fandom.com")))
-(define (generate-redirect dest)
+(define (generate-redirect dest #:headers [headers-in '()])
(define dest-bytes (string->bytes/utf-8 dest))
(response/output
#:code 302
- #:headers (list (header #"Location" dest-bytes))
+ #:headers (append (list (header #"Location" dest-bytes)) headers-in)
(λ (out)
(write-html
`(html
diff --git a/src/config.rkt b/src/config.rkt
index bc0cf6c..b1afe0a 100644
--- a/src/config.rkt
+++ b/src/config.rkt
@@ -2,19 +2,17 @@
(require racket/function
racket/pretty
racket/runtime-path
- racket/string)
-(require/typed ini
- [#:opaque Ini ini?]
- [read-ini (Input-Port -> Ini)]
- [ini->hash (Ini -> (Immutable-HashTable Symbol (Immutable-HashTable Symbol String)))])
+ racket/string
+ typed/ini)
(provide
config-parameter
config-true?
+ config-member?
config-get)
(module+ test
- (require "typed-rackunit.rkt"))
+ (require "../lib/typed-rackunit.rkt"))
(define-runtime-path path-config "../config.ini")
@@ -26,72 +24,87 @@
(define (config-true? key)
(not (member ((config-parameter key)) '("" "false"))))
+(: config-member? (Symbol String [#:sep String] -> Boolean))
+(define (config-member? key item #:sep [sep #px"\\s+"])
+ (and (config-true? key)
+ (not (not (member item (string-split (config-get key) sep))))))
+
(: config-get (Symbol -> String))
(define (config-get key)
((config-parameter key)))
(define default-config
'((application_name . "BreezeWiki")
+ (bind_host . "auto")
+ (port . "10416")
(canonical_origin . "")
(debug . "false")
(feature_search_suggestions . "true")
(instance_is_official . "false") ; please don't turn this on, or you will make me very upset
(log_outgoing . "true")
- (port . "10416")
- (strict_proxy . "true")))
+ (strict_proxy . "false")
+
+ (feature_offline::enabled . "false")
+ (feature_offline::format . "json.gz")
+ (feature_offline::only . "false")
+ (feature_offline::search . "fandom")
+
+ (access_log::enabled . "false")
+
+ (promotions::indie_wiki_buddy . "banner home")))
(define loaded-alist
(with-handlers
([exn:fail:filesystem:errno?
(λ (exn)
- (begin0
- '()
- (displayln "note: config file not detected, using defaults")))]
+ (displayln "note: config file not detected, using defaults")
+ '())]
[exn:fail:contract?
(λ (exn)
- (begin0
- '()
- (displayln "note: config file empty or missing [] section, using defaults")))])
+ (displayln "note: config file empty or missing [] section, using defaults")
+ '())])
+ (define h (in-hash
+ (ini->hash
+ (call-with-input-file path-config
+ (λ (in)
+ (read-ini in))))))
(define l
- (hash->list
- (hash-ref
- (ini->hash
- (call-with-input-file path-config
- (λ (in)
- (read-ini in))))
- '||)))
- (begin0
- l
- (printf "note: ~a items loaded from config file~n" (length l)))))
+ (for*/list : (Listof (Pairof Symbol String))
+ ([(section-key section) h]
+ [(key value) (in-hash section)])
+ (if (eq? section-key '||)
+ (cons key value)
+ (cons (string->symbol (string-append (symbol->string section-key)
+ "::"
+ (symbol->string key)))
+ value))))
+ (printf "note: ~a items loaded from config file~n" (length l))
+ l))
(define env-alist
- (let ([e-names (environment-variables-names (current-environment-variables))]
- [e-ref (λ ([name : Bytes])
- (bytes->string/latin-1
- (cast (environment-variables-ref (current-environment-variables) name)
- Bytes)))])
- (map (λ ([name : Bytes])
- (cons (string->symbol (string-downcase (substring (bytes->string/latin-1 name) 3)))
- (e-ref name)))
- (filter (λ ([name : Bytes]) (string-prefix? (string-downcase (bytes->string/latin-1 name))
- "bw_"))
- e-names))))
+ (for/list : (Listof (Pairof Symbol String))
+ ([name (environment-variables-names (current-environment-variables))]
+ #:when (string-prefix? (string-downcase (bytes->string/latin-1 name)) "bw_"))
+ (cons
+ ;; key: convert to string, remove bw_ prefix, convert to symbol
+ (string->symbol (string-downcase (substring (bytes->string/latin-1 name) 3)))
+ ;; value: convert to string
+ (bytes->string/latin-1
+ (cast (environment-variables-ref (current-environment-variables) name) Bytes)))))
(when (> (length env-alist) 0)
(printf "note: ~a items loaded from environment variables~n" (length env-alist)))
(define combined-alist (append default-config loaded-alist env-alist))
(define config
- (make-immutable-hasheq
- (map (λ ([pair : (Pairof Symbol String)])
- (cons (car pair) (make-parameter (cdr pair))))
- combined-alist)))
+ (for/hasheq ([pair combined-alist]) : (Immutable-HashTable Symbol (Parameter String))
+ (values (car pair) (make-parameter (cdr pair)))))
(when (config-true? 'debug)
; all values here are optimised for maximum prettiness
(parameterize ([pretty-print-columns 80])
(display "config: ")
- (pretty-write ((inst sort (Pairof Symbol String))
+ (pretty-write ((inst sort (Pairof Symbol String) Symbol)
(hash->list (make-immutable-hasheq combined-alist))
symbol
#:key car))))
@@ -105,6 +118,10 @@
(module+ test
; this is just a sanity check
(parameterize ([(config-parameter 'application_name) "JeffWiki"]
- [(config-parameter 'strict_proxy) ""])
+ [(config-parameter 'strict_proxy) ""]
+ [(config-parameter 'promotions::indie_wiki_buddy) "a b c"])
(check-equal? (config-get 'application_name) "JeffWiki")
- (check-false (config-true? 'strict_proxy))))
+ (check-false (config-true? 'strict_proxy))
+ (check-equal? (string? (config-get 'feature_offline::format)) #t)
+ (check-true (config-member? 'promotions::indie_wiki_buddy "b"))))
+
diff --git a/src/data.rkt b/src/data.rkt
index f856710..63c7f03 100644
--- a/src/data.rkt
+++ b/src/data.rkt
@@ -1,46 +1,76 @@
#lang racket/base
(require racket/list
racket/match
+ racket/string
+ web-server/http/request-structs
+ net/url-string
+ (only-in net/cookies/server cookie-header->alist cookie->set-cookie-header make-cookie)
(prefix-in easy: net/http-easy)
+ db
memo
+ "fandom-request.rkt"
"static-data.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt"
+ "../archiver/archiver-database.rkt"
+ "config.rkt")
(provide
(struct-out siteinfo^)
(struct-out license^)
(struct-out head-data^)
+ (struct-out user-cookies^)
siteinfo-fetch
siteinfo-default
license-default
head-data-getter
- head-data-default)
+ head-data-default
+ user-cookies-getter
+ user-cookies-default
+ user-cookies-setter
+ user-cookies-setter-url
+ user-cookies-setter-url/add-notice)
(struct siteinfo^ (sitename basepage license) #:transparent)
(struct license^ (text url) #:transparent)
(struct head-data^ (body-class icon-url) #:transparent)
(define license-default (license^ "CC-BY-SA" "https://www.fandom.com/licensing"))
-(define siteinfo-default (siteinfo^ "Test Wiki" "Main_Page" license-default))
+(define siteinfo-default (siteinfo^ "Unknown Wiki" "Main_Page" license-default))
(define head-data-default (head-data^ "skin-fandomdesktop" (get-static-url "breezewiki-favicon.svg")))
+(when (config-true? 'feature_offline::only)
+ (void (get-slc)))
+
(define/memoize (siteinfo-fetch wikiname) #:hash hash
- (define dest-url
- (format "https://~a.fandom.com/api.php?~a"
- wikiname
- (params->query '(("action" . "query")
- ("meta" . "siteinfo")
- ("siprop" . "general|rightsinfo")
- ("format" . "json")
- ("formatversion" . "2")))))
- (log-outgoing dest-url)
- (define res (easy:get dest-url))
- (define data (easy:response-json res))
- (siteinfo^ (jp "/query/general/sitename" data)
- (second (regexp-match #rx"/wiki/(.*)" (jp "/query/general/base" data)))
- (license^ (jp "/query/rightsinfo/text" data)
- (jp "/query/rightsinfo/url" data))))
+ (cond
+ [(config-true? 'feature_offline::only)
+ (when (config-true? 'debug)
+ (printf "using offline mode for siteinfo ~a~n" wikiname))
+ (define row (query-maybe-row* "select sitename, basepage, license_text, license_url from wiki where wikiname = ?"
+ wikiname))
+ (if row
+ (siteinfo^ (vector-ref row 0)
+ (vector-ref row 1)
+ (license^ (vector-ref row 2)
+ (vector-ref row 3)))
+ siteinfo-default)]
+ [else
+ (define res
+ (fandom-get-api
+ wikiname
+ '(("action" . "query")
+ ("meta" . "siteinfo")
+ ("siprop" . "general|rightsinfo")
+ ("format" . "json")
+ ("formatversion" . "2"))))
+ (cond [(= (easy:response-status-code res) 200)
+ (define data (easy:response-json res))
+ (siteinfo^ (jp "/query/general/sitename" data)
+ (second (regexp-match #rx"/wiki/(.*)" (jp "/query/general/base" data)))
+ (license^ (jp "/query/rightsinfo/text" data)
+ (jp "/query/rightsinfo/url" data)))]
+ [else siteinfo-default])]))
(define/memoize (head-data-getter wikiname) #:hash hash
;; data will be stored here, can be referenced by the memoized closure
@@ -61,3 +91,40 @@
(set! this-data data))
;; then no matter what, return the best information we have so far
this-data))
+
+(struct user-cookies^ (theme notices) #:prefab)
+(define user-cookies-default (user-cookies^ 'default '()))
+(define (user-cookies-getter req)
+ (define cookie-header (headers-assq* #"cookie" (request-headers/raw req)))
+ (define cookies-alist (if cookie-header (cookie-header->alist (header-value cookie-header) bytes->string/utf-8) null))
+ (define cookies-hash
+ (for/hasheq ([pair cookies-alist])
+ (match pair
+ [(cons "theme" (and theme (or "light" "dark" "default")))
+ (values 'theme (string->symbol theme))]
+ [(cons "notices" notices)
+ (values 'notices (string-split notices "|"))]
+ [_ (values #f #f)])))
+ (user-cookies^
+ (hash-ref cookies-hash 'theme (user-cookies^-theme user-cookies-default))
+ (hash-ref cookies-hash 'notices (user-cookies^-notices user-cookies-default))))
+
+(define (user-cookies-setter user-cookies)
+ (map (λ (c) (header #"Set-Cookie" (cookie->set-cookie-header c)))
+ (list (make-cookie "theme" (symbol->string (user-cookies^-theme user-cookies))
+ #:path "/"
+ #:max-age (* 60 60 24 365 10))
+ (make-cookie "notices" (string-join (user-cookies^-notices user-cookies) "|")
+
+ #:path "/"
+ #:max-age (* 60 60 24 365 10)))))
+
+(define (user-cookies-setter-url req new-settings)
+ (format "/set-user-settings?~a" (params->query `(("next_location" . ,(url->string (request-uri req)))
+ ("new_settings" . ,(format "~s" new-settings))))))
+
+(define (user-cookies-setter-url/add-notice req user-cookies notice-name)
+ (user-cookies-setter-url
+ req
+ (struct-copy user-cookies^ user-cookies
+ [notices (cons notice-name (user-cookies^-notices user-cookies))])))
diff --git a/src/dispatcher-tree.rkt b/src/dispatcher-tree.rkt
index 9e072bc..a967095 100644
--- a/src/dispatcher-tree.rkt
+++ b/src/dispatcher-tree.rkt
@@ -1,15 +1,17 @@
#lang racket/base
-(require "syntax.rkt"
+(require "../lib/syntax.rkt"
(for-syntax racket/base)
racket/string
net/url
+ web-server/http
+ web-server/dispatchers/dispatch
(prefix-in host: web-server/dispatchers/dispatch-host)
(prefix-in pathprocedure: web-server/dispatchers/dispatch-pathprocedure)
(prefix-in sequencer: web-server/dispatchers/dispatch-sequencer)
(prefix-in lift: web-server/dispatchers/dispatch-lift)
(prefix-in filter: web-server/dispatchers/dispatch-filter)
"config.rkt"
- "url-utils.rkt")
+ "../lib/url-utils.rkt")
(provide
; syntax to make the hashmap from names
@@ -31,24 +33,48 @@
; don't forget that I'm returning *code* - return a call to the function
(datum->syntax stx `(make-dispatcher-tree ,ds)))
+; guard that the page returned a response, otherwise print more detailed debugging information
+(define-syntax-rule (page ds name)
+ (λ (req)
+ (define dispatcher (hash-ref ds (quote name)))
+ (define page-response (dispatcher req))
+ (if (response? page-response)
+ page-response
+ (response/output
+ #:code 500
+ #:mime-type #"text/plain"
+ (λ (out)
+ (for ([port (list (current-error-port) out)])
+ (parameterize ([current-output-port port])
+ (printf "error in ~a:~n expected page to return a response~n actually returned: ~v~n"
+ (quote name)
+ page-response))))))))
+
(define (make-dispatcher-tree ds)
- (host:make
- (λ (host-sym)
- (if/out (config-true? 'canonical_origin)
- (let* ([host-header (symbol->string host-sym)]
- [splitter (string-append "." (url-host (string->url (config-get 'canonical_origin))))]
- [s (string-split host-header splitter #:trim? #f)])
- (if/in (and (eq? 2 (length s)) (equal? "" (cadr s)))
- ((hash-ref ds 'subdomain-dispatcher) (car s))))
- (sequencer:make
- (pathprocedure:make "/" (hash-ref ds 'page-home))
- (pathprocedure:make "/proxy" (hash-ref ds 'page-proxy))
- (pathprocedure:make "/search" (hash-ref ds 'page-global-search))
- (pathprocedure:make "/buddyfight/wiki/It_Doesn't_Work!!" (hash-ref ds 'page-it-works))
- (filter:make (pregexp (format "^/~a/wiki/Category:.+$" px-wikiname)) (lift:make (hash-ref ds 'page-category)))
- (filter:make (pregexp (format "^/~a/wiki/File:.+$" px-wikiname)) (lift:make (hash-ref ds 'page-file)))
- (filter:make (pregexp (format "^/~a/wiki/.+$" px-wikiname)) (lift:make (hash-ref ds 'page-wiki)))
- (filter:make (pregexp (format "^/~a/search$" px-wikiname)) (lift:make (hash-ref ds 'page-search)))
- (filter:make (pregexp (format "^/~a(/(wiki(/)?)?)?$" px-wikiname)) (lift:make (hash-ref ds 'redirect-wiki-home)))
- (hash-ref ds 'static-dispatcher)
- (lift:make (hash-ref ds 'page-not-found)))))))
+ (define subdomain-dispatcher (hash-ref ds 'subdomain-dispatcher))
+ (define tree
+ (sequencer:make
+ subdomain-dispatcher
+ (pathprocedure:make "/" (page ds page-home))
+ (pathprocedure:make "/proxy" (page ds page-proxy))
+ (pathprocedure:make "/search" (page ds page-global-search))
+ (pathprocedure:make "/set-user-settings" (page ds page-set-user-settings))
+ (pathprocedure:make "/buddyfight/wiki/It_Doesn't_Work!!" (page ds page-it-works))
+ (filter:make (pregexp (format "^/~a/wiki/Category:.+$" px-wikiname)) (lift:make (page ds page-category)))
+ (filter:make (pregexp (format "^/~a/wiki/File:.+$" px-wikiname)) (lift:make (page ds page-file)))
+ (if (config-true? 'feature_offline::enabled)
+ (filter:make (pregexp (format "^/~a/wiki/.+$" px-wikiname)) (lift:make (page ds page-wiki-offline)))
+ (λ (_conn _req) (next-dispatcher)))
+ (filter:make (pregexp (format "^/~a/wiki/.+$" px-wikiname)) (lift:make (page ds page-wiki)))
+ (filter:make (pregexp (format "^/~a/search$" px-wikiname)) (lift:make (page ds page-search)))
+ (filter:make (pregexp (format "^/~a(/(wiki(/)?)?)?$" px-wikiname)) (lift:make (page ds redirect-wiki-home)))
+ (if (config-true? 'feature_offline::enabled)
+ (filter:make (pregexp (format "^/archive/~a/(styles|images)/.+$" px-wikiname)) (lift:make (page ds page-static-archive)))
+ (λ (_conn _req) (next-dispatcher)))
+ (hash-ref ds 'static-dispatcher)
+ (lift:make (hash-ref ds 'page-not-found))))
+ (make-semicolon-fixer-dispatcher tree))
+
+(define ((make-semicolon-fixer-dispatcher orig-dispatcher) conn orig-req)
+ (define new-req (struct-copy request orig-req [uri (fix-semicolons-url (request-uri orig-req))]))
+ (orig-dispatcher conn new-req))
diff --git a/src/extwiki-data.rkt b/src/extwiki-data.rkt
new file mode 100644
index 0000000..a8ee159
--- /dev/null
+++ b/src/extwiki-data.rkt
@@ -0,0 +1,593 @@
+#lang racket/base
+
+(provide
+ (struct-out extwiki-props^)
+ (struct-out extwiki-group^)
+ extwiki-groups
+ (struct-out extwiki^)
+ extwikis)
+
+(struct extwiki-props^ (go) #:transparent)
+
+(struct extwiki-group^ (name links description) #:transparent)
+(define extwiki-groups
+ (hasheq 'NIWA
+ (extwiki-group^
+ "NIWA"
+ '(("Why did editors leave Fandom?" . "https://www.kotaku.com.au/2022/10/massive-zelda-wiki-reclaims-independence-six-months-before-tears-of-the-kingdom/"))
+ (λ (props)
+ `(p "Most major Nintendo wikis are part of the "
+ (a (@ (href "https://www.niwanetwork.org/about/")) "Nintendo Independent Wiki Alliance")
+ " and have their own wikis off Fandom.")))
+
+ 'SEIWA
+ (extwiki-group^
+ "SEIWA"
+ '(("SEIWA Website" . "https://seiwanetwork.org/"))
+ (λ (props)
+ `(p "The Square Enix Indpendent Wiki Alliance, or SEIWA, is a network of independent wikis established in 2011 and focused on providing high-quality coverage of Square Enix and its content. We work together, along with our affiliates and others, to co-operate and support one another while providing the best-quality content on the various Square Enix video games and media.")))
+
+ 'GWN
+ (extwiki-group^
+ "GWN"
+ '(("Gaming Wiki Network" . "https://gamingwikinetwork.org/"))
+ (λ (props)
+ `(p "This wiki is part of the Gaming Wiki Network, a network of independently-hosted wikis about video game franchises. The GWN was founded on October 21, 2022. It aims to support all gaming communities in building independently-hosted wikis.")))
+
+ 'Terraria
+ (extwiki-group^
+ "Terraria"
+ '(("Announcement: New Official Terraria Wiki!" . "https://forums.terraria.org/index.php?threads/new-official-terraria-wiki-launches-today.111239/") ("In the media" . "https://www.pcgamesn.com/terraria/wiki"))
+ (λ (props) '()))
+
+ 'Calamity_Mod
+ (extwiki-group^
+ "Calamity Mod"
+ '(("Announcement: Moving to wiki.gg" . "https://www.reddit.com/r/CalamityMod/comments/ts0586/important_calamity_wiki_announcement/"))
+ (λ (props) '()))
+
+ 'ARK
+ (extwiki-group^
+ "ARK"
+ '(("Announcement: Official Wiki Is Moving!" . "https://survivetheark.com/index.php?/forums/topic/657902-official-ark-wiki-feedback/")
+ ("Reasons" . "https://todo.sr.ht/~cadence/breezewiki-todo/4#event-216613")
+ ("Browser Extension" . "https://old.reddit.com/r/playark/comments/xe51sy/official_ark_wiki_launched_a_browser_extension_to/"))
+ (λ (props) '()))
+
+ 'Astroneer
+ (extwiki-group^
+ "Astroneer"
+ '(("Migration discussion" . "https://old.reddit.com/r/Astroneer/comments/z905id/the_official_astroneer_wiki_has_moved_to_wikigg/") ("Migration info" . "https://astroneer.fandom.com/wiki/Talk:Astroneer_Wiki/Migration_to_Wiki.gg"))
+ (λ (props) '()))
+
+ 'RuneScape
+ (extwiki-group^
+ "RuneScape"
+ '(("Leaving Wikia" . "https://runescape.wiki/w/Forum:Leaving_Wikia")
+ ("In the media" . "https://kotaku.com/video-game-wikis-abandon-their-platform-after-year-of-p-1829401866")
+ ("Browser Extension" . "https://runescape.wiki/w/RuneScape:Finding_the_wikis_with_ease#Extensions"))
+ (λ (props) '()))
+
+ 'Minecraft
+ (extwiki-group^
+ "Minecraft"
+ '(("Post-Move FAQ" . "https://minecraft.wiki/w/Minecraft_Wiki:Moving_from_Fandom")
+ ("Pre-Move Discussion" . "https://minecraft.fandom.com/wiki/Minecraft_Wiki:Moving_from_Fandom")
+ ("In the media: PCGamer" . "https://www.pcgamer.com/official-minecraft-wiki-editors-so-furious-at-fandoms-degraded-functionality-and-popups-theyre-overwhelmingly-voting-to-leave-the-site/")
+ ("In the media: PCGamesN" . "https://www.pcgamesn.com/minecraft/wiki-fandom"))
+ (λ (props)
+ '(p "The wiki was founded by Citricsquid on July 16th, 2009 as a way to document information from Minecraft. Since November 15th, 2010, it has been hosted by Curse Media. On December 12th, 2018, it moved to Fandom as it purchased Curse Media. Since September 24, 2023, it forked from Fandom and has been hosted by Weird Gloop.")))
+
+ 'Tardis
+ (extwiki-group^
+ "Tardis"
+ '(("Forking announcement" . "https://tardis.wiki/wiki/Tardis:Forking_announcement")
+ ("Discussion on Reddit" . "https://old.reddit.com/r/doctorwho/comments/1azxmrl/tardis_wiki_has_regenerated/"))
+ (λ (props) '()))
+
+ 'Rainverse
+ (extwiki-group^
+ "Rainverse"
+ '(("Forking announcement" . "https://transfem.social/notes/9qsqdkmqi78e01bh"))
+ (λ (props)
+ '()))
+
+ 'empty
+ (extwiki-group^
+ "Misc"
+ '(("This wiki doesn't have a description yet. Add one?" . "https://docs.breezewiki.com/Reporting_Bugs.html"))
+ (λ (props) '()))))
+
+;; wikiname, niwa-name, url, logo-url
+(struct extwiki^ (wikinames banner group name home logo description) #:transparent)
+(define extwikis
+ (list
+ (extwiki^
+ '("arms" "armsgame") 'default
+ 'NIWA
+ "ARMS Institute"
+ "https://armswiki.org/wiki/Home"
+ "https://niwanetwork.org/images/logos/armswiki.png"
+ (λ (props)
+ `((p "ARMS Institute is a comprehensive resource for information about the Nintendo Switch game, ARMS. Founded on May 1, 2017 and growing rapidly, the wiki strives to offer in-depth coverage of ARMS from both a competitive and casual perspective. Join us and ARM yourself with knowledge!"))))
+
+ (extwiki^
+ '("pokemon" "monster") 'default
+ 'NIWA
+ "Bulbapedia"
+ "https://bulbapedia.bulbagarden.net/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/bulbapedia.png"
+ (λ (props)
+ `((p "A part of the Bulbagarden community, Bulbapedia was founded on December 21, 2004 by Liam Pomfret. Everything you need to know about Pokémon can be found at Bulbapedia, whether about the games, the anime, the manga, or something else entirely. With its Bulbanews section and the Bulbagarden forums, it's your one-stop online place for Pokémon."))))
+
+ (extwiki^
+ '("dragalialost") 'default
+ 'NIWA
+ "Dragalia Lost Wiki"
+ "https://dragalialost.wiki/w/Dragalia_Lost_Wiki"
+ "https://niwanetwork.org/images/logos/dragalialost.png"
+ (λ (props)
+ `((p "The Dragalia Lost Wiki was originally founded in September 2018 on the Gamepedia platform but went independent in January 2021. The Wiki aims to document anything and everything Dragalia Lost, from in-game data to mechanics, story, guides, and more!"))))
+
+ (extwiki^
+ '("dragonquest") 'default
+ 'NIWA
+ "Dragon Quest Wiki"
+ "https://dragon-quest.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/dragonquestwiki.png"
+ (λ (props)
+ `((p "Originally founded on Wikia, the Dragon Quest Wiki was largely inactive until FlyingRagnar became an admin in late 2009. The wiki went independent about a year later when it merged with the Dragon Quest Dictionary/Encyclopedia which was run by Zenithian and supported by the Dragon's Den. The Dragon Quest Wiki aims to be the most complete resource for Dragon Quest information on the web. It continues to grow in the hope that one day the series will be as popular in the rest of the world as it is in Japan."))))
+
+ (extwiki^
+ '("fireemblem") 'default
+ 'NIWA
+ "Fire Emblem Wiki"
+ "https://fireemblemwiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/fireemblemwiki.png"
+ (λ (props)
+ `((p "Growing since August 26, 2010, Fire Emblem Wiki is a project whose goal is to cover all information pertaining to the Fire Emblem series. It aspires to become the most complete and accurate independent source of information on this series."))))
+
+ (extwiki^
+ '("fzero" "f-zero") 'default
+ 'NIWA
+ "F-Zero Wiki"
+ "https://mutecity.org/wiki/F-Zero_Wiki"
+ "https://niwanetwork.org/images/logos/fzerowiki.png"
+ (λ (props)
+ `((p "Founded on Wikia in November 2007, F-Zero Wiki became independent with NIWA's help in 2011. F-Zero Wiki is quickly growing into the Internet's definitive source for the world of 2200 km/h+, from pilots to machines, and is the founding part of MuteCity.org, the web's first major F-Zero community."))))
+
+ (extwiki^
+ '("goldensun") 'default
+ 'NIWA
+ "Golden Sun Universe"
+ "https://www.goldensunwiki.net/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/goldensununiverse.png"
+ (λ (props)
+ `((p "Originally founded on Wikia in late 2006, Golden Sun Universe has always worked hard to meet one particular goal: to be the single most comprehensive yet accessible resource on the Internet for Nintendo's RPG series Golden Sun. It became an independent wiki four years later. Covering characters and plot, documenting all aspects of the gameplay, featuring walkthroughs both thorough and bare-bones, and packed with all manner of odd and fascinating minutiae, Golden Sun Universe leaves no stone unturned!"))))
+
+ (extwiki^
+ '("tetris") 'default
+ 'NIWA
+ "Hard Drop - Tetris Wiki"
+ "https://harddrop.com/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/harddrop.png"
+ (λ (props)
+ `((p "The Tetris Wiki was founded by Tetris fans for Tetris fans on tetrisconcept.com in March 2006. The Tetris Wiki torch was passed to harddrop.com in July 2009. Hard Drop is a Tetris community for all Tetris players, regardless of skill or what version of Tetris you play."))))
+
+ (extwiki^
+ '("kidicarus") 'default
+ 'NIWA
+ "Icaruspedia"
+ "https://www.kidicaruswiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/icaruspedia.png"
+ (λ (props)
+ `((p "Icaruspedia is the Kid Icarus wiki that keeps flying to new heights. After going independent on January 8, 2012, Icaruspedia has worked to become the largest and most trusted independent source of Kid Icarus information. Just like Pit, they'll keep on fighting until the job is done."))))
+
+ (extwiki^
+ '("splatoon" "uk-splatoon" "splatoon3" "splatoon2") 'default
+ 'NIWA
+ "Inkipedia"
+ "https://splatoonwiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/inkipedia.png"
+ (λ (props)
+ `((p "Inkipedia is your ever-growing go-to source for all things Splatoon related. Though founded on Wikia on June 10, 2014, Inkipedia went independent on May 18, 2015, just days before Splatoon's release. Our aim is to cover all aspects of the series, both high and low. Come splat with us now!"))))
+
+ (extwiki^
+ '("starfox") 'default
+ 'NIWA
+ "Lylat Wiki"
+ "https://starfoxwiki.info/wiki/Lylat_Wiki"
+ "https://niwanetwork.org/images/logos/lylatwiki.png"
+ (λ (props)
+ `((p "Out of seemingly nowhere, Lylat Wiki sprung up one day in early 2010. Led by creator, Justin Folvarcik, and project head, Tacopill, the wiki has reached stability since the move to its own domain. The staff of Lylat Wiki are glad to help out the NIWA wikis and are even prouder to join NIWA's ranks as the source for information on the Star Fox series."))))
+
+ (extwiki^
+ '("metroid" "themetroid") 'default
+ 'NIWA
+ "Metroid Wiki"
+ "https://www.metroidwiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/metroidwiki.png"
+ (λ (props)
+ `((p "Metroid Wiki, founded on January 27, 2010 by Nathanial Rumphol-Janc and Zelda Informer, is a rapidly expanding wiki that covers everything Metroid, from the games, to every suit, vehicle and weapon."))))
+
+ (extwiki^
+ '("nintendo" "nintendoseries" "nintendogames") 'default
+ 'NIWA
+ "Nintendo Wiki"
+ "http://niwanetwork.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/nintendowiki.png"
+ (λ (props)
+ `((p "Created on May 12, 2010, NintendoWiki (N-Wiki) is a collaborative project by the NIWA team to create an encyclopedia dedicated to Nintendo, being the company around which all other NIWA content is focused. It ranges from mainstream information such as the games and people who work for the company, to the most obscure info like patents and interesting trivia."))))
+
+ (extwiki^
+ '("animalcrossing" "animalcrossingcf" "acnh") 'default
+ 'NIWA
+ "Nookipedia"
+ "https://nookipedia.com/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/nookipedia.png"
+ (λ (props)
+ `((p "Founded in August 2005 on Wikia, Nookipedia was originally known as Animal Crossing City. Shortly after its five-year anniversary, Animal Crossing City decided to merge with the independent Animal Crossing Wiki, which in January 2011 was renamed to Nookipedia. Covering everything from the series including characters, items, critters, and much more, Nookipedia is your number one resource for everything Animal Crossing!"))))
+
+ (extwiki^
+ '("pikmin") 'default
+ 'NIWA
+ "Pikipedia"
+ "https://www.pikminwiki.com/"
+ "https://niwanetwork.org/images/logos/pikipedia.png"
+ (λ (props)
+ `((p "Pikipedia, also known as Pikmin Wiki, was founded by Dark Lord Revan on Wikia in December 2005. In September 2010, with NIWA's help, Pikipedia moved away from Wikia to become independent. Pikipedia is working towards their goal of being the foremost source for everything Pikmin."))))
+
+ (extwiki^
+ '("pikmin-fan" "pikpikpedia") 'default
+ 'NIWA
+ "Pimkin Fanon"
+ "https://www.pikminfanon.com/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/pikifanon.png"
+ (λ (props)
+ `((p "Pikmin Fanon is a Pikmin wiki for fan stories (fanon). Founded back on November 1, 2008 by Rocky0718 as a part of Wikia, Pikmin Fanon has been independent since September 14, 2010. Check them out for fan created stories based around the Pikmin series."))))
+
+ (extwiki^
+ '("supersmashbros") 'default
+ 'NIWA
+ "SmashWiki"
+ "https://www.ssbwiki.com/"
+ "https://niwanetwork.org/images/logos/smashwiki.png"
+ (λ (props)
+ `((p "Originally two separate wikis (one on SmashBoards, the other on Wikia), SmashWiki as we know it was formed out of a merge on February 29th, 2008, becoming independent on September 28th, 2010. SmashWiki is the premier source of Smash Bros. information, from simple tidbits to detailed mechanics, and also touches on the origins of its wealth of content from its sibling franchises."))))
+
+ (extwiki^
+ '("starfy") 'default
+ 'NIWA
+ "Starfy Wiki"
+ "https://www.starfywiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/starfywiki.png"
+ (λ (props)
+ `((p "Founded on May 30, 2009, Starfy Wiki's one goal is to become the best source on Nintendo's elusive game series The Legendary Starfy. After gaining independence in 2011 with the help of Tappy and the wiki's original administrative team, the wiki still hopes to achieve its goal and be the best source of Starfy info for all present and future fans."))))
+
+ (extwiki^
+ '() 'default
+ 'NIWA
+ "StrategyWiki"
+ "https://www.strategywiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/strategywiki.png"
+ (λ (props)
+ `((p "StrategyWiki was founded in December 2005 by former member Brandon Suit with the idea that the existing strategy guides on the Internet could be improved. Three years later, in December 2008, Scott Jacobi officially established Abxy LLC for the purpose of owning and operating StrategyWiki as a community. Their vision is to bring free, collaborative video game strategy guides to the masses, including Nintendo franchise strategy guides."))))
+
+ (extwiki^
+ '("mario" "themario" "imario" "supermarionintendo" "mariokart" "luigi-kart" "mario3") 'default
+ 'NIWA
+ "Super Mario Wiki"
+ "https://www.mariowiki.com/"
+ "https://niwanetwork.org/images/logos/mariowiki.png"
+ (λ (props)
+ `((p "Online since August 12, 2005, when it was founded by Steve Shinn, Super Mario Wiki has you covered for anything Mario, Donkey Kong, Wario, Luigi, Yoshi—the whole gang, in fact. With its own large community in its accompanying forum, Super Mario Wiki is not only a great encyclopedia, but a fansite for you to talk anything Mario."))))
+
+ (extwiki^
+ '("mario64") 'default
+ 'NIWA
+ "Ukikipedia"
+ "https://ukikipedia.net/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/ukikipedia.png"
+ (λ (props)
+ `((p "Founded in 2018, Ukikipedia is a wiki focused on expert level knowledge of Super Mario 64, including detailed coverage of game mechanics, glitches, speedrunning, and challenges."))))
+
+ (extwiki^
+ '("advancewars") 'default
+ 'NIWA
+ "Wars Wiki"
+ "https://www.warswiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/warswiki.png"
+ (λ (props)
+ `((p "Created in February 2009, Wars Wiki is a small wiki community with a large heart. Founded by JoJo and Wars Central, Wars Wiki is going strong on one of Nintendo's lesser known franchises. Wars Wiki is keen to contribute to NIWA, and we're proud to be able to support them. With the Wars Central community, including forums, it's definitely worth checking out."))))
+
+ (extwiki^
+ '("earthbound") 'default
+ 'NIWA
+ "WikiBound"
+ "https://www.wikibound.info/wiki/WikiBound"
+ "https://niwanetwork.org/images/logos/wikibound.png"
+ (λ (props)
+ `((p "Founded in early 2010 by Tacopill, WikiBound strives to create a detailed database on the Mother/EarthBound games, a quaint series only having two games officially released outside of Japan. Help spread the PK Love by editing WikiBound!"))))
+
+ (extwiki^
+ '("kirby") 'default
+ 'NIWA
+ "WiKirby"
+ "https://wikirby.com/wiki/Kirby_Wiki"
+ "https://niwanetwork.org/images/logos/wikirby.png"
+ (λ (props)
+ `((p "WiKirby. It's a wiki. About Kirby! Amidst the excitement of NIWA being founded, Josh LeJeune decided to create a Kirby Wiki, due to lack of a strong independent one online. Coming online on January 24, 2010, WiKirby continues its strong launch with a dedicated community and a daily growing source of Kirby based knowledge."))))
+
+ (extwiki^
+ '("xenoblade" "xenoseries" "xenogears" "xenosaga") 'parallel
+ 'NIWA
+ "Xeno Series Wiki"
+ "https://www.xenoserieswiki.org/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/xenoserieswiki.png"
+ (λ (props)
+ `((p "Xeno Series Wiki was created February 4, 2020 by Sir Teatei Moonlight. While founded by the desire to have an independent wiki for Xenoblade, there was an interest in including the Xenogears and Xenosaga games within its focus as well. This wide range of coverage means it's always in need of new editors to help bolster its many subjects."))))
+
+ (extwiki^
+ '("zelda" "zelda-archive") 'default
+ 'NIWA
+ "Zelda Wiki"
+ "https://zeldawiki.wiki/wiki/Main_Page"
+ "https://niwanetwork.org/images/logos/zeldapedia.png"
+ (λ (props)
+ `((p "Founded on April 23, 2005, Zelda Wiki is your definitive source for encyclopedic information on The Legend of Zelda series, as well as all of the latest Zelda news. Zelda Wiki went independent from Fandom in October 2022, citing Fandom's recent buyouts and staffing decisions among their reasons."))))
+
+ (extwiki^
+ '("chrono") 'default
+ 'SEIWA
+ "Chrono Wiki"
+ "https://www.chronowiki.org/wiki/Chrono_Wiki"
+ "https://cdn.wikimg.net/en/chronowiki/images/5/59/Site-wiki.png"
+ (λ (props) '((p "A free encyclopedia dedicated to Chrono Trigger, Chrono Cross, Radical Dreamers, and everything else related to the series. A long, rich history and a friendly, encouraging userbase makes this the best Chrono in the entire time/space continuum!"))))
+
+ (extwiki^
+ '("finalfantasy" "finalfantasyxv" "ffxiclopedia") 'parallel
+ 'SEIWA
+ "Final Fantasy Wiki"
+ "https://finalfantasywiki.com/wiki/Main_Page"
+ "https://cdn.finalfantasywiki.com/wiki.png"
+ (λ (props) '((p "A new wiki focused on covering Square Enix's flagship franchise, the critically-acclaimed Final Fantasy series. The Final Fantasy Wiki was founded on January 12, 2020 as part of SEIWA and covers all things Final Fantasy and related franchises."))))
+
+ (extwiki^
+ '("kingdomhearts") 'default
+ 'SEIWA
+ "Kingdom Hearts Wiki"
+ "https://www.khwiki.com/"
+ "https://kh.wiki.gallery/images/b/bc/Wiki.png"
+ (λ (props) '((p "The Kingdom Hearts Wiki attempts to document all things related to the Kingdom Hearts series, from elements of storyline to gameplay. The site was originally founded on April 1, 2006 on Wikia and became independent on February 9, 2011. Since this time, the community of the KHWiki strives to be the most professional and comprehensive Kingdom Hearts resource in the world."))))
+
+ (extwiki^
+ '("squareenix") 'default
+ 'SEIWA
+ "Square Enix Wiki"
+ "https://wiki.seiwanetwork.org/wiki/Main_Page"
+ "https://cdn.seiwanetwork.org/thumb/9/94/Square_Enix_Wiki_Logo.png/200px-Square_Enix_Wiki_Logo.png"
+ (λ (props) '((p "The Square Enix Wiki was founded on February 8, 2012, and is an up-and-coming wiki project created by SEIWA. It focuses on covering all things Square Enix, from its video game series to its physical publications to its most notable employees and work as a company."))))
+
+ (extwiki^
+ '("terraria") 'default
+ 'Terraria
+ "Official Terraria Wiki"
+ "https://terraria.wiki.gg/wiki/Terraria_Wiki"
+ "https://terraria.wiki.gg/images/5/5a/App_icon_1.3_Update.png"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("calamitymod" "calamity-mod") 'empty
+ 'Calamity_Mod
+ "Official Calamity Mod Wiki"
+ "https://calamitymod.wiki.gg/wiki/Calamity_Mod_Wiki"
+ #f
+ #f)
+
+ (extwiki^
+ '("ark" "ark-survival-evolved-archive") 'default
+ 'ARK
+ "ARK Community Wiki"
+ "https://ark.wiki.gg/wiki/ARK_Survival_Evolved_Wiki"
+ "https://ark.wiki.gg/images/e/e6/Site-logo.png"
+ (λ (props)
+ `((p "The official ARK: Survival Evolved Wiki launched in 2016. In April 2022 it moved to wiki.gg's hosting to improve creative control and the overall browsing experience."))))
+
+ (extwiki^
+ '("runescape") 'default
+ 'RuneScape
+ "RuneScape Wiki"
+ "https://runescape.wiki/w/Main_Page"
+ "https://runescape.wiki/images/Wiki.png"
+ (λ (props)
+ `((p "The RuneScape Wiki was founded on April 8, 2005. In October 2018, the wiki left Fandom (then Wikia), citing their apathy towards the wiki and excessive advertisements."))))
+
+ (extwiki^
+ '("oldschoolrunescape") 'default
+ 'RuneScape
+ "Old School RuneScape Wiki"
+ "https://oldschool.runescape.wiki/w/Main_Page"
+ "https://oldschool.runescape.wiki/images/Wiki.png"
+ (λ (props)
+ `((p "The Old School RuneScape Wiki was founded on February 14, 2013. In October 2018, the RuneScape Wiki left Fandom (then Wikia), citing their apathy towards the wiki and excessive advertisements, with the Old School RuneScape Wiki following suit."))))
+
+ (extwiki^
+ '("runescapeclassic") 'default
+ 'RuneScape
+ "RuneScape Classic Wiki"
+ "https://classic.runescape.wiki/w/Main_Page"
+ "https://classic.runescape.wiki/images/Wiki.png"
+ (λ (props)
+ `((p "The Old School RuneScape Wiki was founded on April 19, 2009. In October 2018, the RuneScape Wiki left Fandom (then Wikia), citing their apathy towards the wiki and excessive advertisements, with the RuneScape Classic Wiki following suit."))))
+
+ (extwiki^
+ '("astroneer") 'default
+ 'Astroneer
+ "Astroneer Wiki"
+ "https://astroneer.wiki.gg/wiki/Astroneer_Wiki"
+ "https://astroneer.wiki.gg/images/7/74/Icon_Astroneer.png"
+ (λ (props)
+ `((p "“Fandom bought Gamepedia and forced a migration, with their restricted, ad-heavy appearance, and other annoying features that we could not remove, the wiki grew slow and annoying to use, especially for logged out users.")
+ (p "“We decided to move away from Fandom to Wiki.gg, which returns the wiki to how it used to be on gamepedia, without the ads spamming and forced videos.”"))))
+
+ (extwiki^
+ '("minecraft") 'default
+ 'Minecraft
+ "The Minecraft Wiki"
+ "https://minecraft.wiki/w/Minecraft_Wiki"
+ "https://minecraft.wiki/images/Wiki.png"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("tardis") 'default
+ 'Tardis
+ "TARDIS Wiki"
+ "https://tardis.wiki/wiki/Doctor_Who_Wiki"
+ "https://tardis.wiki/w/images/Tardis_Images/e/e6/Site-logo.png"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("wizardry") 'default
+ 'GWN
+ "Wizardry Wiki"
+ "https://wizardry.wiki.gg/wiki/Wizardry_Wiki"
+ "https://wizardry.wiki.gg/images/e/e6/Site-logo.png"
+ (λ (props)
+ `((p "On March 21, 2023, the wiki has decided to leave and abandoning from Fandom due to numerous of issues such as intrusive advertising, long-lasting bugs, restrictions on customization, etcetera. Wizardry Wiki was officially inducted into the wiki.gg wikifarm, with all contents forked over.")
+ (p "The wiki has partnered with " (a (@ (href "https://fallout.wiki/")) "Independent Fallout Wiki") " as of June 14, 2024."))))
+
+ (extwiki^
+ '("jackryan") 'default
+ 'GWN
+ "Tom Clancy Wiki"
+ "https://tomclancy.wiki.gg/wiki/Tom_Clancy_Wiki"
+ "https://tomclancy.wiki.gg/images/thumb/c/c5/Jack_Ryan_Logo_Dark.png/600px-Jack_Ryan_Logo_Dark.png"
+ (λ (props)
+ `((p "The Tom Clancy Wiki is a collaborative encyclopedia dedicated to Tom Clancy’s franchises. The Tom Clancy franchise is a 40-year old expansive franchise founded by Tom Clancy, telling several unique sagas through books, video games, and films, as well as a TV show."))))
+
+ (extwiki^
+ '("hollowknight") 'default
+ 'GWN
+ "Hollow Knight Wiki"
+ "https://hollowknight.wiki/wiki/Main_Page"
+ "https://gamingwikinetwork.org/images/logos/hollowknight.png"
+ (λ (props)
+ `((p "We are an independently hosted wiki for the games Hollow Knight and Hollow Knight: Silksong, created by fans, for fans. The wiki is a fork of the FANDOM Hollow Knight Wiki and was officially unveiled on October 31, 2023."))))
+
+ (extwiki^
+ '("hellokitty" "sanrio") 'default
+ 'GWN
+ "Sanrio Wiki"
+ "https://sanriowiki.com/wiki/Sanrio_Wiki"
+ "https://cdn.sanriowiki.com/wiki.png"
+ (λ (props)
+ `((p "Sanrio Wiki is a project that was started on April 14, 2015 by EvieMelody. It was hosted on the wiki-farm ShoutWiki and has since become independent."))))
+
+ (extwiki^
+ '("sto") 'default
+ 'GWN
+ "Star Trek Online Wiki"
+ "https://stowiki.net/wiki/Main_Page"
+ "https://gamingwikinetwork.org/images/logos/stowiki.png"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("rayman-game" "ubisoftrayman") 'default
+ 'GWN
+ "Rayman Wiki"
+ "https://raymanpc.com/wiki/en/Main_Page"
+ "https://raymanpc.com/wiki/script-en/resources/assets/logo-en.png?5c608"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("granblue") 'empty
+ 'empty
+ "Granblue Fantasy Wiki"
+ "https://gbf.wiki/"
+ "https://gbf.wiki/images/1/18/Vyrnball.png?0704c"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("hellmet-roblox") 'empty
+ 'empty
+ "HELLMET Wiki"
+ "https://hellmet.miraheze.org/wiki/Main_Page"
+ "https://static.miraheze.org/hellmetwiki/thumb/c/ce/Hellmet_Wiki_Logo.png/135px-Hellmet_Wiki_Logo.png"
+ (λ (props)
+ `()))
+
+ (extwiki^
+ '("rain-web-comic") 'default
+ 'empty
+ "Rainverse Wiki"
+ "https://rainverse.wiki/wiki/Main_Page"
+ "https://static.miraheze.org/rainversewiki/2/2c/Rain_comic_cover.png"
+ (λ (props)
+ `((p "We have a newly-migrated Rainverse Wiki which escaped from Fandom! Rain is the comic that helped me figure out my gender, so I am really glad to have a wiki on a non-evil host.")
+ (p "Please stop using the abandoned copy of Rain Wiki on Fandom. Fandom is still \"training\" a generator which adds procedurally-generated bullshit to articles, with no way for users to remove or correct it, and they're demanding volunteer wiki admins waste time \"vetting\" the procedurally-generated BS for accuracy. As Jocelyn herself said, \"fuck Fandom forever.\"")
+ (p "If you are interested, please add more articles related to other Rainverse stories."))))
+
+ ;; fandom wikinames * empty * empty * Name * Home Page
+ (extwiki^ '("aether") 'empty 'empty "Aether Wiki" "https://aether.wiki.gg/wiki/Aether_Wiki" #f #f)
+ (extwiki^ '("before-darkness-falls") 'empty 'empty "Before Darkness Falls Wiki" "https://beforedarknessfalls.wiki.gg/wiki/Before_Darkness_Falls_Wiki" #f #f)
+ (extwiki^ '("chivalry" "chivalry2") 'empty 'empty "Official Chivalry Wiki" "https://chivalry.wiki.gg/wiki/Chivalry_Wiki" #f #f)
+ (extwiki^ '("clockup") 'empty 'empty "CLOCKUP WIKI" "https://en.clockup.wiki/wiki/Main_Page" #f #f)
+ (extwiki^ '("half-life") 'empty 'empty "Combine OverWiki" "https://combineoverwiki.net/wiki/Main_Page" #f #f)
+ (extwiki^ '("coromon") 'empty 'empty "Coromon Wiki" "https://coromon.wiki.gg/wiki/Coromon_Wiki" #f #f)
+ (extwiki^ '("cosmoteer") 'empty 'empty "Cosmoteer Wiki" "https://cosmoteer.wiki.gg/wiki/Cosmoteer_Wiki" #f #f)
+ (extwiki^ '("criticalrole") 'empty 'empty "Encylopedia Exandria" "https://criticalrole.miraheze.org/wiki/Main_Page" #f #f)
+ (extwiki^ '("cuphead") 'empty 'empty "Cuphead Wiki" "https://cuphead.wiki.gg/wiki/Cuphead_Wiki" #f #f)
+ (extwiki^ '("darkdeity") 'empty 'empty "Dark Deity Wiki" "https://darkdeity.wiki.gg/wiki/Dark_Deity_Wiki" #f #f)
+ (extwiki^ '("deeprockgalactic") 'empty 'empty "Deep Rock Galactic Wiki" "https://deeprockgalactic.wiki.gg/wiki/Deep_Rock_Galactic_Wiki" #f #f)
+ (extwiki^ '("doom") 'empty 'empty "DoomWiki.org" "https://doomwiki.org/wiki/Entryway" #f #f)
+ (extwiki^ '("dreamscaper") 'empty 'empty "Official Dreamscaper Wiki" "https://dreamscaper.wiki.gg/wiki/Dreamscaper_Wiki" #f #f)
+ (extwiki^ '("elderscrolls") 'empty 'empty "UESP" "https://en.uesp.net/wiki/Main_Page" #f #f)
+ (extwiki^ '("enterthegungeon" "exit-the-gungeon" "enter-the-gungeon-archive") 'empty 'empty "Official Enter The Gungeon Wiki" "https://enterthegungeon.wiki.gg/wiki/Enter_the_Gungeon_Wiki" "https://enterthegungeon.wiki.gg/images/e/e6/Site-logo.png" #f)
+ (extwiki^ '("fiend-folio") 'empty 'empty "Official Fiend Folio Wiki" "https://fiendfolio.wiki.gg/wiki/Fiend_Folio_Wiki" #f #f)
+ (extwiki^ '("foxhole") 'empty 'empty "Foxhole Wiki" "https://foxhole.wiki.gg/wiki/Foxhole_Wiki" #f #f)
+ (extwiki^ '("have-a-nice-death") 'empty 'empty "Have a Nice Death Wiki" "https://haveanicedeath.wiki.gg/wiki/Have_a_Nice_Death_Wiki" #f #f)
+ (extwiki^ '("jojo" "jojos") 'empty 'empty "JoJo's Bizarre Encyclopedia" "https://jojowiki.com/" #f #f)
+ (extwiki^ '("legiontd2") 'empty 'empty "Legion TD 2 Wiki" "https://legiontd2.wiki.gg/wiki/Legion_TD_2_Wiki" #f #f)
+ (extwiki^ '("noita") 'empty 'empty "Noita Wiki" "https://noita.wiki.gg/wiki/Noita_Wiki" #f #f)
+ (extwiki^ '("pathofexile") 'empty 'empty "Official Path of Exile Wiki" "https://www.poewiki.net/wiki/Path_of_Exile_Wiki" #f #f)
+ (extwiki^ '("projectarrhythmia") 'empty 'empty "Project Arrhythmia Wiki" "https://projectarrhythmia.wiki.gg/wiki/Project_Arrhythmia_Wiki" #f #f)
+ (extwiki^ '("sandsofaura") 'empty 'empty "Official Sands of Aura Wiki" "https://sandsofaura.wiki.gg/wiki/Sands_of_Aura_Wiki" #f #f)
+ (extwiki^ '("seaofthieves") 'empty 'empty "Official Sea of Thieves Wiki" "https://seaofthieves.wiki.gg/wiki/Sea_of_Thieves" #f #f)
+ (extwiki^ '("sonsoftheforest") 'empty 'empty "Sons of the Forest Wiki" "https://sonsoftheforest.wiki.gg/wiki/Sons_of_the_Forest_Wiki" #f #f)
+ (extwiki^ '("stardewvalley") 'empty 'empty "Official Stardew Valley Wiki" "https://www.stardewvalleywiki.com/Stardew_Valley_Wiki" #f #f)
+ (extwiki^ '("steamworld") 'empty 'empty "Official SteamWorld Wiki" "https://steamworld.wiki.gg/wiki/SteamWorld_Wiki" #f #f)
+ (extwiki^ '("teamfortress") 'empty 'empty "Official Team Fortress Wiki" "https://wiki.teamfortress.com/wiki/Main_Page" #f #f)
+ (extwiki^ '("temtem") 'empty 'empty "Official Temtem Wiki" "https://temtem.wiki.gg/wiki/Temtem_Wiki" #f #f)
+ (extwiki^ '("terrariamods") 'empty 'empty "Official Terraria Mods Wiki" "https://terrariamods.wiki.gg/wiki/Terraria_Mods_Wiki" #f #f)
+ (extwiki^ '("thoriummod") 'empty 'empty "Official Thorium Mod Wiki" "https://thoriummod.wiki.gg/wiki/Thorium_Mod_Wiki" #f #f)
+ (extwiki^ '("totherescue") 'empty 'empty "To The Rescue!" "https://totherescue.wiki.gg/wiki/To_The_Rescue%21_Wiki" #f #f)
+ (extwiki^ '("touhou") 'empty 'empty "Touhou Wiki" "https://en.touhouwiki.net/wiki/Touhou_Wiki" #f #f)
+ (extwiki^ '("undermine") 'empty 'empty "Official UnderMine Wiki" "https://undermine.wiki.gg/wiki/UnderMine_Wiki" #f #f)
+ (extwiki^ '("westofloathing" "loathing") 'empty 'empty "Wiki of Loathing" "https://loathing.wiki.gg/wiki/Wiki_of_Loathing" #f #f)
+ (extwiki^ '("willyousnail") 'empty 'empty "Official Will You Snail Wiki" "https://willyousnail.wiki.gg/wiki/Will_You_Snail_Wiki" #f #f)
+ (extwiki^ '("yumenikki" "yume-nikki-dream-diary") 'empty 'empty "Yume Wiki" "https://yume.wiki/Main_Page" #f #f)))
+
+;; get the current dataset so it can be stored above
+(module+ fetch
+ (require racket/generator
+ racket/list
+ net/http-easy
+ html-parsing
+ "../lib/xexpr-utils.rkt")
+ (define r (get "https://www.niwanetwork.org/members/"))
+ (define x (html->xexp (bytes->string/utf-8 (response-body r))))
+ (define english ((query-selector (λ (e a c) (equal? (get-attribute 'id a) "content1")) x)))
+ (define gen (query-selector (λ (e a c) (has-class? "member" a)) english))
+ (for/list ([item (in-producer gen #f)])
+ (define links (query-selector (λ (e a c) (eq? e 'a)) item))
+ (define url (get-attribute 'href (bits->attributes (links))))
+ (define title (third (links)))
+ (define icon (get-attribute 'src (bits->attributes ((query-selector (λ (e a c) (eq? e 'img)) item)))))
+ (define description (second ((query-selector (λ (e a c) (eq? e 'p)) item))))
+ (list '() title url icon description)))
diff --git a/src/extwiki-generic.rkt b/src/extwiki-generic.rkt
new file mode 100644
index 0000000..113b139
--- /dev/null
+++ b/src/extwiki-generic.rkt
@@ -0,0 +1,130 @@
+#lang racket/base
+(require racket/list
+ racket/match
+ racket/string
+ memo
+ net/http-easy
+ html-parsing
+ "../lib/pure-utils.rkt"
+ "../lib/syntax.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
+
+(provide
+ get-redirect-content)
+
+(module+ test
+ (require rackunit))
+
+;; fandom wikinames * Title * Main Page * Search page override * API endpoint override
+(define wikis
+ '(((gallowmere) "MediEvil Wiki" "https://medievil.wiki/w/Main_Page" #f #f)
+ ((fallout) "Fallout Wiki" "https://fallout.wiki/wiki/Fallout_Wiki" #f "https://fallout.wiki/api.php")
+ ((drawntolife) "Wapopedia" "https://drawntolife.wiki/en/Main_Page" #f "https://drawntolife.wiki/w/api.php")
+ ))
+
+(define wikis-hash (make-hash))
+(for ([w wikis])
+ (for ([wikiname (car w)])
+ (hash-set! wikis-hash (symbol->string wikiname) w)))
+(module+ test
+ (check-equal? (cadr (hash-ref wikis-hash "gallowmere"))
+ "MediEvil Wiki"))
+
+(define (parse-table table)
+ (define rows (query-selector (λ (t a c) (eq? t 'tr)) table))
+ (define header-row (rows))
+ (define column-names
+ (for/list ([th (in-producer (query-selector (λ (t a c) (eq? t 'th)) header-row) #f)])
+ (string->symbol (string-downcase (string-trim (findf string? th))))))
+ (define data-row (rows))
+ (for/hasheq ([col-name column-names]
+ [col-value (in-producer (query-selector (λ (t a c) (eq? t 'td)) data-row) #f)])
+ (values col-name (filter element-is-content? (cdr col-value)))))
+(module+ test
+ (check-equal? (parse-table (html->xexp ""))
+ '#hasheq((links . ((a (@ (target "_blank") (rel "nofollow noreferrer noopener") (class "external text") (href "https://sirdanielfortesque.proboards.com/")) "Forum"))))))
+
+(define (table->links table)
+ (define v (hash-ref table 'links #f))
+ (cond/var
+ [(not v) (values null '("Data table must have a \"Links\" column"))]
+ (var links (filter (λ (a) (and (pair? a) (eq? (car a) 'a))) v)) ; elements
+ [(null? links) (values null '("Links column must have at least one link"))]
+ [#t (values links null)]))
+
+(define (table->logo table)
+ (define logo (hash-ref table 'logo #f))
+ (cond/var
+ [(not logo) (values #f '("Data table must have a \"Logo\" column"))]
+ [(null? logo) (values #f '("Logo table column must have a link"))]
+ (var href (get-attribute 'href (bits->attributes (car (hash-ref table 'logo)))))
+ (var src (get-attribute 'src (bits->attributes (car (hash-ref table 'logo)))))
+ (var true-src (or href src))
+ [(not true-src) (values #f '("Logo table column must have a link"))]
+ [#t (values true-src null)]))
+
+(define (get-api-endpoint wiki)
+ (define main-page (third wiki))
+ (define override (fifth wiki))
+ (or override
+ (match main-page
+ [(regexp #rx"/$") (string-append main-page "api.php")]
+ [(regexp #rx"^(.*)/wiki/" (list _ domain)) (string-append domain "/w/api.php")]
+ [(regexp #rx"^(.*)/w/" (list _ domain)) (string-append domain "/api.php")]
+ [_ (error 'get-api-endpoint "unknown url format: ~a" main-page)])))
+
+(define (get-search-page wiki)
+ (define main-page (third wiki))
+ (define override (fourth wiki))
+ (or override
+ (match main-page
+ [(regexp #rx"/$") (string-append main-page "Special:Search")]
+ [(regexp #rx"^(.*/(?:en|w[^./]*)/)" (list _ wiki-prefix)) (string-append wiki-prefix "Special:Search")]
+ [_ (error 'get-search-page "unknown url format: ~a" main-page)])))
+
+(define/memoize (get-redirect-content wikiname) #:hash hash
+ (define wiki (hash-ref wikis-hash wikiname #f))
+ (cond
+ [wiki
+ (define display-name (cadr wiki))
+ (define endpoint (string-append (get-api-endpoint wiki) "?action=parse&page=MediaWiki:BreezeWikiRedirect&prop=text&formatversion=2&format=json"))
+ (define res (get endpoint))
+ (define html (jp "/parse/text" (response-json res)))
+ (define content ((query-selector (λ (t a c) (has-class? "mw-parser-output" a))
+ (html->xexp html))))
+ (define body (for/list ([p (in-producer (query-selector (λ (t a c) (eq? t 'p)) content) #f)]) p))
+ (define table (parse-table ((query-selector (λ (t a c) (eq? t 'table)) content))))
+ (define-values (links links-errors) (table->links table))
+ (define-values (logo logo-errors) (table->logo table))
+ (define construct-errors (append links-errors logo-errors))
+ (λ (title)
+ (define go
+ (string-append (get-search-page wiki)
+ "?"
+ (params->query `(("search" . ,title)
+ ("go" . "Go")))))
+ `(aside (@ (class "niwa__notice"))
+ (h1 (@ (class "niwa__header")) ,display-name " has its own website separate from Fandom.")
+ (div (@ (class "niwa__cols"))
+ (div (@ (class "niwa__left"))
+ (a (@ (class "niwa__go") (href ,go)) "Read " ,title " on " ,display-name " →")
+ ,@body
+ (p "This external wiki is a helpful alternative to Fandom. You should "
+ (a (@ (href ,go)) "check it out now!")))
+ ,(if logo
+ `(div (@ (class "niwa__right"))
+ (img (@ (class "niwa__logo") (src ,logo))))
+ ""))
+ ,(if (pair? links)
+ `(p (@ (class "niwa__feedback"))
+ ,@(add-between links " / "))
+ "")
+ ,(if (pair? construct-errors)
+ `(ul
+ ,@(for/list ([error construct-errors])
+ `(li ,error)))
+ "")))]
+ [#t #f]))
+(module+ test
+ (check-not-false ((get-redirect-content "gallowmere") "MediEvil Wiki")))
diff --git a/src/fandom-request.rkt b/src/fandom-request.rkt
new file mode 100644
index 0000000..c306b04
--- /dev/null
+++ b/src/fandom-request.rkt
@@ -0,0 +1,74 @@
+#lang typed/racket/base
+(require racket/format
+ racket/string
+ "config.rkt"
+ "../lib/url-utils.rkt")
+(define-type Headers (HashTable Symbol (U Bytes String)))
+(require/typed net/http-easy
+ [#:opaque Timeout-Config timeout-config?]
+ [#:opaque Response response?]
+ [#:opaque Session session?]
+ [response-status-code (Response -> Natural)]
+ [current-session (Parameter Session)]
+ [current-user-agent (Parameter (U Bytes String))]
+ [make-timeout-config ([#:lease Positive-Real] [#:connect Positive-Real] -> Timeout-Config)]
+ [get ((U Bytes String)
+ [#:close? Boolean]
+ [#:headers Headers]
+ [#:timeouts Timeout-Config]
+ [#:max-attempts Exact-Positive-Integer]
+ [#:max-redirects Exact-Nonnegative-Integer]
+ [#:user-agent (U Bytes String)]
+ -> Response)])
+
+(provide
+ fandom-get
+ fandom-get-api
+ timeouts)
+
+(unless (string-contains? (~a (current-user-agent)) "BreezeWiki")
+ (current-user-agent
+ (format "BreezeWiki/1.0 (~a) ~a"
+ (if (config-true? 'canonical_origin)
+ (config-get 'canonical_origin)
+ "local")
+ (current-user-agent))))
+
+(define timeouts (make-timeout-config #:lease 5 #:connect 5))
+
+(: last-failure Flonum)
+(define last-failure 0.0)
+(: stored-failure (Option Response))
+(define stored-failure #f)
+(define failure-persist-time 30000)
+
+(: no-headers Headers)
+(define no-headers '#hasheq())
+
+(: fandom-get (String String [#:headers (Option Headers)] -> Response))
+(define (fandom-get wikiname path #:headers [headers #f])
+ (or
+ (and ((current-inexact-milliseconds) . < . (+ last-failure failure-persist-time)) stored-failure)
+ (let ()
+ (define dest-url (string-append "https://www.fandom.com" path))
+ (define host (string-append wikiname ".fandom.com"))
+ (log-outgoing wikiname path)
+ (define res
+ (get dest-url
+ #:timeouts timeouts
+ #:headers (hash-set (or headers no-headers) 'Host host)))
+ (when (memq (response-status-code res) '(403 406))
+ (set! last-failure (current-inexact-milliseconds))
+ (set! stored-failure res))
+ res)))
+
+(: fandom-get-api (String (Listof (Pair String String)) [#:headers (Option Headers)] -> Response))
+(define (fandom-get-api wikiname params #:headers [headers #f])
+ (fandom-get wikiname
+ (string-append "/api.php?" (params->query params))
+ #:headers headers))
+
+(: log-outgoing (String String -> Void))
+(define (log-outgoing wikiname path)
+ (when (config-true? 'log_outgoing)
+ (printf "out: ~a ~a~n" wikiname path)))
diff --git a/src/log.rkt b/src/log.rkt
new file mode 100644
index 0000000..047c8aa
--- /dev/null
+++ b/src/log.rkt
@@ -0,0 +1,63 @@
+#lang typed/racket/base
+(require racket/file
+ racket/path
+ racket/port
+ racket/string
+ typed/srfi/19
+ "config.rkt")
+
+(provide
+ log-page-request
+ log-styles-request
+ log-set-settings-request)
+
+(define last-flush 0)
+(define flush-every-millis 60000)
+
+;; anytime-path macro expansion only works in an untyped submodule for reasons I cannot comprehend
+(module define-log-dir racket/base
+ (require racket/path
+ "../lib/syntax.rkt")
+ (provide log-dir)
+ (define log-dir (anytime-path ".." "storage/logs")))
+(require/typed (submod "." define-log-dir)
+ [log-dir Path])
+
+(define log-file (build-path log-dir "access-0.log"))
+(define log-port
+ (if (config-true? 'access_log::enabled)
+ (begin
+ (make-directory* log-dir)
+ (open-output-file log-file #:exists 'append))
+ (open-output-nowhere)))
+
+(: get-date-iso8601 (-> String))
+(define (get-date-iso8601)
+ (date->string (current-date 0) "~5"))
+
+(: offline-string (Boolean -> String))
+(define (offline-string offline?)
+ (if offline? "---" "ooo"))
+
+(: log (String * -> Void))
+(define (log . entry)
+ ;; create log entry string
+ (define full-entry (cons (get-date-iso8601) entry))
+ ;; write to output port
+ (displayln (string-join full-entry ";") log-port)
+ ;; flush output port to file (don't do this too frequently)
+ (when ((- (current-milliseconds) last-flush) . >= . flush-every-millis)
+ (flush-output log-port)
+ (set! last-flush (current-milliseconds))))
+
+(: log-page-request (Boolean String String (U 'light 'dark 'default) -> Void))
+(define (log-page-request offline? wikiname title theme)
+ (log "page" (offline-string offline?) wikiname title (symbol->string theme)))
+
+(: log-styles-request (Boolean String String -> Void))
+(define (log-styles-request offline? wikiname basename)
+ (log "style" (offline-string offline?) wikiname basename))
+
+(: log-set-settings-request (Symbol -> Void))
+(define (log-set-settings-request theme)
+ (log "settings" (symbol->string theme)))
diff --git a/src/niwa-data.rkt b/src/niwa-data.rkt
deleted file mode 100644
index a1036af..0000000
--- a/src/niwa-data.rkt
+++ /dev/null
@@ -1,156 +0,0 @@
-#lang racket/base
-
-(provide
- niwa-data)
-
-;; wikiname, niwa-name, url, logo-url
-(define niwa-data
- '((("arms" "armsgame")
- "ARMS Institute"
- "https://armswiki.org/wiki/Home"
- "/images/logos/armswiki.png"
- "ARMS Institute is a comprehensive resource for information about the Nintendo Switch game, ARMS. Founded on May 1, 2017 and growing rapidly, the wiki strives to offer in-depth coverage of ARMS from both a competitive and casual perspective. Join us and ARM yourself with knowledge!")
- (("pokemon" "monster")
- "Bulbapedia"
- "https://bulbapedia.bulbagarden.net/wiki/Main_Page"
- "/images/logos/bulbapedia.png"
- "A part of the Bulbagarden community, Bulbapedia was founded on December 21, 2004 by Liam Pomfret. Everything you need to know about Pokémon can be found at Bulbapedia, whether about the games, the anime, the manga, or something else entirely. With its Bulbanews section and the Bulbagarden forums, it's your one-stop online place for Pokémon.")
- (("dragalialost")
- "Dragalia Lost Wiki"
- "https://dragalialost.wiki/w/Dragalia_Lost_Wiki"
- "/images/logos/dragalialost.png"
- "The Dragalia Lost Wiki was originally founded in September 2018 on the Gamepedia platform but went independent in January 2021. The Wiki aims to document anything and everything Dragalia Lost, from in-game data to mechanics, story, guides, and more!")
- (("dragonquest")
- "Dragon Quest Wiki"
- "https://dragon-quest.org/wiki/Main_Page"
- "/images/logos/dragonquestwiki.png"
- "Originally founded on Wikia, the Dragon Quest Wiki was largely inactive until FlyingRagnar became an admin in late 2009. The wiki went independent about a year later when it merged with the Dragon Quest Dictionary/Encyclopedia which was run by Zenithian and supported by the Dragon's Den. The Dragon Quest Wiki aims to be the most complete resource for Dragon Quest information on the web. It continues to grow in the hope that one day the series will be as popular in the rest of the world as it is in Japan.")
- (("fireemblem")
- "Fire Emblem Wiki"
- "https://fireemblemwiki.org/wiki/Main_Page"
- "/images/logos/fireemblemwiki.png"
- "Growing since August 26, 2010, Fire Emblem Wiki is a project whose goal is to cover all information pertaining to the Fire Emblem series. It aspires to become the most complete and accurate independent source of information on this series.")
- (("fzero" "f-zero")
- "F-Zero Wiki"
- "https://mutecity.org/wiki/F-Zero_Wiki"
- "/images/logos/fzerowiki.png"
- "Founded on Wikia in November 2007, F-Zero Wiki became independent with NIWA's help in 2011. F-Zero Wiki is quickly growing into the Internet's definitive source for the world of 2200 km/h+, from pilots to machines, and is the founding part of MuteCity.org, the web's first major F-Zero community.")
- (("goldensun")
- "Golden Sun Universe"
- "https://www.goldensunwiki.net/wiki/Main_Page"
- "/images/logos/goldensununiverse.png"
- "Originally founded on Wikia in late 2006, Golden Sun Universe has always worked hard to meet one particular goal: to be the single most comprehensive yet accessible resource on the Internet for Nintendo's RPG series Golden Sun. It became an independent wiki four years later. Covering characters and plot, documenting all aspects of the gameplay, featuring walkthroughs both thorough and bare-bones, and packed with all manner of odd and fascinating minutiae, Golden Sun Universe leaves no stone unturned!")
- (("tetris")
- "Hard Drop - Tetris Wiki"
- "https://harddrop.com/wiki/Main_Page"
- "/images/logos/harddrop.png"
- "The Tetris Wiki was founded by Tetris fans for Tetris fans on tetrisconcept.com in March 2006. The Tetris Wiki torch was passed to harddrop.com in July 2009. Hard Drop is a Tetris community for all Tetris players, regardless of skill or what version of Tetris you play.")
- (("kidicarus")
- "Icaruspedia"
- "https://www.kidicaruswiki.org/wiki/Main_Page"
- "/images/logos/icaruspedia.png"
- "Icaruspedia is the Kid Icarus wiki that keeps flying to new heights. After going independent on January 8, 2012, Icaruspedia has worked to become the largest and most trusted independent source of Kid Icarus information. Just like Pit, they'll keep on fighting until the job is done.")
- (("splatoon" "uk-splatoon" "splatoon3" "splatoon2")
- "Inkipedia"
- "https://splatoonwiki.org/wiki/Main_Page"
- "/images/logos/inkipedia.png"
- "Inkipedia is your ever-growing go-to source for all things Splatoon related. Though founded on Wikia on June 10, 2014, Inkipedia went independent on May 18, 2015, just days before Splatoon's release. Our aim is to cover all aspects of the series, both high and low. Come splat with us now!")
- (("starfox")
- "Lylat Wiki"
- "https://starfoxwiki.info/wiki/Lylat_Wiki"
- "/images/logos/lylatwiki.png"
- "Out of seemingly nowhere, Lylat Wiki sprung up one day in early 2010. Led by creator, Justin Folvarcik, and project head, Tacopill, the wiki has reached stability since the move to its own domain. The staff of Lylat Wiki are glad to help out the NIWA wikis and are even prouder to join NIWA's ranks as the source for information on the Star Fox series.")
- (("metroid" "themetroid")
- "Metroid Wiki"
- "https://www.metroidwiki.org/wiki/Main_Page"
- "/images/logos/metroidwiki.png"
- "Metroid Wiki, founded on January 27, 2010 by Nathanial Rumphol-Janc and Zelda Informer, is a rapidly expanding wiki that covers everything Metroid, from the games, to every suit, vehicle and weapon.")
- (("nintendo" "nintendoseries" "nintendogames")
- "Nintendo Wiki"
- "http://niwanetwork.org/wiki/Main_Page"
- "/images/logos/nintendowiki.png"
- "Created on May 12, 2010, NintendoWiki (N-Wiki) is a collaborative project by the NIWA team to create an encyclopedia dedicated to Nintendo, being the company around which all other NIWA content is focused. It ranges from mainstream information such as the games and people who work for the company, to the most obscure info like patents and interesting trivia.")
- (("animalcrossing" "animalcrossingcf" "acnh")
- "Nookipedia"
- "https://nookipedia.com/wiki/Main_Page"
- "/images/logos/nookipedia.png"
- "Founded in August 2005 on Wikia, Nookipedia was originally known as Animal Crossing City. Shortly after its five-year anniversary, Animal Crossing City decided to merge with the independent Animal Crossing Wiki, which in January 2011 was renamed to Nookipedia. Covering everything from the series including characters, items, critters, and much more, Nookipedia is your number one resource for everything Animal Crossing!")
- (("pikmin")
- "Pikipedia"
- "https://www.pikminwiki.com/"
- "/images/logos/pikipedia.png"
- "Pikipedia, also known as Pikmin Wiki, was founded by Dark Lord Revan on Wikia in December 2005. In September 2010, with NIWA's help, Pikipedia moved away from Wikia to become independent. Pikipedia is working towards their goal of being the foremost source for everything Pikmin.")
- (("pikmin-fan" "pikpikpedia")
- "Pimkin Fanon"
- "https://www.pikminfanon.com/wiki/Main_Page"
- "/images/logos/pikifanon.png"
- "Pikmin Fanon is a Pikmin wiki for fan stories (fanon). Founded back on November 1, 2008 by Rocky0718 as a part of Wikia, Pikmin Fanon has been independent since September 14, 2010. Check them out for fan created stories based around the Pikmin series.")
- (("supersmashbros")
- "SmashWiki"
- "https://www.ssbwiki.com/"
- "/images/logos/smashwiki.png"
- "Originally two separate wikis (one on SmashBoards, the other on Wikia), SmashWiki as we know it was formed out of a merge on February 29th, 2008, becoming independent on September 28th, 2010. SmashWiki is the premier source of Smash Bros. information, from simple tidbits to detailed mechanics, and also touches on the origins of its wealth of content from its sibling franchises.")
- (("starfy")
- "Starfy Wiki"
- "https://www.starfywiki.org/wiki/Main_Page"
- "/images/logos/starfywiki.png"
- "Founded on May 30, 2009, Starfy Wiki's one goal is to become the best source on Nintendo's elusive game series The Legendary Starfy. After gaining independence in 2011 with the help of Tappy and the wiki's original administrative team, the wiki still hopes to achieve its goal and be the best source of Starfy info for all present and future fans.")
- (()
- "StrategyWiki"
- "https://www.strategywiki.org/wiki/Main_Page"
- "/images/logos/strategywiki.png"
- "StrategyWiki was founded in December 2005 by former member Brandon Suit with the idea that the existing strategy guides on the Internet could be improved. Three years later, in December 2008, Scott Jacobi officially established Abxy LLC for the purpose of owning and operating StrategyWiki as a community. Their vision is to bring free, collaborative video game strategy guides to the masses, including Nintendo franchise strategy guides.")
- (("mario" "themario" "imario" "supermarionintendo" "mariokart" "luigi-kart" "mario3")
- "Super Mario Wiki"
- "https://www.mariowiki.com/"
- "/images/logos/mariowiki.png"
- "Online since August 12, 2005, when it was founded by Steve Shinn, Super Mario Wiki has you covered for anything Mario, Donkey Kong, Wario, Luigi, Yoshi—the whole gang, in fact. With its own large community in its accompanying forum, Super Mario Wiki is not only a great encyclopedia, but a fansite for you to talk anything Mario.")
- (("mario64")
- "Ukikipedia"
- "https://ukikipedia.net/wiki/Main_Page"
- "/images/logos/ukikipedia.png"
- "Founded in 2018, Ukikipedia is a wiki focused on expert level knowledge of Super Mario 64, including detailed coverage of game mechanics, glitches, speedrunning, and challenges.")
- (("advancewars")
- "Wars Wiki"
- "https://www.warswiki.org/wiki/Main_Page"
- "/images/logos/warswiki.png"
- "Created in February 2009, Wars Wiki is a small wiki community with a large heart. Founded by JoJo and Wars Central, Wars Wiki is going strong on one of Nintendo's lesser known franchises. Wars Wiki is keen to contribute to NIWA, and we're proud to be able to support them. With the Wars Central community, including forums, it's definitely worth checking out.")
- (("earthbound")
- "WikiBound"
- "https://www.wikibound.info/wiki/WikiBound"
- "/images/logos/wikibound.png"
- "Founded in early 2010 by Tacopill, WikiBound strives to create a detailed database on the Mother/EarthBound games, a quaint series only having two games officially released outside of Japan. Help spread the PK Love by editing WikiBound!")
- (("kirby")
- "WiKirby"
- "https://wikirby.com/wiki/Kirby_Wiki"
- "/images/logos/wikirby.png"
- "WiKirby. It's a wiki. About Kirby! Amidst the excitement of NIWA being founded, Josh LeJeune decided to create a Kirby Wiki, due to lack of a strong independent one online. Coming online on January 24, 2010, WiKirby continues its strong launch with a dedicated community and a daily growing source of Kirby based knowledge.")
- (("xenoblade" "xenoseries" "xenogears" "xenosaga")
- "Xeno Series Wiki"
- "https://www.xenoserieswiki.org/wiki/Main_Page"
- "/images/logos/xenoserieswiki.png"
- "Xeno Series Wiki was created February 4, 2020 by Sir Teatei Moonlight. While founded by the desire to have an independent wiki for Xenoblade, there was an interest in including the Xenogears and Xenosaga games within its focus as well. This wide range of coverage means it's always in need of new editors to help bolster its many subjects.")
- (("zelda" "zelda-archive")
- "Zeldapedia"
- "https://zeldapedia.wiki/wiki/Main_Page"
- "/images/logos/zeldapedia.png"
- "Founded on April 23, 2005 as Zelda Wiki, today's Zeldapedia is your definitive source for encyclopedic information on The Legend of Zelda series, as well as all of the latest Zelda news.")))
-
-;; get the current dataset so it can be stored above
-(module+ fetch
- (require racket/generator
- racket/list
- net/http-easy
- html-parsing
- "xexpr-utils.rkt")
- (define r (get "https://www.niwanetwork.org/members/"))
- (define x (html->xexp (bytes->string/utf-8 (response-body r))))
- (define english ((query-selector (λ (e a c) (equal? (get-attribute 'id a) "content1")) x)))
- (define gen (query-selector (λ (e a c) (has-class? "member" a)) english))
- (for/list ([item (in-producer gen #f)])
- (define links (query-selector (λ (e a c) (eq? e 'a)) item))
- (define url (get-attribute 'href (bits->attributes (links))))
- (define title (third (links)))
- (define icon (get-attribute 'src (bits->attributes ((query-selector (λ (e a c) (eq? e 'img)) item)))))
- (define description (second ((query-selector (λ (e a c) (eq? e 'p)) item))))
- (list '() title url icon description)))
diff --git a/src/page-category.rkt b/src/page-category.rkt
index 89bc45a..e1fe659 100644
--- a/src/page-category.rkt
+++ b/src/page-category.rkt
@@ -15,20 +15,24 @@
"application-globals.rkt"
"config.rkt"
"data.rkt"
+ "fandom-request.rkt"
"page-wiki.rkt"
- "syntax.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/syntax.rkt"
+ "../lib/thread-utils.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
page-category)
(module+ test
- (require rackunit)
+ (require rackunit
+ "test-utils.rkt")
(define category-json-data
'#hasheq((batchcomplete . #t) (continue . #hasheq((cmcontinue . "page|4150504c45|41473") (continue . "-||"))) (query . #hasheq((categorymembers . (#hasheq((ns . 0) (pageid . 25049) (title . "Item (entity)")) #hasheq((ns . 0) (pageid . 128911) (title . "3D")) #hasheq((ns . 0) (pageid . 124018) (title . "A Very Fine Item")) #hasheq((ns . 0) (pageid . 142208) (title . "Amethyst Shard")) #hasheq((ns . 0) (pageid . 121612) (title . "Ankle Monitor")))))))))
(define (generate-results-page
+ #:req req
#:source-url source-url
#:wikiname wikiname
#:title title
@@ -38,6 +42,7 @@
#:siteinfo [siteinfo #f])
(define members (jp "/query/categorymembers" members-data))
(generate-wiki-page
+ #:req req
#:source-url source-url
#:wikiname wikiname
#:title title
@@ -52,7 +57,7 @@
,@(map
(λ (result)
(define title (jp "/title" result))
- (define page-path (regexp-replace* #rx" " title "_"))
+ (define page-path (page-title->path title))
`(li
(a (@ (href ,(format "/~a/wiki/~a" wikiname page-path)))
,title)))
@@ -61,61 +66,61 @@
(define (page-category req)
(response-handler
(define wikiname (path/param-path (first (url-path (request-uri req)))))
- (define prefixed-category (path/param-path (caddr (url-path (request-uri req)))))
+ (define prefixed-category (string-join (map path/param-path (cddr (url-path (request-uri req)))) "/"))
(define origin (format "https://~a.fandom.com" wikiname))
(define source-url (format "~a/wiki/~a" origin prefixed-category))
- (thread-let
- ([members-data (define dest-url
- (format "~a/api.php?~a"
- origin
- (params->query `(("action" . "query")
- ("list" . "categorymembers")
- ("cmtitle" . ,prefixed-category)
- ("cmlimit" . "max")
- ("formatversion" . "2")
- ("format" . "json")))))
- (log-outgoing dest-url)
- (define dest-res (easy:get dest-url #:timeouts timeouts))
- (easy:response-json dest-res)]
- [page-data (define dest-url
- (format "~a/api.php?~a"
- origin
- (params->query `(("action" . "parse")
- ("page" . ,prefixed-category)
- ("prop" . "text|headhtml|langlinks")
- ("formatversion" . "2")
- ("format" . "json")))))
- (log-outgoing dest-url)
- (define dest-res (easy:get dest-url #:timeouts timeouts))
- (easy:response-json dest-res)]
- [siteinfo (siteinfo-fetch wikiname)])
+ (define-values (members-data page-data siteinfo)
+ (thread-values
+ (λ ()
+ (easy:response-json
+ (fandom-get-api
+ wikiname
+ `(("action" . "query")
+ ("list" . "categorymembers")
+ ("cmtitle" . ,prefixed-category)
+ ("cmlimit" . "max")
+ ("formatversion" . "2")
+ ("format" . "json")))))
+ (λ ()
+ (easy:response-json
+ (fandom-get-api
+ wikiname
+ `(("action" . "parse")
+ ("page" . ,prefixed-category)
+ ("prop" . "text|headhtml|langlinks")
+ ("formatversion" . "2")
+ ("format" . "json")))))
+ (λ ()
+ (siteinfo-fetch wikiname))))
- (define title (preprocess-html-wiki (jp "/parse/title" page-data prefixed-category)))
- (define page-html (preprocess-html-wiki (jp "/parse/text" page-data "")))
- (define page (html->xexp page-html))
- (define head-data ((head-data-getter wikiname) page-data))
- (define body (generate-results-page
- #:source-url source-url
- #:wikiname wikiname
- #:title title
- #:members-data members-data
- #:page page
- #:head-data head-data
- #:siteinfo siteinfo))
+ (define title (preprocess-html-wiki (jp "/parse/title" page-data prefixed-category)))
+ (define page-html (preprocess-html-wiki (jp "/parse/text" page-data "")))
+ (define page (html->xexp page-html))
+ (define head-data ((head-data-getter wikiname) page-data))
+ (define body (generate-results-page
+ #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title title
+ #:members-data members-data
+ #:page page
+ #:head-data head-data
+ #:siteinfo siteinfo))
- (when (config-true? 'debug)
- ; used for its side effects
- ; convert to string with error checking, error will be raised if xexp is invalid
- (xexp->html body))
- (response/output
- #:code 200
- #:headers (build-headers always-headers)
- (λ (out)
- (write-html body out))))))
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ (response/output
+ #:code 200
+ #:headers (build-headers always-headers)
+ (λ (out)
+ (write-html body out)))))
(module+ test
(check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Ankle_Monitor")
(generate-results-page
+ #:req test-req
#:source-url ""
#:wikiname "test"
#:title "Category:Items"
diff --git a/src/page-file.rkt b/src/page-file.rkt
index 1802568..5151f1d 100644
--- a/src/page-file.rkt
+++ b/src/page-file.rkt
@@ -15,15 +15,18 @@
"application-globals.rkt"
"config.rkt"
"data.rkt"
+ "fandom-request.rkt"
"page-wiki.rkt"
- "syntax.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/syntax.rkt"
+ "../lib/thread-utils.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide page-file)
(module+ test
- (require rackunit)
+ (require rackunit
+ "test-utils.rkt")
(define test-media-detail
'#hasheq((fileTitle . "Example file")
(videoEmbedCode . "")
@@ -37,8 +40,7 @@
(imageDescription . #f))))
(define (url-content-type url)
- (log-outgoing url)
- (define dest-res (easy:head url #:timeouts timeouts))
+ (define dest-res (easy:head url))
(easy:response-headers-ref dest-res 'content-type))
(define (get-media-html url content-type)
@@ -51,7 +53,8 @@
[(regexp-match? #rx"(?i:^video/)" content-type) `(video (@ (src ,maybe-proxied-url) (controls)))]
[else `""]))
-(define (generate-results-page #:source-url source-url
+(define (generate-results-page #:req req
+ #:source-url source-url
#:wikiname wikiname
#:title title
#:media-detail media-detail
@@ -68,6 +71,7 @@
(define maybe-proxied-raw-image-url
(if (config-true? 'strict_proxy) (u-proxy-url raw-image-url) raw-image-url))
(generate-wiki-page
+ #:req req
#:source-url source-url
#:wikiname wikiname
#:title title
@@ -98,46 +102,49 @@
`""))))
(define (page-file req)
- (define wikiname (path/param-path (first (url-path (request-uri req)))))
- (define prefixed-title (path/param-path (caddr (url-path (request-uri req)))))
- (define origin (format "https://~a.fandom.com" wikiname))
- (define source-url (format "~a/wiki/~a" origin prefixed-title))
+ (response-handler
+ (define wikiname (path/param-path (first (url-path (request-uri req)))))
+ (define prefixed-title (path/param-path (caddr (url-path (request-uri req)))))
+ (define source-url (format "https://~a.fandom.com/wiki/~a" wikiname prefixed-title))
- (thread-let ([media-detail
- (define dest-url
- (format "~a/wikia.php?~a"
- origin
- (params->query `(("format" . "json") ("controller" . "Lightbox")
- ("method" . "getMediaDetail")
- ("fileTitle" . ,prefixed-title)))))
- (log-outgoing dest-url)
- (define dest-res (easy:get dest-url #:timeouts timeouts))
- (easy:response-json dest-res)]
- [siteinfo (siteinfo-fetch wikiname)])
- (if (not (jp "/exists" media-detail #f))
- (next-dispatcher)
- (response-handler
- (define file-title (jp "/fileTitle" media-detail ""))
- (define title
- (if (non-empty-string? file-title) (format "File:~a" file-title) prefixed-title))
- (define image-content-type
- (if (non-empty-string? (jp "/videoEmbedCode" media-detail ""))
- #f
- (url-content-type (jp "/imageUrl" media-detail))))
- (define body
- (generate-results-page #:source-url source-url
- #:wikiname wikiname
- #:title title
- #:media-detail media-detail
- #:image-content-type image-content-type
- #:siteinfo siteinfo))
- (when (config-true? 'debug)
- ; used for its side effects
- ; convert to string with error checking, error will be raised if xexp is invalid
- (xexp->html body))
- (response/output #:code 200
- #:headers (build-headers always-headers)
- (λ (out) (write-html body out)))))))
+ (define-values (media-detail siteinfo)
+ (thread-values
+ (λ ()
+ (define dest-res
+ (fandom-get
+ wikiname
+ (format "/wikia.php?~a"
+ (params->query `(("format" . "json") ("controller" . "Lightbox")
+ ("method" . "getMediaDetail")
+ ("fileTitle" . ,prefixed-title))))))
+ (easy:response-json dest-res))
+ (λ ()
+ (siteinfo-fetch wikiname))))
+ (if (not (jp "/exists" media-detail #f))
+ (next-dispatcher)
+ (response-handler
+ (define file-title (jp "/fileTitle" media-detail ""))
+ (define title
+ (if (non-empty-string? file-title) (format "File:~a" file-title) prefixed-title))
+ (define image-content-type
+ (if (non-empty-string? (jp "/videoEmbedCode" media-detail ""))
+ #f
+ (url-content-type (jp "/imageUrl" media-detail))))
+ (define body
+ (generate-results-page #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title title
+ #:media-detail media-detail
+ #:image-content-type image-content-type
+ #:siteinfo siteinfo))
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ (response/output #:code 200
+ #:headers (build-headers always-headers)
+ (λ (out) (write-html body out)))))))
(module+ test
(parameterize ([(config-parameter 'strict_proxy) "true"])
(check-equal? (get-media-html "https://static.wikia.nocookie.net/a" "image/jpeg")
@@ -159,7 +166,8 @@
(check-not-false
((query-selector
(attribute-selector 'src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fexamplefile")
- (generate-results-page #:source-url ""
+ (generate-results-page #:req test-req
+ #:source-url ""
#:wikiname "test"
#:title "File:Example file"
#:media-detail test-media-detail
diff --git a/src/page-global-search.rkt b/src/page-global-search.rkt
index a9c79bb..08bfd13 100644
--- a/src/page-global-search.rkt
+++ b/src/page-global-search.rkt
@@ -5,8 +5,8 @@
web-server/http
"application-globals.rkt"
"data.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
page-global-search)
@@ -19,7 +19,7 @@
[(not wikiname)
(response/output
#:code 400
- #:mime-type "text/plain"
+ #:mime-type #"text/plain"
(λ (out)
(displayln "Requires wikiname and q parameters." out)))]
[(or (not q) (equal? q ""))
diff --git a/src/page-home.rkt b/src/page-home.rkt
index 6037d9a..24f7393 100644
--- a/src/page-home.rkt
+++ b/src/page-home.rkt
@@ -6,8 +6,8 @@
"application-globals.rkt"
"data.rkt"
"static-data.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt"
"config.rkt")
(provide
@@ -18,21 +18,26 @@
(define examples
'(("minecraft" "Bricks")
- ("crosscode" "CrossCode_Wiki")
- ("undertale" "Hot_Dog...%3F")
- ("tardis" "Eleanor_Blake")
+ ("crosscode" "CrossCode Wiki")
+ ("undertale" "Hot Dog...?")
+ ("tardis" "Eleanor Blake")
("zelda" "Boomerang")))
(define content
`((h2 "BreezeWiki makes wiki pages on Fandom readable")
(p "It removes ads, videos, and suggested content, leaving you with a clean page that doesn't slow down your device or use up your data.")
- (p "BreezeWiki can also be called an \"alternative frontend for Fandom\".")
(p ,(format "To use BreezeWiki, just replace \"fandom.com\" with \"~a\", and you'll instantly be teleported to a better world."
(if (config-true? 'canonical_origin)
(url-host (string->url (config-get 'canonical_origin)))
"breezewiki.com")))
(p "If you'd like to be automatically sent to BreezeWiki every time in the future, "
+ ,@(if (config-member? 'promotions::indie_wiki_buddy "home")
+ `((a (@ (href "https://getindie.wiki")) "get our affiliated browser extension (NEW!)")
+ " or ")
+ null)
(a (@ (href "https://docs.breezewiki.com/Automatic_Redirection.html")) "check out the tutorial in the manual."))
+ (p "BreezeWiki is available on several different websites called " (a (@ (href "https://en.wikipedia.org/wiki/Mirror_site")) "mirrors") ". Each is independently run. If one mirror is offline, the others still work. "
+ (a (@ (href "https://docs.breezewiki.com/Links.html#%28part._.Mirrors%29")) "See the list."))
(h2 "Find a page")
(form (@ (action "/search"))
(label (@ (class "paired__label"))
@@ -45,12 +50,12 @@
(h2 "Example pages")
(ul
,@(map (λ (x)
- `(li (a (@ (href ,(apply format "/~a/wiki/~a" x)))
+ `(li (a (@ (href ,(format "/~a/wiki/~a" (car x) (page-title->path (cadr x)))))
,(apply format "~a: ~a" x))))
examples))
(h2 "Testimonials")
(p (@ (class "testimonial")) ">so glad someone introduced me to a F*ndom alternative (BreezeWiki) because that x-factorized spillway of an ad-infested radioactive dumpsite can go die in a fire —RB")
- (p (@ (class "testimonial")) ">you are so right that fandom still sucks even with adblock somehow. even zapping all the stupid padding it still sucks —Minimus")
+ (p (@ (class "testimonial")) ">apparently there are thousands of people essentially running our company " (em "for free") " right now, creating tons of content, and we just put ads on top of it and they're not even employees. thousands of people we can't lay off. thousands! —" (a (@ (href "https://hard-drive.net/fandom-ceo-frustrated-its-impossible-to-lay-off-unpaid-users-who-update-wikias-for-fun/?utm_source=breezewiki") (target "_blank")) "Perkins Miller, Fandom CEO"))
(p (@ (class "testimonial")) ">attempting to go to a wiki's forum page with breezewiki doesn't work, which is based honestly —Tom Skeleton")
(p (@ (class "testimonial")) ">Fandom pages crashing and closing, taking forever to load and locking up as they load the ads on the site... they are causing the site to crash because they are trying to load video ads both at the top and bottom of the site as well as two or three banner ads, then a massive top of site ad and eventually my anti-virus shuts the whole site down because it's literally pulling more resources than WoW in ultra settings... —Anonymous")
(p (@ (class "testimonial")) ">reblogs EXTREMELY appreciated I want that twink* (*fandom wiki) obliterated —footlong")
diff --git a/src/page-proxy.rkt b/src/page-proxy.rkt
index 3c22e1e..cd94603 100644
--- a/src/page-proxy.rkt
+++ b/src/page-proxy.rkt
@@ -9,8 +9,8 @@
web-server/http
(only-in web-server/dispatchers/dispatch next-dispatcher)
"application-globals.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
page-proxy)
diff --git a/src/page-redirect-wiki-home.rkt b/src/page-redirect-wiki-home.rkt
index c8e6dde..255f625 100644
--- a/src/page-redirect-wiki-home.rkt
+++ b/src/page-redirect-wiki-home.rkt
@@ -3,8 +3,8 @@
web-server/http
"application-globals.rkt"
"data.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
redirect-wiki-home)
diff --git a/src/page-search.rkt b/src/page-search.rkt
index f4d1ce3..39f361a 100644
--- a/src/page-search.rkt
+++ b/src/page-search.rkt
@@ -2,7 +2,6 @@
(require racket/dict
racket/list
racket/string
- (prefix-in easy: net/http-easy)
; html libs
html-writing
; web server libs
@@ -13,77 +12,75 @@
"application-globals.rkt"
"config.rkt"
"data.rkt"
- "syntax.rkt"
- "url-utils.rkt"
- "xexpr-utils.rkt")
+ "search-provider-fandom.rkt"
+ "search-provider-solr.rkt"
+ "../lib/syntax.rkt"
+ "../lib/thread-utils.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
page-search)
-(module+ test
- (require rackunit)
- (define search-json-data
- '#hasheq((batchcomplete . #t) (query . #hasheq((search . (#hasheq((ns . 0) (pageid . 219) (size . 1482) (snippet . "") (timestamp . "2022-08-21T08:54:23Z") (title . "Gacha Capsule") (wordcount . 214)) #hasheq((ns . 0) (pageid . 201) (size . 1198) (snippet . "") (timestamp . "2022-07-11T17:52:47Z") (title . "Badges") (wordcount . 181)))))))))
+(define search-providers
+ (hash "fandom" search-fandom
+ "solr" search-solr))
-(define (generate-results-page dest-url wikiname query data #:siteinfo [siteinfo #f])
- (define search-results (jp "/query/search" data))
+;; this takes the info we gathered from fandom and makes the big fat x-expression page
+(define (generate-results-page req source-url wikiname query results-content #:siteinfo [siteinfo #f])
+ ;; this is *another* helper that builds the wiki page UI and lets me put the search results (or whatever else) in the middle
(generate-wiki-page
- #:source-url dest-url
+ ;; so I provide my helper function with the necessary context...
+ #:req req
+ #:source-url source-url
#:wikiname wikiname
#:title query
#:siteinfo siteinfo
- `(div (@ (class "mw-parser-output"))
- (p ,(format "~a results found for " (length search-results))
- (strong ,query))
- (ul ,@(map
- (λ (result)
- (let* ([title (jp "/title" result)]
- [page-path (regexp-replace* #rx" " title "_")]
- [timestamp (jp "/timestamp" result)]
- [wordcount (jp "/wordcount" result)]
- [size (jp "/size" result)])
- `(li (@ (class "my-result"))
- (a (@ (class "my-result__link") (href ,(format "/~a/wiki/~a" wikiname page-path)))
- ,title)
- (div (@ (class "my-result__info"))
- "last edited "
- (time (@ (datetime ,timestamp)) ,(list-ref (string-split timestamp "T") 0))
- ,(format ", ~a words, ~a kb"
- wordcount
- (exact->inexact (/ (round (/ size 100)) 10)))))))
- search-results)))))
+ ;; and here's the actual results to display in the wiki page layout
+ results-content))
+;; will be called when the web browser asks to load the page
(define (page-search req)
+ ;; this just means, catch any errors and display them in the browser. it's a function somewhere else
(response-handler
+ ;; the URL will look like "/minecraft/wiki/Special:Search?q=Spawner"
+ ;; grab the first part to use as the wikiname, in this case, "minecraft"
(define wikiname (path/param-path (first (url-path (request-uri req)))))
- (define query (dict-ref (url-query (request-uri req)) 'q #f))
- (define origin (format "https://~a.fandom.com" wikiname))
- (define dest-url
- (format "~a/api.php?~a"
- origin
- (params->query `(("action" . "query")
- ("list" . "search")
- ("srsearch" . ,query)
- ("formatversion" . "2")
- ("format" . "json")))))
+ ;; grab a dict of url search params
+ (define params (url-query (request-uri req)))
+ ;; grab the part after ?q= which is the search terms
+ (define query (dict-ref params 'q #f))
+ ;; figure out which search provider we're going to use
+ (define search-provider (hash-ref search-providers (config-get 'feature_offline::search)
+ (λ () (error 'search-provider "unknown search provider configured"))))
- (thread-let
- ([dest-res (log-outgoing dest-url)
- (easy:get dest-url #:timeouts timeouts)]
- [siteinfo (siteinfo-fetch wikiname)])
+ ;; external special:search url to link at the bottom of the page as the upstream source
+ (define external-search-url
+ (format "https://~a.fandom.com/wiki/Special:Search?~a"
+ wikiname
+ (params->query `(("query" . ,query)
+ ("search" . "internal")))))
- (define data (easy:response-json dest-res))
+ ;; simultaneously get the search results, as well as information about the wiki as a whole (its license, icon, name)
+ (define-values (results-content siteinfo)
+ (thread-values
+ (λ ()
+ (search-provider wikiname query params)) ;; call the search provider (see file "search-provider-fandom.rkt")
+ (λ ()
+ (siteinfo-fetch wikiname)))) ;; helper function in another file to get information about the wiki
+
+ ;; calling my generate-results-page function with the information so far in order to get a big fat x-expression
+ ;; big fat x-expression goes into the body variable
+ (define body (generate-results-page req external-search-url wikiname query results-content #:siteinfo siteinfo))
+ ;; error checking
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ ;; convert body to HTML and send to browser
+ (response/output
+ #:code 200
+ #:headers (build-headers always-headers)
+ (λ (out)
+ (write-html body out)))))
- (define body (generate-results-page dest-url wikiname query data #:siteinfo siteinfo))
- (when (config-true? 'debug)
- ; used for its side effects
- ; convert to string with error checking, error will be raised if xexp is invalid
- (xexp->html body))
- (response/output
- #:code 200
- #:headers (build-headers always-headers)
- (λ (out)
- (write-html body out))))))
-(module+ test
- (check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Gacha_Capsule")
- (generate-results-page "" "test" "Gacha" search-json-data)))))
diff --git a/src/page-set-user-settings.rkt b/src/page-set-user-settings.rkt
new file mode 100644
index 0000000..b949142
--- /dev/null
+++ b/src/page-set-user-settings.rkt
@@ -0,0 +1,20 @@
+#lang racket/base
+(require racket/dict
+ net/url
+ web-server/http
+ "application-globals.rkt"
+ "data.rkt"
+ "log.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
+
+(provide
+ page-set-user-settings)
+
+(define (page-set-user-settings req)
+ (response-handler
+ (define next-location (dict-ref (url-query (request-uri req)) 'next_location))
+ (define new-settings (read (open-input-string (dict-ref (url-query (request-uri req)) 'new_settings))))
+ (log-set-settings-request (user-cookies^-theme new-settings))
+ (define headers (user-cookies-setter new-settings))
+ (generate-redirect next-location #:headers headers)))
diff --git a/src/page-static-archive.rkt b/src/page-static-archive.rkt
new file mode 100644
index 0000000..501bda7
--- /dev/null
+++ b/src/page-static-archive.rkt
@@ -0,0 +1,94 @@
+#lang racket/base
+(require racket/file
+ racket/path
+ racket/port
+ racket/string
+ net/url
+ web-server/http
+ web-server/servlet-dispatch
+ web-server/dispatchers/filesystem-map
+ (only-in web-server/dispatchers/dispatch next-dispatcher)
+ "../archiver/archiver.rkt"
+ "../lib/mime-types.rkt"
+ "../lib/syntax.rkt"
+ "../lib/xexpr-utils.rkt"
+ "config.rkt"
+ "log.rkt")
+
+(provide
+ page-static-archive)
+
+(define path-archive (anytime-path ".." "storage/archive"))
+
+(define ((replacer wikiname) whole url)
+ (format
+ "url(~a)"
+ (if (or (equal? url "")
+ (equal? url "'")
+ (string-contains? url "/resources-ucp/")
+ (string-contains? url "/fonts/")
+ (string-contains? url "/drm_fonts/")
+ (string-contains? url "//db.onlinewebfonts.com/")
+ (string-contains? url "//bits.wikimedia.org/")
+ (string-contains? url "mygamercard.net/")
+ (string-contains? url "dropbox")
+ (string-contains? url "only=styles")
+ (string-contains? url "https://https://")
+ (regexp-match? #rx"^%20|^'" url)
+ (regexp-match? #rx"^\"?data:" url)
+ (regexp-match? #rx"^file:" url))
+ url
+ (let* ([norm-url
+ (cond
+ [(string-prefix? url "https://") url]
+ [(string-prefix? url "http://") (regexp-replace #rx"http:" url "https:")]
+ [(string-prefix? url "httpshttps://") (regexp-replace #rx"httpshttps://" url "https://")]
+ [(string-prefix? url "//") (string-append "https:" url)]
+ [(string-prefix? url "/") (format "https://~a.fandom.com~a" wikiname url)]
+ [else (error 'replace-style-for-images "unknown URL format: ~a" url)])])
+ (define p (image-url->values norm-url))
+ ;; (printf "hashed: ~a~n -> ~a~n #-> ~a~n" url (car p) (cdr p))
+ (format "/archive/~a/images/~a" wikiname (cdr p))))))
+
+(define (replace-style-for-images wikiname path)
+ (define content (file->string path))
+ (regexp-replace* #rx"url\\(\"?'?([^)]*)'?\"?\\)" content (replacer wikiname)))
+
+(define (handle-style wikiname dest)
+ (when (config-true? 'debug)
+ (printf "using offline mode for style ~a ~a~n" wikiname dest))
+ (log-styles-request #t wikiname dest)
+ (define fs-path (build-path path-archive wikiname "styles" dest))
+ (unless (file-exists? fs-path)
+ (next-dispatcher))
+ (response-handler
+ (define new-content (replace-style-for-images wikiname fs-path))
+ (response/output
+ #:code 200
+ #:headers (list (header #"Content-Type" #"text/css")
+ (header #"Referrer-Policy" #"same-origin"))
+ (λ (out) (displayln new-content out)))))
+
+(define (handle-image wikiname dest) ;; dest is the hash with no extension
+ (unless ((string-length dest) . >= . 40) (next-dispatcher))
+ (response-handler
+ (define dir (build-path path-archive wikiname "images" (substring dest 0 1) (substring dest 0 2)))
+ (unless (directory-exists? dir) (next-dispatcher))
+ (define candidates (directory-list dir))
+ (define target (path->string (findf (λ (f) (string-prefix? (path->string f) dest)) candidates)))
+ (unless target (next-dispatcher))
+ (define ext (substring target 41))
+ (response/output
+ #:code 200
+ #:headers (list (header #"Content-Type" (ext->mime-type (string->bytes/latin-1 ext))))
+ (λ (out)
+ (call-with-input-file (build-path dir target)
+ (λ (in)
+ (copy-port in out)))))))
+
+(define (page-static-archive req)
+ (define path (url-path (request-uri req)))
+ (define-values (_ wikiname kind dest) (apply values (map path/param-path path)))
+ (cond [(equal? kind "styles") (handle-style wikiname dest)]
+ [(equal? kind "images") (handle-image wikiname dest)]
+ [else (response-handler (raise-user-error "page-static-archive: how did we get here?" kind))]))
diff --git a/src/page-static.rkt b/src/page-static.rkt
index 2bf684c..0311229 100644
--- a/src/page-static.rkt
+++ b/src/page-static.rkt
@@ -7,6 +7,8 @@
web-server/dispatchers/filesystem-map
(only-in web-server/dispatchers/dispatch next-dispatcher)
(prefix-in files: web-server/dispatchers/dispatch-files)
+ "../lib/mime-types.rkt"
+ "../lib/syntax.rkt"
"config.rkt")
(provide
@@ -16,6 +18,7 @@
(require rackunit))
(define-runtime-path path-static "../static")
+(define path-archive (anytime-path ".." "storage/archive"))
(define hash-ext-mime-type
(hash #".css" #"text/css"
@@ -25,45 +28,49 @@
#".woff2" #"font/woff2"
#".txt" #"text/plain"))
-(define (ext->mime-type ext)
- (hash-ref hash-ext-mime-type ext))
-(module+ test
- (check-equal? (ext->mime-type #".png") #"image/png"))
-
(define (make-path segments)
(map (λ (seg) (path/param seg '())) segments))
(module+ test
(check-equal? (make-path '("static" "main.css"))
(list (path/param "static" '()) (path/param "main.css" '()))))
+;; given a request path, return a rewritten request path and the source directory on the filesystem to serve based on
(define (path-rewriter p)
(cond
; url is ^/static/... ?
[(equal? (path/param-path (car p)) "static")
; rewrite to ^/... which will be treated as relative to static/ on the filesystem
- (cdr p)]
+ (values (cdr p) path-static)]
+ ; url is ^/archive/... ?
+ [(equal? (path/param-path (car p)) "archive")
+ ; rewrite req to ^/ and dir to /storage/archive
+ (values (cdr p) path-archive)]
; url is literally ^/robots.txt
[(equal? p (make-path '("robots.txt")))
; rewrite to ^/... -- it already is!
- p]
+ (values p path-static)]
; not going to use the static file dispatcher
[#t (next-dispatcher)]))
(module+ test
- (check-equal? (path-rewriter (make-path '("static" "main.css")))
- (make-path '("main.css")))
- (check-equal? (path-rewriter (make-path '("static" "robots.txt")))
- (make-path '("robots.txt")))
- (check-equal? (path-rewriter (make-path '("robots.txt")))
- (make-path '("robots.txt"))))
+ (check-equal? (call-with-values (λ () (path-rewriter (make-path '("static" "main.css")))) cons)
+ (cons (make-path '("main.css")) path-static))
+ (check-equal? (call-with-values (λ () (path-rewriter (make-path '("static" "robots.txt")))) cons)
+ (cons (make-path '("robots.txt")) path-static))
+ (check-equal? (call-with-values (λ () (path-rewriter (make-path '("robots.txt")))) cons)
+ (cons (make-path '("robots.txt")) path-static))
+ (check-equal? (call-with-values (λ () (path-rewriter (make-path '("archive" "minecraft" "styles" "main.css")))) cons)
+ (cons (make-path '("minecraft" "styles" "main.css")) path-archive)))
(define (static-dispatcher conn old-req)
(define old-uri (request-uri old-req))
(define old-path (url-path old-uri))
- (define new-path (path-rewriter old-path))
+ (define-values (new-path source-dir) (path-rewriter old-path))
(define new-uri (struct-copy url old-uri [path new-path]))
(define new-req (struct-copy request old-req [uri new-uri]))
((files:make
- #:url->path (lambda (u) ((make-url->path path-static) u))
+ #:url->path (lambda (u) ((make-url->path source-dir) u))
+ #:path->headers (lambda (p) (list (header #"Access-Control-Allow-Origin" #"*")
+ (header #"Referrer-Policy" #"same-origin")))
#:path->mime-type (lambda (u) (ext->mime-type (path-get-extension u)))
#:cache-no-cache (config-true? 'debug)
#:cache-immutable (not (config-true? 'debug))
diff --git a/src/page-subdomain.rkt b/src/page-subdomain.rkt
index dededfb..024ea79 100644
--- a/src/page-subdomain.rkt
+++ b/src/page-subdomain.rkt
@@ -1,22 +1,65 @@
#lang racket/base
-(require racket/path
+(require racket/match
+ racket/path
racket/string
net/url
web-server/http
+ web-server/dispatchers/dispatch
+ (only-in racket/promise delay)
(prefix-in lift: web-server/dispatchers/dispatch-lift)
"application-globals.rkt"
"config.rkt"
- "xexpr-utils.rkt")
+ "../lib/syntax.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
subdomain-dispatcher)
-(define (subdomain-dispatcher subdomain)
+(module+ test
+ (require rackunit))
+
+(define (do-redirect:make subdomain canonical-origin)
(lift:make
(λ (req)
(response-handler
(define uri (request-uri req))
(define path (url-path uri))
(define path-string (string-join (map (λ (p) (path/param-path p)) path) "/"))
- (define dest (format "~a/~a/~a" (config-get 'canonical_origin) subdomain path-string))
+ (define dest (format "~a/~a/~a" canonical-origin subdomain path-string))
(generate-redirect dest)))))
+
+(define (router req)
+ (define host (bytes->string/utf-8 (header-value (headers-assq* #"host" (request-headers/raw req)))))
+ (define x-canonical-origin (headers-assq* #"x-canonical-origin" (request-headers/raw req)))
+ (define canonical-origin
+ (cond
+ [x-canonical-origin (bytes->string/utf-8 (header-value x-canonical-origin))]
+ [(config-true? 'canonical_origin) (config-get 'canonical_origin)]
+ [#t #f]))
+ (if/out canonical-origin
+ (let* ([canonical-origin-host (url-host (string->url canonical-origin))])
+ (if/in canonical-origin-host
+ (let* ([splitter (string-append "." (url-host (string->url canonical-origin)))]
+ [s (string-split host splitter #:trim? #f)])
+ (if/in (and (eq? 2 (length s)) (equal? "" (cadr s)))
+ (list 'redirect (car s) canonical-origin)))))
+ 'next-dispatcher))
+(module+ test
+ (define (qr url headers)
+ (request #"GET" (string->url url) (map (λ (h) (header (car h) (cadr h))) headers) (delay '()) #f "127.0.0.1" 10416 "127.0.0.1"))
+ (parameterize ([(config-parameter 'canonical_origin) "https://breezewiki.com"])
+ (check-equal? (router (qr "/" '((#"Host" #"breezewiki.com"))))
+ 'next-dispatcher)
+ (check-equal? (router (qr "/wiki/Spell" '((#"Host" #"magic.breezewiki.com"))))
+ '(redirect "magic" "https://breezewiki.com"))
+ (check-equal? (router (qr "/" '((#"Host" #"magic.bw.breezewiki.com")
+ (#"X-Canonical-Origin" #"https://bw.breezewiki.com"))))
+ '(redirect "magic" "https://bw.breezewiki.com"))
+ (check-equal? (router (qr "/" '((#"Host" #"magic.bwxxxxx.onion")
+ (#"X-Canonical-Origin" #"http://bwxxxxx.onion"))))
+ '(redirect "magic" "http://bwxxxxx.onion"))))
+
+(define (subdomain-dispatcher conn req)
+ (match (router req)
+ [(list 'redirect subdomain canonical-origin) ((do-redirect:make subdomain canonical-origin) conn req)]
+ [_ (next-dispatcher)]))
diff --git a/src/page-wiki-offline.rkt b/src/page-wiki-offline.rkt
new file mode 100644
index 0000000..906718d
--- /dev/null
+++ b/src/page-wiki-offline.rkt
@@ -0,0 +1,165 @@
+#lang racket/base
+(require racket/dict
+ racket/file
+ racket/function
+ racket/list
+ racket/match
+ racket/path
+ racket/string
+ ; libs
+ (prefix-in easy: net/http-easy)
+ file/sha1
+ file/gunzip
+ json
+ ; html libs
+ "../lib/html-parsing/main.rkt"
+ html-writing
+ ; web server libs
+ net/url
+ web-server/http
+ web-server/dispatchers/dispatch
+ ; my libs
+ "application-globals.rkt"
+ "../archiver/archiver-database.rkt"
+ "config.rkt"
+ "data.rkt"
+ "log.rkt"
+ "page-wiki.rkt"
+ "../lib/archive-file-mappings.rkt"
+ "../lib/pure-utils.rkt"
+ "../lib/syntax.rkt"
+ "../lib/tree-updater.rkt"
+ "../lib/xexpr-utils.rkt"
+ "../lib/url-utils.rkt")
+
+(provide
+ ; used by the web server
+ page-wiki-offline)
+
+(module+ test
+ (require rackunit))
+
+(define path-archive (anytime-path ".." "storage/archive"))
+
+(when (config-true? 'feature_offline::only)
+ (void (get-slc)))
+
+(define (page-wiki-offline req)
+ (response-handler
+ (define wikiname (path/param-path (first (url-path (request-uri req)))))
+ (define segments (map path/param-path (cdr (url-path (request-uri req)))))
+ (define basename (url-segments->basename segments))
+ (define maybe-hashed-basename (if ((string-length basename) . > . 240)
+ (sha1 (string->bytes/latin-1 basename))
+ basename))
+
+ (define user-cookies (user-cookies-getter req))
+ (define theme (user-cookies^-theme user-cookies))
+
+ (log-page-request #t wikiname maybe-hashed-basename theme)
+
+ (define archive-format
+ (case (config-get 'feature_offline::format)
+ [(".json" "json") (cons "~a.json" (λ () (read-json)))]
+ [(".json.gz" "json.gz") (cons "~a.json.gz" (λ ()
+ (define-values (in out) (make-pipe))
+ (gunzip-through-ports (current-input-port) out)
+ (read-json in)))]
+ [else (error 'archive-format "unknown archive format configured")]))
+ (define fs-path (build-path path-archive wikiname (format (car archive-format) maybe-hashed-basename)))
+ (define source-url (format "https://~a.fandom.com/wiki/~a" wikiname (basename->name-for-query basename)))
+ (cond/var
+
+ [(file-exists? fs-path)
+ (when (config-true? 'debug)
+ (printf "using offline mode for ~v~n" fs-path))
+ (response-handler
+ (define data (with-input-from-file fs-path (cdr archive-format)))
+ (define article-title (jp "/parse/title" data))
+ (define original-page (html->xexp (preprocess-html-wiki (jp "/parse/text" data))))
+ (define page ((query-selector (λ (t a c) (has-class? "mw-parser-output" a)) original-page)))
+ (define initial-head-data ((head-data-getter wikiname) data))
+ (define head-data
+ (case theme
+ [(light dark)
+ (struct-copy head-data^ initial-head-data
+ [body-class (regexp-replace #rx"(theme-fandomdesktop-)(light|dark)"
+ (head-data^-body-class initial-head-data)
+ (format "\\1~a" theme))])]
+ [else initial-head-data]))
+ (define body
+ (generate-wiki-page
+ (update-tree-wiki page wikiname)
+ #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title article-title
+ #:online-styles #f
+ #:head-data head-data
+ #:siteinfo (siteinfo-fetch wikiname)
+ ))
+ (define redirect-query-parameter (dict-ref (url-query (request-uri req)) 'redirect "yes"))
+ (define redirect-msg ((query-selector (attribute-selector 'class "redirectMsg") body)))
+ (define redirect-msg-a (if redirect-msg
+ ((query-selector (λ (t a c) (eq? t 'a)) redirect-msg))
+ #f))
+ (define headers
+ (build-headers
+ always-headers
+ ; redirect-query-parameter: only the string "no" is significant:
+ ; https://github.com/Wikia/app/blob/fe60579a53f16816d65dad1644363160a63206a6/includes/Wiki.php#L367
+ (when (and redirect-msg-a
+ (not (equal? redirect-query-parameter "no")))
+ (let* ([dest (get-attribute 'href (bits->attributes redirect-msg-a))]
+ [value (bytes-append #"0;url=" (string->bytes/utf-8 dest))])
+ (header #"Refresh" value)))))
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ (response/output
+ #:code 200
+ #:headers headers
+ (λ (out)
+ (write-html body out))))]
+
+ ;; page not found on disk, perhaps it's a redirect? redirects are stored in the database
+ (var target (query-maybe-value* "select redirect from page where wikiname = ? and basename = ?" wikiname basename))
+ [target
+ ; don't url decode the target, or Category: pages will be interpreted as a protocol
+ (generate-redirect (format "/~a/wiki/~a" wikiname (regexp-replace* #rx"#" target "/")))]
+
+ ;; breezewiki doesn't have the page archived, see if we can make a network request for it
+ [(not (config-true? 'feature_offline::only))
+ (next-dispatcher)]
+
+ ;; no possible way to provide the page
+ [else
+ (define mirror-path (url->string (request-uri req)))
+ (define body
+ (generate-wiki-page
+ `(div (@ (class "unsaved-page"))
+ (style ".unsaved-page a { text-decoration: underline !important }")
+ (p "breezewiki.com doesn't have this page saved.")
+ (p "You can see this page by visiting a BreezeWiki mirror:")
+ (ul
+ (li (a (@ (href ,(format "https://antifandom.com~a" mirror-path))) "View on antifandom.com"))
+ (li (a (@ (href ,(format "https://bw.artemislena.eu~a" mirror-path))) "View on artemislena.eu"))
+ (li (a (@ (href ,source-url)) "or, you can see the original page on Fandom (ugh)")))
+ (p "If you'd like " ,wikiname ".fandom.com to be added to breezewiki.com, " (a (@ (href "https://lists.sr.ht/~cadence/breezewiki-requests")) "let me know about it!")))
+ #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title (url-segments->guess-title segments)
+ #:online-styles #f
+ #:siteinfo (siteinfo-fetch wikiname)
+ ))
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ (response/output
+ #:code 200
+ #:headers always-headers
+ (λ (out)
+ (write-html body out)))])))
diff --git a/src/page-wiki.rkt b/src/page-wiki.rkt
index a0a2944..da63617 100644
--- a/src/page-wiki.rkt
+++ b/src/page-wiki.rkt
@@ -7,7 +7,7 @@
; libs
(prefix-in easy: net/http-easy)
; html libs
- html-parsing
+ "../lib/html-parsing/main.rkt"
html-writing
; web server libs
net/url
@@ -17,10 +17,14 @@
"application-globals.rkt"
"config.rkt"
"data.rkt"
- "pure-utils.rkt"
- "syntax.rkt"
- "xexpr-utils.rkt"
- "url-utils.rkt")
+ "fandom-request.rkt"
+ "../lib/archive-file-mappings.rkt"
+ "../lib/pure-utils.rkt"
+ "../lib/syntax.rkt"
+ "../lib/thread-utils.rkt"
+ "../lib/tree-updater.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
(provide
; used by the web server
@@ -30,292 +34,101 @@
preprocess-html-wiki)
(module+ test
- (require rackunit)
- (define wiki-document
- '(*TOP*
- (div (@ (class "mw-parser-output"))
- (aside (@ (role "region") (class "portable-infobox pi-theme-wikia pi-layout-default"))
- (h2 (@ (class "pi-item pi-title") (data-source "title"))
- "Infobox Title")
- (figure (@ (class "pi-item pi-image") (data-source "image"))
- (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image image-thumbnail") (title ""))
- (img (@ (src "https://static.wikia.nocookie.net/nice-image-thumbnail.png") (class "pi-image-thumbnail")))))
- (div (@ (class "pi-item pi-data") (data-source "description"))
- (h3 (@ (class "pi-data-label"))
- "Description")
- (div (@ (class "pi-data-value"))
- "Mystery infobox!")))
- (div (@ (data-test-collapsesection) (class "collapsible collapsetoggle-inline collapsed"))
- (i (b "This section is hidden for dramatic effect."))
- (div (@ (class "collapsible-content"))
- (p "Another page link: "
- (a (@ (data-test-wikilink) (href "https://test.fandom.com/wiki/Another_Page") (title "Another Page"))
- "Another Page"))))
- (figure (@ (class "thumb tnone"))
- (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image") (data-test-figure-a))
- (img (@ (src "data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D")
- (data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
- (class "thumbimage lazyload"))))
- (noscript
- (a (@ (href "https://static.wikia.nocookie.net/nice-image.png") (class "image"))
- (img (@ (src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
- (data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
- (class "thumbimage")))))
- (figcaption "Test figure!"))
- (iframe (@ (src "https://example.com/iframe-src")))))))
-
-(define (preprocess-html-wiki html)
- (define (rr* find replace contents)
- (regexp-replace* find contents replace))
- ((compose1
- ; fix navbox list nesting
- ; navbox on right of page has incorrect html "" and the xexpr parser puts the much further up the tree
- ; add a to make the parser happy
- ; usage: /fallout/wiki/Fallout:_New_Vegas_achievements_and_trophies
- (curry rr* #rx"(]*>\n?)()" "\\1\\2")
- ; change to to make the parser happy
- (curry rr* #rx"(]*>)[ \t]*([^<]*)
" "\\1\\2 "))
- html))
-(module+ test
- (check-equal? (preprocess-html-wiki "\nHey ")
- " \nHey ")
- (check-equal? (preprocess-html-wiki " Caption text.
")
- "Caption text. "))
-
-(define (update-tree-wiki tree wikiname)
- (update-tree
- (λ (element element-type attributes children)
- ;; replace whole element?
- (cond
- ; wrap tables in a div.table-scroller
- [(and (eq? element-type 'table)
- (has-class? "wikitable" attributes)
- (not (dict-has-key? attributes 'data-scrolling)))
- `(div
- ((class "table-scroller"))
- ((,element-type (@ (data-scrolling) ,@attributes)
- ,@children)))]
- ; exclude empty figcaptions
- [(and (eq? element-type 'figcaption)
- (or (eq? (length (filter element-is-element? children)) 0)
- ((query-selector (λ (element-type attributes children)
- (eq? element-type 'use))
- element))))
- return-no-element]
- ; exclude infobox items that are videos, and gallery items that are videos
- [(and (or (has-class? "pi-item" attributes)
- (has-class? "wikia-gallery-item" attributes))
- ((query-selector (λ (element-type attributes children)
- (has-class? "video-thumbnail" attributes))
- element)))
- return-no-element]
- ; exclude the invisible brackets after headings
- [(and (eq? element-type 'span)
- (has-class? "mw-editsection" attributes))
- return-no-element]
- ; display a link instead of an iframe
- [(eq? element-type 'iframe)
- (define src (car (dict-ref attributes 'src null)))
- `(a
- ((class "iframe-alternative") (href ,src))
- (,(format "Embedded media: ~a" src)))]
- ; remove noscript versions of images because they are likely lower quality than the script versions
- [(and (eq? element-type 'noscript)
- (match children
- ; either the noscript has a.image as a first child...
- [(list (list 'a (list '@ a-att ...) _)) (has-class? "image" a-att)]
- ; or the noscript has img as a first child
- [(list (list 'img _)) #t]
- [_ #f]))
- return-no-element]
- [#t
- (list element-type
- ;; attributes
- ((compose1
- ; uncollapsing
- (curry attribute-maybe-update 'class
- (λ (class)
- (string-join
- ((compose1
- ; uncollapse all navbox items (bottom of page mass navigation)
- (curry u
- (λ (classlist) (and (eq? element-type 'table)
- (member "navbox" classlist)
- (member "collapsed" classlist)))
- (λ (classlist) (filter (curry (negate equal?) "collapsed") classlist)))
- ; uncollapse portable-infobox sections
- (curry u
- (λ (classlist) (and (eq? element-type 'section)
- (member "pi-collapse" classlist)))
- (λ (classlist) (filter (λ (v)
- (and (not (equal? v "pi-collapse-closed"))
- (not (equal? v "pi-collapse"))))
- classlist)))
- ; generic: includes article sections and tables, probably more
- (curry u
- (λ (classlist) (and (member "collapsible" classlist)
- (member "collapsed" classlist)))
- (λ (classlist) (filter (curry (negate equal?) "collapsed") classlist))))
- (string-split class " "))
- " ")))
- ; change links to stay on the same wiki
- (curry attribute-maybe-update 'href
- (λ (href)
- ((compose1
- (λ (href) (regexp-replace #rx"^(/wiki/.*)" href (format "/~a\\1" wikiname)))
- (λ (href) (regexp-replace (pregexp (format "^https://(~a)\\.fandom\\.com(/wiki/.*)" px-wikiname)) href "/\\1\\2")))
- href)))
- ; add noreferrer to a.image
- (curry u
- (λ (v) (and (eq? element-type 'a)
- (has-class? "image" v)))
- (λ (v) (dict-update v 'rel (λ (s)
- (list (string-append (car s) " noreferrer")))
- '(""))))
- ; proxy images from inline styles, if strict_proxy is set
- (curry u
- (λ (v) (config-true? 'strict_proxy))
- (λ (v) (attribute-maybe-update
- 'style
- (λ (style)
- (regexp-replace #rx"url\\(['\"]?(.*?)['\"]?\\)" style
- (λ (whole url)
- (string-append
- "url("
- (u-proxy-url url)
- ")")))) v)))
- ; and also their links, if strict_proxy is set
- (curry u
- (λ (v)
- (and (config-true? 'strict_proxy)
- (eq? element-type 'a)
- (or (has-class? "image-thumbnail" v)
- (has-class? "image" v))))
- (λ (v) (attribute-maybe-update 'href u-proxy-url v)))
- ; proxy images from src attributes, if strict_proxy is set
- (curry u
- (λ (v) (config-true? 'strict_proxy))
- (λ (v) (attribute-maybe-update 'src u-proxy-url v)))
- ; don't lazyload images
- (curry u
- (λ (v) (dict-has-key? v 'data-src))
- (λ (v) (attribute-maybe-update 'src (λ (_) (car (dict-ref v 'data-src))) v)))
- ; don't use srcset - TODO: use srcset?
- (λ (v) (dict-remove v 'srcset)))
- attributes)
- ;; children
- ((compose1
- ; wrap blinking animated images in a slot so they can be animated with CSS
- (curry u
- (λ (v) (and (has-class? "animated" attributes)
- ((length v) . > . 1)))
- (λ (v)
- `((span (@ (class "animated-slot__outer") (style ,(format "--steps: ~a" (length v))))
- (span (@ (class "animated-slot__inner"))
- ,@v))))))
- children))]))
- tree))
-(module+ test
- (define transformed
- (parameterize ([(config-parameter 'strict_proxy) "true"])
- (update-tree-wiki wiki-document "test")))
- ; check that wikilinks are changed to be local
- (check-equal? (get-attribute 'href (bits->attributes
- ((query-selector
- (λ (t a c) (dict-has-key? a 'data-test-wikilink))
- transformed))))
- "/test/wiki/Another_Page")
- ; check that a.image has noreferrer
- (check-equal? (get-attribute 'rel (bits->attributes
- ((query-selector
- (λ (t a c) (and (eq? t 'a)
- (has-class? "image" a)))
- transformed))))
- " noreferrer")
- ; check that article collapse sections become uncollapsed
- (check-equal? (get-attribute 'class (bits->attributes
- ((query-selector
- (λ (t a c) (dict-has-key? a 'data-test-collapsesection))
- transformed))))
- "collapsible collapsetoggle-inline")
- ; check that iframes are gone
- (check-false ((query-selector (λ (t a c) (eq? t 'iframe)) transformed)))
- (check-equal? (let* ([alternative ((query-selector (λ (t a c) (has-class? "iframe-alternative" a)) transformed))]
- [link ((query-selector (λ (t a c) (eq? t 'a)) alternative))])
- (get-attribute 'href (bits->attributes link)))
- "https://example.com/iframe-src")
- ; check that images are proxied
- (check-equal? (get-attribute 'src (bits->attributes
- ((query-selector
- (λ (t a c) (eq? t 'img))
- transformed))))
- "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image-thumbnail.png")
- ; check that links to images are proxied
- (check-equal? (get-attribute 'href (bits->attributes
- ((query-selector
- (λ (t a c) (and (eq? t 'a) (has-class? "image-thumbnail" a)))
- transformed))))
- "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image.png")
- (check-equal? (get-attribute 'href (bits->attributes
- ((query-selector
- (λ (t a c) (member '(data-test-figure-a) a))
- transformed))))
- "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnice-image.png")
- ; check that noscript images are removed
- (check-equal? ((query-selector (λ (t a c) (eq? t 'noscript)) transformed)) #f))
+ (require rackunit))
(define (page-wiki req)
(define wikiname (path/param-path (first (url-path (request-uri req)))))
- (define origin (format "https://~a.fandom.com" wikiname))
- (define path (string-join (map path/param-path (cddr (url-path (request-uri req)))) "/"))
+ (define segments (map path/param-path (cdr (url-path (request-uri req)))))
+ (define user-cookies (user-cookies-getter req))
+ (define path (string-join (cdr segments) "/"))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path))
- (thread-let
- ([dest-res (define dest-url
- (format "~a/api.php?~a"
- origin
- (params->query `(("action" . "parse")
- ("page" . ,path)
- ("prop" . "text|headhtml|langlinks")
- ("formatversion" . "2")
- ("format" . "json")))))
- (log-outgoing dest-url)
- (easy:get dest-url #:timeouts timeouts)]
- [siteinfo (siteinfo-fetch wikiname)])
+ (define-values (dest-res siteinfo)
+ (thread-values
+ (λ ()
+ (fandom-get-api
+ wikiname
+ `(("action" . "parse")
+ ("page" . ,path)
+ ("prop" . "text|headhtml|langlinks")
+ ("formatversion" . "2")
+ ("format" . "json"))
+ #:headers `#hasheq((cookie . ,(format "theme=~a" (user-cookies^-theme user-cookies))))))
+ (λ ()
+ (siteinfo-fetch wikiname))))
- (cond
- [(eq? 200 (easy:response-status-code dest-res))
- (let* ([data (easy:response-json dest-res)]
- [title (jp "/parse/title" data "")]
- [page-html (jp "/parse/text" data "")]
- [page-html (preprocess-html-wiki page-html)]
- [page (html->xexp page-html)]
- [head-data ((head-data-getter wikiname) data)])
- (if (equal? "missingtitle" (jp "/error/code" data #f))
- (next-dispatcher)
- (response-handler
- (define body
- (generate-wiki-page
- (update-tree-wiki page wikiname)
- #:source-url source-url
- #:wikiname wikiname
- #:title title
- #:head-data head-data
- #:siteinfo siteinfo))
- (define redirect-msg ((query-selector (attribute-selector 'class "redirectMsg") body)))
- (define headers
- (build-headers
- always-headers
- (when redirect-msg
- (let* ([dest (get-attribute 'href (bits->attributes ((query-selector (λ (t a c) (eq? t 'a)) redirect-msg))))]
- [value (bytes-append #"0;url=" (string->bytes/utf-8 dest))])
- (header #"Refresh" value)))))
- (when (config-true? 'debug)
- ; used for its side effects
- ; convert to string with error checking, error will be raised if xexp is invalid
- (xexp->html body))
- (response/output
- #:code 200
- #:headers headers
- (λ (out)
- (write-html body out))))))])))
+ (cond
+ [(eq? 200 (easy:response-status-code dest-res))
+ (let* ([data (easy:response-json dest-res)]
+ [title (jp "/parse/title" data "")]
+ [page-html (jp "/parse/text" data "")]
+ [page-html (preprocess-html-wiki page-html)]
+ [page (html->xexp page-html)]
+ [head-data ((head-data-getter wikiname) data)])
+ (if (equal? "missingtitle" (jp "/error/code" data #f))
+ (next-dispatcher)
+ (response-handler
+ (define body
+ (generate-wiki-page
+ (update-tree-wiki page wikiname)
+ #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title title
+ #:head-data head-data
+ #:siteinfo siteinfo))
+ (define redirect-query-parameter (dict-ref (url-query (request-uri req)) 'redirect "yes"))
+ (define redirect-msg ((query-selector (attribute-selector 'class "redirectMsg") body)))
+ (define redirect-msg-a (if redirect-msg
+ ((query-selector (λ (t a c) (eq? t 'a)) redirect-msg))
+ #f))
+ (define headers
+ (build-headers
+ always-headers
+ ; redirect-query-parameter: only the string "no" is significant:
+ ; https://github.com/Wikia/app/blob/fe60579a53f16816d65dad1644363160a63206a6/includes/Wiki.php#L367
+ (when (and redirect-msg-a
+ (not (equal? redirect-query-parameter "no")))
+ (let* ([dest (get-attribute 'href (bits->attributes redirect-msg-a))]
+ [value (bytes-append #"0;url=" (string->bytes/utf-8 dest))])
+ (header #"Refresh" value)))))
+ (when (config-true? 'debug)
+ ; used for its side effects
+ ; convert to string with error checking, error will be raised if xexp is invalid
+ (xexp->html body))
+ (response/output
+ #:code 200
+ #:headers headers
+ (λ (out)
+ (write-html body out))))))]
+ [(eq? 404 (easy:response-status-code dest-res))
+ (next-dispatcher)]
+ [(memq (easy:response-status-code dest-res) '(403 406))
+ (response-handler
+ (define body
+ (generate-wiki-page
+ `(div
+ (p "Sorry! Fandom isn't allowing BreezeWiki to show pages right now.")
+ (p "We'll automatically try again in 30 seconds, so please stay on this page and be patient.")
+ (p (small "In a hurry? " (a (@ (href ,source-url)) "Click here to read the page on Fandom."))))
+ #:req req
+ #:source-url source-url
+ #:wikiname wikiname
+ #:title (url-segments->guess-title segments)
+ #:siteinfo siteinfo))
+ (response/output
+ #:code 503
+ #:headers (build-headers
+ always-headers
+ (header #"Retry-After" #"30")
+ (header #"Cache-Control" #"max-age=30, public")
+ (header #"Refresh" #"35"))
+ (λ (out)
+ (write-html body out))))]
+ [else
+ (response-handler
+ (error 'page-wiki "Tried to load page ~a/~a~nSadly, the page didn't load because Fandom returned status code ~a with response:~n~a"
+ wikiname
+ path
+ (easy:response-status-code dest-res)
+ (easy:response-body dest-res)))]))
diff --git a/src/reloadable.rkt b/src/reloadable.rkt
index 1939a8b..3cac00b 100644
--- a/src/reloadable.rkt
+++ b/src/reloadable.rkt
@@ -3,6 +3,7 @@
;;; Source: https://github.com/tonyg/racket-reloadable/blob/master/reloadable/main.rkt
;;; Source commit: cae2a14 from 24 May 2015
;;; Source license: LGPL 3 or later
+;;; Further modifications by Cadence as seen in this repo's git history.
(provide (struct-out reloadable-entry-point)
reload-poll-interval
@@ -19,8 +20,8 @@
(require racket/match)
(require racket/rerequire)
-(define reload-poll-interval 0.5) ;; seconds
-(define reload-failure-retry-delay (make-parameter 5)) ;; seconds
+(define reload-poll-interval 0.5) ; seconds
+(define reload-failure-retry-delay (make-parameter 5)) ; seconds
(struct reloadable-entry-point (name
module-path
diff --git a/src/search-provider-fandom.rkt b/src/search-provider-fandom.rkt
new file mode 100644
index 0000000..b8dd48f
--- /dev/null
+++ b/src/search-provider-fandom.rkt
@@ -0,0 +1,59 @@
+#lang racket/base
+(require racket/string
+ (prefix-in easy: net/http-easy)
+ "application-globals.rkt"
+ "config.rkt"
+ "fandom-request.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
+
+(provide
+ search-fandom)
+
+(module+ test
+ (require rackunit
+ "test-utils.rkt")
+ (define search-results-data
+ '(#hasheq((ns . 0) (pageid . 219) (size . 1482) (snippet . "") (timestamp . "2022-08-21T08:54:23Z") (title . "Gacha Capsule") (wordcount . 214)) #hasheq((ns . 0) (pageid . 201) (size . 1198) (snippet . "") (timestamp . "2022-07-11T17:52:47Z") (title . "Badges") (wordcount . 181)))))
+
+(define (search-fandom wikiname query params)
+ (define res
+ (fandom-get-api
+ wikiname
+ `(("action" . "query")
+ ("list" . "search")
+ ("srsearch" . ,query)
+ ("formatversion" . "2")
+ ("format" . "json"))))
+ (define json (easy:response-json res))
+ (define search-results (jp "/query/search" json))
+ (generate-results-content-fandom wikiname query search-results))
+
+;;; generate content for display in the wiki page layout
+(define (generate-results-content-fandom wikiname query search-results)
+ `(div (@ (class "mw-parser-output"))
+ ;; header before the search results showing how many we found
+ (p ,(format "~a results found for " (length search-results))
+ (strong ,query))
+ ;; *u*nordered *l*ist of matching search results
+ (ul ,@(for/list ([result search-results])
+ (let* ([title (jp "/title" result)]
+ [page-path (page-title->path title)]
+ [timestamp (jp "/timestamp" result)]
+ [wordcount (jp "/wordcount" result)]
+ [size (jp "/size" result)])
+ ;; and make this x-expression...
+ `(li (@ (class "my-result"))
+ (a (@ (class "my-result__link") (href ,(format "/~a/wiki/~a" wikiname page-path))) ; using unquote to insert the result page URL
+ ,title) ; using unquote to insert the result page title
+ (div (@ (class "my-result__info")) ; constructing the line under the search result
+ "last edited "
+ (time (@ (datetime ,timestamp)) ,(list-ref (string-split timestamp "T") 0))
+ ,(format ", ~a words, ~a kb"
+ wordcount
+ (exact->inexact (/ (round (/ size 100)) 10))))))))))
+
+(module+ test
+ (parameterize ([(config-parameter 'feature_offline::only) "false"])
+ (check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Gacha_Capsule")
+ (generate-results-content-fandom "test" "Gacha" search-results-data))))))
diff --git a/src/search-provider-solr.rkt b/src/search-provider-solr.rkt
new file mode 100644
index 0000000..c15e31f
--- /dev/null
+++ b/src/search-provider-solr.rkt
@@ -0,0 +1,89 @@
+#lang racket/base
+(require racket/dict
+ racket/string
+ (prefix-in easy: net/http-easy)
+ "application-globals.rkt"
+ "../lib/html-parsing/main.rkt"
+ "../lib/url-utils.rkt"
+ "../lib/xexpr-utils.rkt")
+
+(provide
+ search-solr)
+
+(struct result^ (hl-title hl-body kb words page-path) #:transparent)
+
+(define (search-solr wikiname query params)
+ ;; grab things from params that would modify the search
+ (define op (if (equal? (dict-ref params 'op #f) "or") '("or" . "OR") '("and" . "AND")))
+ (define sort (if (equal? (dict-ref params 'sort #f) "len") '("len" . "len desc") '("relevance" . "score desc")))
+
+ ;; the dest-URL will look something like http://localhost:8983/solr/bloons/select?defType=edismax&fl=id%2Clen&hl.defaultSummary=true&hl.encoder=html&hl.fl=title%2Cbody&hl.method=unified&hl.tag.post=%3C%2Fmark%3E&hl.tag.pre=%3Cmark%3E&hl=true&indent=true&q.op=AND&q=blo&qf=title_prefix%20title%5E2.0%20body%20table%5E0.3&useParams=
+ (define dest-url
+ (format "http://localhost:8983/solr/~a/select?~a"
+ wikiname
+ (params->query `(("defType" . "edismax")
+ ("q" . ,query)
+ ("q.op" . ,(cdr op))
+ ("qf" . "title_prefix title^2.0 body table^0.3")
+ ("hl" . "true")
+ ("hl.method" . "unified")
+ ("hl.defaultSummary" . "true")
+ ("hl.fl" . "title,body")
+ ("fl" . "id,len,title")
+ ("hl.encoder" . "html")
+ ("hl.tag.pre" . "")
+ ("hl.tag.post" . " ")
+ ("sort" . ,(cdr sort))))))
+ ;; HTTP request to dest-url for search results
+ (define res (easy:get dest-url #:timeouts (easy:make-timeout-config #:lease 5 #:connect 5)))
+ (define json (easy:response-json res))
+
+ ;; build result objects
+ (define highlighting (jp "/highlighting" json))
+ (define results
+ (for/list ([doc (jp "/response/docs" json)])
+ (define id (jp "/id" doc))
+ (define len (jp "/len" doc))
+ (define title (let ([t (jp "/title" doc)])
+ (if (list? t) (car t) t)))
+ (define page-path (page-title->path title))
+ (define kb (exact->inexact (/ (round (/ len 100)) 10))) ; divide by 1000 and round to nearest 0.1
+ (define words (* (round (/ len 60)) 10)) ; divide by 6 and round to nearest 10
+ (define hl (hash-ref highlighting (string->symbol id)))
+ (define hl-title (cdr (html->xexp (jp "/title/0" hl))))
+ (define hl-body (cdr (html->xexp (string-trim (jp "/body/0" hl)))))
+ (result^ hl-title hl-body kb words page-path)))
+
+ (define qtime (exact->inexact (/ (round (/ (jp "/responseHeader/QTime" json) 10)) 100)))
+
+ (define (value-selected? value current-value)
+ (append
+ `((value ,value))
+ (if (equal? value current-value)
+ `((selected))
+ `())))
+
+ ;; generate content for display in the wiki page layout
+ `(div (@ (class "mw-parser-output"))
+ (form (@ (class "my-result__filter"))
+ (input (@ (type "hidden") (name "q") (value ,query)))
+ (select (@ (name "op"))
+ (option (@ ,@(value-selected? "and" (car op))) "All words must match")
+ (option (@ ,@(value-selected? "or" (car op))) "Some words must match"))
+ (select (@ (name "sort"))
+ (option (@ ,@(value-selected? "relevance" (car sort))) "Relevant articles")
+ (option (@ ,@(value-selected? "len" (car sort))) "Wordiest articles"))
+ (button "Filter results"))
+ ;; header before the search results showing how many we found
+ (p ,(format "~a results (~a seconds) found for " (jp "/response/numFound" json) qtime)
+ (strong ,query))
+ ;; *u*nordered *l*ist of matching search results
+ (ul ,@(for/list ([result results])
+ `(li (@ (class "my-result"))
+ (a (@ (class "my-result__link") (href ,(format "/~a/wiki/~a" wikiname (result^-page-path result)))) ; url
+ ,@(result^-hl-title result)) ; title
+ (p (@ (class "my-result__description")) ,@(result^-hl-body result)) ; result preview
+ (div (@ (class "my-result__info")) ; line under the search result
+ ,(format "~a words, ~a kb of readable stuff"
+ (result^-words result)
+ (result^-kb result))))))))
diff --git a/src/syntax.rkt b/src/syntax.rkt
deleted file mode 100644
index 8205326..0000000
--- a/src/syntax.rkt
+++ /dev/null
@@ -1,108 +0,0 @@
-#lang racket/base
-(require (for-syntax racket/base))
-
-(provide
- ; help make a nested if. if/in will gain the same false form of its containing if/out.
- if/out
- ; let, but the value for each variable is evaluated within a thread
- thread-let)
-
-(module+ test
- (require rackunit)
- (define (check-syntax-equal? s1 s2)
- (check-equal? (syntax->datum s1)
- (syntax->datum s2))))
-
-;; actual transforming goes on in here.
-;; it's in a submodule so that it can be required in both levels, for testing
-
-(module transform racket/base
- (provide
- transform-if/out
- transform-thread-let)
-
- (define (transform-if/out stx)
- (define tree (cdr (syntax->datum stx))) ; condition true false
- (define else (cddr tree)) ; the else branch cons cell
- (define result
- (let walk ([node tree])
- (cond
- ; normally, node should be a full cons cell (a pair) but it might be something else.
- ; situation: reached the end of a list, empty cons cell
- [(null? node) node]
- ; situation: reached the end of a list, cons cdr was non-list
- [(symbol? node) node]
- ; normal situation, full cons cell
- ; -- don't go replacing through nested if/out
- [(and (pair? node) (eq? 'if/out (car node))) node]
- ; -- replace if/in
- [(and (pair? node) (eq? 'if/in (car node)))
- (append '(if) (cdr node) else)]
- ; recurse down pair head and tail
- [(pair? node) (cons (walk (car node)) (walk (cdr node)))]
- ; something else that can't be recursed into, so pass it through
- [#t node])))
- (datum->syntax stx (cons 'if result)))
-
- (define (transform-thread-let stx)
- (define tree (cdr (syntax->datum stx)))
- (define defs (car tree))
- (define forms (cdr tree))
- (when (eq? (length forms) 0)
- (error (format "thread-let: bad syntax (need some forms to execute after the threads)~n forms: ~a" forms)))
- (define counter (build-list (length defs) values))
- (datum->syntax
- stx
- `(let ([chv (build-vector ,(length defs) (λ (_) (make-channel)))])
- ,@(map (λ (n)
- (define def (list-ref defs n))
- `(thread (λ () (channel-put (vector-ref chv ,n) (let _ () ,@(cdr def))))))
- counter)
- (let ,(map (λ (n)
- (define def (list-ref defs n))
- `(,(car def) (channel-get (vector-ref chv ,n))))
- counter)
- ,@forms)))))
-
-;; the syntax definitions and their tests go below here
-
-(require 'transform (for-syntax 'transform))
-
-(define-syntax (if/out stx)
- (transform-if/out stx))
-(module+ test
- (check-syntax-equal? (transform-if/out #'(if/out (condition 1) (if/in (condition 2) (do-yes)) (do-no)))
- #'(if (condition 1) (if (condition 2) (do-yes) (do-no)) (do-no)))
- (check-equal? (if/out #t (if/in #t 'yes) 'no) 'yes)
- (check-equal? (if/out #f (if/in #t 'yes) 'no) 'no)
- (check-equal? (if/out #t (if/in #f 'yes) 'no) 'no)
- (check-equal? (if/out #f (if/in #f 'yes) 'no) 'no))
-
-(define-syntax (thread-let stx)
- (transform-thread-let stx))
-(module+ test
- ; check that it is transformed as expected
- (check-syntax-equal?
- (transform-thread-let
- #'(thread-let ([a (hey "this is a")]
- [b (hey "this is b")])
- (list a b)))
- #'(let ([chv (build-vector 2 (λ (_) (make-channel)))])
- (thread (λ () (channel-put (vector-ref chv 0) (let _ () (hey "this is a")))))
- (thread (λ () (channel-put (vector-ref chv 1) (let _ () (hey "this is b")))))
- (let ([a (channel-get (vector-ref chv 0))]
- [b (channel-get (vector-ref chv 1))])
- (list a b))))
- ; check that they actually execute concurrently
- (define ch (make-channel))
- (check-equal? (thread-let ([a (begin
- (channel-put ch 'a)
- (channel-get ch))]
- [b (begin0
- (channel-get ch)
- (channel-put ch 'b))])
- (list a b))
- '(b a))
- ; check that it assigns the correct value to the correct variable
- (check-equal? (thread-let ([a (sleep 0) 'a] [b 'b]) (list a b))
- '(a b)))
diff --git a/src/test-utils.rkt b/src/test-utils.rkt
new file mode 100644
index 0000000..1c9860d
--- /dev/null
+++ b/src/test-utils.rkt
@@ -0,0 +1,8 @@
+#lang racket/base
+(require web-server/http/request-structs
+ net/url-structs
+ (only-in racket/promise delay))
+(provide
+ test-req)
+
+(define test-req (request #"GET" (url "https" #f "breezewiki.com" #f #t (list (path/param "" '())) '() #f) '() (delay '()) #f "127.0.0.1" 0 "127.0.0.1"))
diff --git a/static/breezewiki-color.svg b/static/breezewiki-color.svg
new file mode 100644
index 0000000..f273dd3
--- /dev/null
+++ b/static/breezewiki-color.svg
@@ -0,0 +1,136 @@
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/breezewiki-icon-color.svg b/static/breezewiki-icon-color.svg
new file mode 100644
index 0000000..5249b75
--- /dev/null
+++ b/static/breezewiki-icon-color.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/breezewiki-master.svg b/static/breezewiki-master.svg
new file mode 100644
index 0000000..1119dce
--- /dev/null
+++ b/static/breezewiki-master.svg
@@ -0,0 +1,142 @@
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/countdown.js b/static/countdown.js
new file mode 100644
index 0000000..ad8d0be
--- /dev/null
+++ b/static/countdown.js
@@ -0,0 +1,53 @@
+// countdown timer for gacha enthusiasts
+// sample: bandori/wiki/BanG_Dream!_Wikia
+// sample: ensemble-stars/wiki/The_English_Ensemble_Stars_Wiki
+
+import {h, htm, render, signal, computed, effect} from "./preact.js"
+const html = htm.bind(h)
+
+const now = signal(Date.now())
+setInterval(() => now.value = Date.now(), 1000)
+
+const units = [
+ ["w", 7*24*60*60*1000],
+ ["d", 24*60*60*1000],
+ ["h", 60*60*1000],
+ ["m", 60*1000],
+ ["s", 1000]
+]
+
+function getDisplayTime(datetime, now, or) {
+ let difference = datetime - now
+ let foundSignificantField = false
+ if (difference > 0) {
+ return units.map(([letter, duration], index) => {
+ const multiplier = Math.floor(difference / duration)
+ difference -= multiplier * duration
+ if (multiplier > 0 || foundSignificantField) {
+ foundSignificantField = true
+ return multiplier + letter
+ }
+ }).filter(s => s).join(" ")
+ } else if (or) {
+ return or
+ } else {
+ return `[timer ended on ${new Date(datetime).toLocaleString()}]`
+ }
+}
+
+function Countdown(props) {
+ return html`${props.display} `
+}
+
+document.querySelectorAll(".countdown").forEach(eCountdown => {
+ // grab information and make variables
+ const eDate = eCountdown.querySelector(".countdowndate")
+ const eOr = eCountdown.nextElementSibling
+ const or = eOr?.textContent
+ const datetime = new Date(eDate.textContent).getTime()
+ // the mapped signal
+ const display = computed(() => getDisplayTime(datetime, now.value, or))
+ // clear content and render
+ while (eDate.childNodes[0] !== undefined) eDate.childNodes[0].remove()
+ render(html`<${Countdown} display=${display} />`, eDate);
+})
diff --git a/static/icon-theme-dark.svg b/static/icon-theme-dark.svg
new file mode 100644
index 0000000..bc36ede
--- /dev/null
+++ b/static/icon-theme-dark.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/static/icon-theme-default.svg b/static/icon-theme-default.svg
new file mode 100644
index 0000000..4f5655c
--- /dev/null
+++ b/static/icon-theme-default.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/static/icon-theme-light.svg b/static/icon-theme-light.svg
new file mode 100644
index 0000000..cc19c3a
--- /dev/null
+++ b/static/icon-theme-light.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/static/internal.css b/static/internal.css
index 3b9a037..3ca3d38 100644
--- a/static/internal.css
+++ b/static/internal.css
@@ -24,7 +24,7 @@
--theme-body-background-color: #286cab;
--theme-body-background-color--rgb: 40,108,171;
- --theme-body-text-color: #fff;
+ --theme-body-text-color: #000;
--theme-body-text-color--rgb: 255,255,255;
--theme-body-text-color--hover: #cccccc;
--theme-sticky-nav-background-color: #ffffff;
diff --git a/static/main.css b/static/main.css
index 0976e72..5b3e7e2 100644
--- a/static/main.css
+++ b/static/main.css
@@ -8,6 +8,9 @@ pre, code {
font-family: monospace;
font-size: 0.85em;
}
+pre {
+ overflow-x: auto;
+}
ul, ol {
list-style-type: initial;
padding-left: 2em;
@@ -31,6 +34,8 @@ body.skin-fandomdesktop, button, input, textarea, .wikitable, .va-table {
font-family: "Source Sans Pro", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.5;
+ margin: 0;
+ padding: 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1.2em 0 0.6em;
@@ -51,7 +56,7 @@ p {
.custom-top {
display: flex;
flex-wrap: wrap;
- align-items: baseline;
+ align-items: center;
justify-content: space-between;
}
.page-title {
@@ -67,6 +72,25 @@ p {
max-width: 240px;
}
+/* global top banner message */
+.bw-top-banner {
+ display: flex;
+ justify-content: space-evenly;
+ align-items: center;
+ background-color: #000;
+ color: #fff;
+ text-align: center;
+ white-space: pre-line;
+ padding: 8px;
+}
+.bw-top-banner a, .bw-top-banner a:visited {
+ color: #ffdd57;
+ text-decoration: underline;
+}
+.bw-top-banner-rainbow {
+ animation: bw-rainbow-color 1.6s linear infinite;
+}
+
/* custom footer with source and license info */
.custom-footer {
clear: both;
@@ -74,6 +98,7 @@ p {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--theme-page-text-color);
+ word-break: break-word;
}
.custom-footer__cols {
display: grid;
@@ -177,14 +202,22 @@ figcaption, .lightbox-caption, .thumbcaption {
padding: 0;
}
-/* show tabs always */
-.wds-tabs__wrapper {
+/* show tabs if tabs.js isn't loaded */
+.bw-tabs-nojs .wds-tabs__wrapper {
display: none;
}
-.wds-tab__content {
+.bw-tabs-nojs .wds-tab__content {
display: block;
}
+/* javascript audio play buttons */
+a.ext-audiobutton { /* see hearthstone/wiki/Diablo_(Duels_hero) */
+ display: none;
+}
+.sound > [style="display:none"] { /* see minecraft/wiki/villager#Sounds */
+ display: inline !important;
+}
+
/* animated slots */
#mw-content-text .animated > :not(.animated-active), #mw-content-text .animated > .animated-subframe > :not(.animated-active) {
display: inline-block;
@@ -216,17 +249,31 @@ figcaption, .lightbox-caption, .thumbcaption {
.my-result__link {
font-size: 1.2em;
}
+.my-result__description {
+ font-size: 0.8em;
+ white-space: pre-line;
+ margin-left: 1.2em;
+}
+.my-result mark {
+ background: rgba(255, 255, 0, 0.4);
+}
.my-result__info {
font-size: 0.8em;
color: var(--theme-page-text-color--hover);
margin-left: 1.2em;
}
+.my-result__filter {
+ display: grid;
+ grid-template-columns: auto auto auto 1fr;
+ grid-gap: 8px;
+}
/* (breezewiki) search suggestions */
.bw-search-form {
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 0px 5px;
+ align-items: baseline;
}
.bw-ss__container {
grid-column: 2;
@@ -255,6 +302,9 @@ figcaption, .lightbox-caption, .thumbcaption {
.bw-ss__list--loading {
background: #c0c0c0;
}
+.bw-ss__input {
+ width: 100%; /* magically makes it fit the available space */
+}
.bw-ss__input--accepted {
background: #fffbc0;
}
@@ -282,6 +332,48 @@ figcaption, .lightbox-caption, .thumbcaption {
text-align: left;
}
+/* (breezewiki) theme selector */
+.bw-theme__select {
+ display: grid;
+ grid-template-columns: auto auto;
+ grid-gap: 0px 5px;
+ justify-content: right;
+ align-items: baseline;
+ margin-top: 4px;
+}
+.bw-theme__items {
+ display: flex;
+}
+.bw-theme__item {
+ display: inline-block;
+ display: flex;
+ align-items: baseline;
+ padding: 2px;
+ border: 1px solid var(--theme-border-color);
+ border-right-width: 0px;
+ background-color: var(--custom-table-background);
+ color: var(--theme-page-text-color);
+ transition: none;
+}
+.bw-theme__item:hover, .bw-theme__item:focus {
+ /* background-color: var(--theme-page-background-color); */
+ color: var(--theme-accent-color);
+}
+.bw-theme__item:first-child {
+ border-radius: 4px 0px 0px 4px;
+}
+.bw-theme__item:last-child {
+ border-radius: 0px 4px 4px 0px;
+ border-right-width: 1px;
+}
+.bw-theme__item--selected, .bw-theme__item--selected:hover, .bw-theme__item--selected:focus {
+ background-color: var(--theme-accent-color);
+ color: var(--theme-accent-label-color);
+}
+.bw-theme__icon-container svg {
+ vertical-align: middle;
+}
+
/* nintendo independent wiki alliance notice */
.niwa__notice {
background: #fdedd8;
@@ -292,8 +384,11 @@ figcaption, .lightbox-caption, .thumbcaption {
border-radius: 6px;
font-size: 18px;
}
+.niwa__notice--alt {
+ background: #e5fdd8;
+}
.niwa__header {
- font-size: max(2.9vw, 26px);
+ font-size: max(2.75vw, 26px);
margin-top: 0;
}
.niwa__notice a {
@@ -336,6 +431,25 @@ figcaption, .lightbox-caption, .thumbcaption {
font-size: 14px;
text-align: right;
}
+/* more compact notice after it's been seen the first time */
+.niwa--seen {
+ padding: 1.5vw 2vw 2vw;
+ overflow-y: auto;
+ max-height: min(280px, 33vh);
+ font-size: 17px;
+ margin-top: -2vw;
+ margin-bottom: 12px;
+}
+.niwa--seen .niwa__header {
+ font-size: 26px;
+}
+.niwa--seen .niwa__go {
+ padding: 10px 18px;
+ font-size: 20px;
+}
+.niwa--seen .niwa__got-it {
+ display: none;
+}
/* media queries */
@@ -357,6 +471,10 @@ figcaption, .lightbox-caption, .thumbcaption {
.niwa__right {
display: none;
}
+ /* remove balloons in top banner */
+ .bw-balloon {
+ display: none;
+ }
}
@media (min-width: 560px) { /* wider than 560 px */
@@ -372,6 +490,16 @@ figcaption, .lightbox-caption, .thumbcaption {
width: auto !important;
text-align: center !important;
}
+ /* make text content hit the edges of the screen (no space for the background) */
+ .page {
+ margin: 0;
+ }
+ .page__main {
+ background: linear-gradient(to bottom, rgba(var(--theme-page-background-color--rgb), 0), rgba(var(--theme-page-background-color--rgb), 1) 160px);
+ }
+ .page-title {
+ color: var(--theme-body-text-color);
+ }
}
/* *****
@@ -410,3 +538,12 @@ figcaption, .lightbox-caption, .thumbcaption {
font-display: swap;
src: url("/static/source-sans-pro-v21-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2") format("woff2");
}
+
+@keyframes bw-rainbow-color {
+ 0% {
+ filter: hue-rotate(0deg);
+ }
+ 100% {
+ filter: hue-rotate(360deg);
+ }
+}
diff --git a/static/robots.txt b/static/robots.txt
index 6f706c5..b0e8bf6 100644
--- a/static/robots.txt
+++ b/static/robots.txt
@@ -1,3 +1,4 @@
User-Agent: *
Disallow: /*/wiki/*
Disallow: /proxy
+Disallow: /set-user-settings
diff --git a/static/search-suggestions.js b/static/search-suggestions.js
index 1a9a3d3..e0a32d6 100644
--- a/static/search-suggestions.js
+++ b/static/search-suggestions.js
@@ -1,4 +1,4 @@
-import {h, htm, render, useState, useEffect, createContext, useContext, signal, computed, effect} from "./preact.js"
+import {h, htm, render, signal, computed, effect} from "./preact.js"
const html = htm.bind(h)
const classNames = classArr => classArr.filter(el => el).join(" ")
@@ -64,6 +64,7 @@ render(html`<${SuggestionList} />`, eSuggestions)
// input view
effect(() => {
+ query.value // dependency should always be tracked
if (st.peek() === "accepted") return // lock results from changing during navigation
st.value = "loading"
fetchSuggestions(query.value).then(res => {
@@ -74,7 +75,7 @@ effect(() => {
})
})
-document.addEventListener("pageshow", () => {
+window.addEventListener("pageshow", () => {
st.value = "ready" // unlock results from changing after returning to page
})
@@ -88,4 +89,12 @@ render(html`<${SuggestionInput} />`, eInput)
// form focus
eForm.addEventListener("focusin", () => focus.value = true)
-eForm.addEventListener("focusout", () => focus.value = false)
+eForm.addEventListener("focusout", event => {
+ if (eForm.contains(event.relatedTarget)) {
+ // event fired when changing from one form element to the other
+ focus.value = true
+ } else {
+ // event fired when moving out of the form element
+ focus.value = false
+ }
+})
diff --git a/static/tabs.js b/static/tabs.js
new file mode 100644
index 0000000..718b48e
--- /dev/null
+++ b/static/tabs.js
@@ -0,0 +1,40 @@
+"use strict";
+
+const tabFromHash = location.hash.length > 1 ? location.hash.substring(1) : null
+
+for (const tabber of document.body.querySelectorAll(".wds-tabber")) {
+ for (const [tab, content] of getTabberTabs(tabber)) {
+ // set up click listener on every tab
+ tab.addEventListener("click", e => {
+ setCurrentTab(tabber, tab, content)
+ e.preventDefault()
+ })
+
+ // re-open a specific tab on page load based on the URL hash
+ if (tab.dataset.hash === tabFromHash) {
+ setCurrentTab(tabber, tab, content)
+ tab.scrollIntoView()
+ }
+ }
+}
+
+function getTabberTabs(tabber) {
+ // need to scope the selector to handle nested tabs. see /unturned/wiki/Crate for an example
+ const tabs = [...tabber.querySelectorAll(":scope > .wds-tabs__wrapper .wds-tabs__tab")]
+ const contents = [...tabber.querySelectorAll(":scope > .wds-tab__content")]
+ return tabs.map((_, index) => [tabs[index], contents[index]]) // transpose arrays into [[tab, content], ...]
+}
+
+function setCurrentTab(tabber, tab, content) {
+ // clear currently selected tab
+ getTabberTabs(tabber).flat().forEach(e => e.classList.remove("wds-is-current"))
+
+ // select new tab
+ tab.classList.add("wds-is-current")
+ content.classList.add("wds-is-current")
+ if (tab.dataset.hash) {
+ history.replaceState(null, "", `#${tab.dataset.hash}`)
+ }
+}
+
+document.body.classList.remove("bw-tabs-nojs")
diff --git a/static/three-balloons.png b/static/three-balloons.png
new file mode 100644
index 0000000..616d527
Binary files /dev/null and b/static/three-balloons.png differ