Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
6afb01d3ee Fix search colours on Just Cause 3 2026-02-12 00:38:34 +13:00
b061e04a3f fix safety limits 2025-12-31 19:29:53 +13:00
ad7d08e176 fallback to no banner if extwiki unavailable 2025-11-24 22:00:03 +13:00
085fe2adc5 clean up log 2025-11-23 03:02:39 +13:00
e04ff565ee add rudimentary up navigation 2025-11-23 03:00:44 +13:00
54eaf4186f provide way to access full results page 2025-11-23 02:03:20 +13:00
fb48224f6b update file page for jsonp 2025-11-23 01:55:57 +13:00
a26fe3cd13 remove genai quick answers 2025-11-22 14:46:22 +13:00
93369aa39b make the captcha red 2025-11-19 17:07:40 +13:00
b7fe180790 Add JSONP support on other endpoints
Refactored the code to define and process endpoints rather than doing
it imperatively, which avoided a lot of duplicated code.
2025-11-17 15:18:54 +13:00
7b2f96eb03 jump to fragment 2025-11-15 14:28:09 +13:00
94cb54a712 don't squeeze text alongside infoboxes 2025-11-13 02:40:11 +13:00
968ec1962c undertale wikis are independent now 2025-11-13 02:30:27 +13:00
8d3024b201 error message if script blocked 2025-11-13 02:05:51 +13:00
f4907a7b77 show captcha correctly if page unloads 2025-11-13 01:06:53 +13:00
b2fe4ec61a fix annotations on wikipedia links 2025-11-11 13:47:53 +13:00
de6233ad65 debounce enter in search (mobile) 2025-11-10 19:55:51 +13:00
7ba59b5d71 Better theme, better errors 2025-11-10 19:45:20 +13:00
0524e60d5d Update appearance of external links 2025-11-10 19:09:24 +13:00
c93c901ad3 Load first suggestion when pressing enter 2025-11-08 13:55:55 +13:00
5c3ff9b37c Fix tabs for jsonp 2025-11-08 13:43:12 +13:00
1dd90f5a7d Refactor jsonp js for cuteness 2025-11-05 16:35:17 +13:00
048709b2d1 fix cache control for jsonp.js 2025-11-05 00:06:49 +13:00
c4e2fb00ef only replace content so suggestions work 2025-11-05 00:04:19 +13:00
02848acfbb make sure it's a post 2025-11-04 23:56:32 +13:00
4f4c939631 also update document head 2025-11-04 23:35:58 +13:00
143fadcafb clear bogus log 2025-11-04 23:16:15 +13:00
1c675d4873 update dist 2025-11-04 23:12:30 +13:00
23a201cc84 Add JSONP mode and captcha
JSONP mode is on by default. It will fetch main wiki pages in the
browser, without the server needing to make any requests. To turn it
off, add [feature_json] enabled = false to config.ini.

Captcha is off by default. It is a custom solution and is still
experimental at this stage. If you turn it on, please monitor the logs
to see how it goes! config.ini options are as follows:

[captcha]
enabled = true|false
log = true|false
ip_header = <header name set by your reverse proxy, like x-forwarded-for>
2025-11-04 23:06:55 +13:00
443f1eecbc Add user agent and detect blocked pages 2024-10-23 22:52:00 +13:00
28 changed files with 1391 additions and 320 deletions

View file

@ -1,5 +1,7 @@
#lang racket/base #lang racket/base
(require web-server/servlet-dispatch (require racket/splicing
web-server/servlet-dispatch
web-server/safety-limits
"src/config.rkt" "src/config.rkt"
"src/dispatcher-tree.rkt" "src/dispatcher-tree.rkt"
"src/reloadable.rkt") "src/reloadable.rkt")
@ -9,6 +11,9 @@
(reloadable-entry-point->procedure (reloadable-entry-point->procedure
(make-reloadable-entry-point (quote varname) filename)))) (make-reloadable-entry-point (quote varname) filename))))
(require-reloadable "src/page-captcha.rkt" page-captcha)
(require-reloadable "src/page-captcha.rkt" page-captcha-image)
(require-reloadable "src/page-captcha.rkt" page-captcha-verify)
(require-reloadable "src/page-category.rkt" page-category) (require-reloadable "src/page-category.rkt" page-category)
(require-reloadable "src/page-global-search.rkt" page-global-search) (require-reloadable "src/page-global-search.rkt" page-global-search)
(require-reloadable "src/page-home.rkt" page-home) (require-reloadable "src/page-home.rkt" page-home)
@ -34,10 +39,14 @@
(if (config-true? 'debug) "127.0.0.1" #f) (if (config-true? 'debug) "127.0.0.1" #f)
(config-get 'bind_host)) (config-get 'bind_host))
#:port (string->number (config-get 'port)) #:port (string->number (config-get 'port))
#:safety-limits (make-safety-limits #:max-request-body-length (* 8 1024 1024))
(λ (quit) (λ (quit)
(channel-put ch (lambda () (semaphore-post quit))) (channel-put ch (lambda () (semaphore-post quit)))
(dispatcher-tree (dispatcher-tree
; order of these does not matter ; order of these does not matter
page-captcha
page-captcha-image
page-captcha-verify
page-category page-category
page-global-search page-global-search
page-home page-home

View file

@ -1,8 +1,10 @@
#lang racket/base #lang racket/base
(require web-server/servlet-dispatch (require web-server/servlet-dispatch
web-server/safety-limits
"src/config.rkt" "src/config.rkt"
"src/dispatcher-tree.rkt") "src/dispatcher-tree.rkt")
(require (only-in "src/page-captcha.rkt" page-captcha page-captcha-image page-captcha-verify))
(require (only-in "src/page-category.rkt" page-category)) (require (only-in "src/page-category.rkt" page-category))
(require (only-in "src/page-global-search.rkt" page-global-search)) (require (only-in "src/page-global-search.rkt" page-global-search))
(require (only-in "src/page-home.rkt" page-home)) (require (only-in "src/page-home.rkt" page-home))
@ -24,9 +26,13 @@
(if (config-true? 'debug) "127.0.0.1" #f) (if (config-true? 'debug) "127.0.0.1" #f)
(config-get 'bind_host)) (config-get 'bind_host))
#:port (string->number (config-get 'port)) #:port (string->number (config-get 'port))
#:safety-limits (make-safety-limits #:max-request-body-length (* 8 1024 1024))
(λ (quit) (λ (quit)
(dispatcher-tree (dispatcher-tree
; order of these does not matter ; order of these does not matter
page-captcha
page-captcha-image
page-captcha-verify
page-category page-category
page-global-search page-global-search
page-home page-home

View file

@ -1,3 +1,3 @@
#lang info #lang info
(define build-deps '("rackunit-lib" "web-server-lib" "http-easy-lib" "html-parsing" "html-writing" "json-pointer" "typed-ini-lib" "memo" "net-cookies-lib" "db")) (define build-deps '("rackunit-lib" "web-server-lib" "http-easy-lib" "html-parsing" "html-writing" "json-pointer" "typed-ini-lib" "memo" "net-cookies-lib" "db" "sequence-tools-lib"))

View file

@ -32,4 +32,7 @@
(uri-decode (regexp-replace* #rx"#" str "/"))) (uri-decode (regexp-replace* #rx"#" str "/")))
(define (url-segments->guess-title segments) (define (url-segments->guess-title segments)
(regexp-replace* #rx"_" (cadr segments) " ")) (string-join
(for/list ([s (in-list (cdr segments))])
(regexp-replace* #rx"_" s " "))
"/"))

83
lib/make-json.rkt Normal file
View file

@ -0,0 +1,83 @@
#lang racket/base
(require racket/list/grouping
racket/match
racket/syntax)
(provide make-json)
(module+ test
(require rackunit json)
(define sample
`(: continue
(: iistart "2022-01-23T03:44:17Z"
fucontinue "455"
continue "||")
query
(: pages
(: 198
(: pageid 198
ns 6
title "File:Rainbow Flag1.svg"
imageinfo
((: timestamp "2025-03-10T07:24:50Z"
user "DogeMcMeow"))
fileusage
((: pageid 191
ns 0
title "Gay")
(: pageid 215
ns 0
title "LGBTQIA+"))))))))
(define (make-json data)
(match data
[(list ': kvs ...)
(for/fold ([h (hasheq)])
([kv (windows 2 2 kvs)])
(match-define (list raw-k v) kv)
(define k (format-symbol "~a" raw-k))
(hash-set h k (make-json v)))]
[(list x ...)
(map make-json x)]
[x
x]))
(module+ test
(check-equal? (make-json sample)
(string->jsexpr #<<END
{
"continue": {
"iistart": "2022-01-23T03:44:17Z",
"fucontinue": "455",
"continue": "||"
},
"query": {
"pages": {
"198": {
"pageid": 198,
"ns": 6,
"title": "File:Rainbow Flag1.svg",
"imageinfo": [
{
"timestamp": "2025-03-10T07:24:50Z",
"user": "DogeMcMeow"
}
],
"fileusage": [
{
"pageid": 191,
"ns": 0,
"title": "Gay"
},
{
"pageid": 215,
"ns": 0,
"title": "LGBTQIA+"
}
]
}
}
}
}
END
)))

View file

@ -12,7 +12,7 @@
update-tree-wiki) update-tree-wiki)
(define (preprocess-html-wiki html) (define (preprocess-html-wiki html)
(regexp-replace* #rx"(<(?:td|figcaption)[^>]*?>\n?)(?:<li>|[ \t]*?<p class=\"caption\">(.*?)</p>)" (regexp-replace* #rx"(<(?:td|figcaption)[^>]*?>\n?)(?:[ \t]*<a href=\"[^\"]*\" class=\"info-icon\"><svg><use xlink:href=\"#wds-icons-info-small\"></use></svg></a>)?(?:<li>|[ \t]*?<p class=\"caption\">(.*?)</p>)"
html (λ (whole first-tag [contents #f]) html (λ (whole first-tag [contents #f])
(if (eq? (string-ref whole 1) #\f) ;; figcaption (if (eq? (string-ref whole 1) #\f) ;; figcaption
(string-append first-tag "<span class=\"caption\">" contents "</span>") (string-append first-tag "<span class=\"caption\">" contents "</span>")
@ -58,6 +58,16 @@
(data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png") (data-src "https://static.wikia.nocookie.net/nice-image-thumbnail.png")
(class "thumbimage"))))) (class "thumbimage")))))
(figcaption "Test figure!")) (figcaption "Test figure!"))
(div (@ (type "slideshow") (position "center") (widths "500") (mode "slideshow") (seq-no "0") (id "slideshow-0") (hash "b62d0efee427ad7dff1026e6e9dd078c") (class "wikia-slideshow wikia-gallery slideshow-center"))
(div (@ (class "wikia-slideshow-wrapper") (style "width: 500px") (data-test-outer-width))
(div (@ (class "wikia-slideshow-images-wrapper"))
(ul (@ (class "wikia-slideshow-images neutral") (style "height: 375px; width: 500px") (data-test-inner-width))
(li (@ (class "wikia-slideshow-first-image"))
(a (@ (class "image lightbox") (title "Expand slideshow") (id "slideshow-0-0") (style "width: 420px"))
(img (@ (data-src "https://static.wikia.nocookie.net/example/images/3/3d/Image.jpg/revision/latest/scale-to-width-down/500?cb=20140129105112") (class "thumbimage") (width "500") (height "281") (style "border: 0px;"))))
(div (@ (class "wikia-slideshow-overlay"))
(div (@ (class "wikia-slideshow-image-caption"))
"Example caption")))))))
(iframe (@ (src "https://example.com/iframe-src"))) (iframe (@ (src "https://example.com/iframe-src")))
(div (@ (class "reviews")) (div (@ (class "reviews"))
(header "GameSpot Expert Reviews")) (header "GameSpot Expert Reviews"))
@ -225,6 +235,16 @@
(eq? element-type 'use)) (eq? element-type 'use))
element)))) element))))
return-no-element] return-no-element]
; svg icon for external links
[(and (eq? element-type 'a)
(dict-has-key? attributes 'href)
(not (string-contains? (car (dict-ref attributes 'href)) "wikipedia.org"))
(or (has-class? "extiw" attributes) (has-class? "external" attributes)))
`(,element-type
,attributes
(,@children
(svg (@ (fill "currentColor") (width "12") (height "12") (viewBox "0 0 12 12") (class "external"))
(path (@ (d "M6 1h5v5L8.86 3.85 4.7 8 4 7.3l4.15-4.16zM2 3h2v1H2v6h6V8h1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1"))))))]
; exclude infobox items that are videos, and gallery items that are videos ; exclude infobox items that are videos, and gallery items that are videos
[(and (or (has-class? "pi-item" attributes) [(and (or (has-class? "pi-item" attributes)
(has-class? "wikia-gallery-item" attributes)) (has-class? "wikia-gallery-item" attributes))
@ -254,6 +274,9 @@
; remove gamespot reviews/ads ; remove gamespot reviews/ads
[(has-class? "reviews" attributes) [(has-class? "reviews" attributes)
return-no-element] return-no-element]
; remove genai quick answers
[(has-class? "trfc161" attributes)
return-no-element]
; remove customcollapsible customtoggle buttons - sample: warframe/wiki/Amp_(Ability) ; 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)))) [(and (dict-has-key? attributes 'class) (regexp-match? #rx"^mw-customtoggle-[^ ]* button-c$" (car (dict-ref attributes 'class))))
return-no-element] return-no-element]

View file

@ -163,11 +163,14 @@
#:head-data [head-data-in #f] #:head-data [head-data-in #f]
#:siteinfo [siteinfo-in #f] #:siteinfo [siteinfo-in #f]
#:user-cookies [user-cookies-in #f] #:user-cookies [user-cookies-in #f]
#:online-styles [online-styles #t]) #:online-styles [online-styles #t]
#:path [path-in #f]
#:jsonp [jsonp-in #f])
(define siteinfo (or siteinfo-in siteinfo-default)) (define siteinfo (or siteinfo-in siteinfo-default))
(define head-data (or head-data-in ((head-data-getter wikiname)))) (define head-data (or head-data-in ((head-data-getter wikiname))))
(define user-cookies (or user-cookies-in (user-cookies-getter req))) (define user-cookies (or user-cookies-in (user-cookies-getter req)))
(define origin (format "https://~a.fandom.com" wikiname)) (define origin (format "https://~a.fandom.com" wikiname))
(define path (or path-in ""))
(define required-styles (define required-styles
(cond (cond
[online-styles [online-styles
@ -197,12 +200,15 @@
(link (@ (rel "stylesheet") (type "text/css") (href ,(get-static-url "main.css")))) (link (@ (rel "stylesheet") (type "text/css") (href ,(get-static-url "main.css"))))
(script "const BWData = " (script "const BWData = "
,(jsexpr->string (hasheq 'wikiname wikiname ,(jsexpr->string (hasheq 'wikiname wikiname
'strict_proxy (config-true? 'strict_proxy)))) 'strict_proxy (config-true? 'strict_proxy)
'path path
'jsonp jsonp-in)))
(script (@ (type "importmap")) ,importmap)
,(if (config-true? 'feature_search_suggestions) ,(if (config-true? 'feature_search_suggestions)
`(script (@ (type "module") (src ,(get-static-url "search-suggestions.js")))) `(script (@ (type "module") (src ,(get-static-url "search-suggestions.js"))))
"") "")
(script (@ (type "module") (src ,(get-static-url "countdown.js")))) (script (@ (type "module") (src ,(get-static-url "countdown.js"))))
(script (@ (defer) (src ,(get-static-url "tabs.js")))) (script (@ (type "module") (src ,(get-static-url "tabs.js"))))
(link (@ (rel "icon") (href ,(u (λ (v) (config-true? 'strict_proxy)) (link (@ (rel "icon") (href ,(u (λ (v) (config-true? 'strict_proxy))
(λ (v) (u-proxy-url v)) (λ (v) (u-proxy-url v))
(head-data^-icon-url head-data)))))) (head-data^-icon-url head-data))))))
@ -229,7 +235,18 @@
(main (@ (class "page__main")) (main (@ (class "page__main"))
,(extwiki-notice wikiname title req user-cookies) ,(extwiki-notice wikiname title req user-cookies)
(div (@ (class "custom-top")) (div (@ (class "custom-top"))
(h1 (@ (class "page-title")) ,title) (h1 (@ (class "page-title"))
;; adds rudimentary "up" navigation, e.g. /minecraft/wiki/Bastion_Remnant/Structure/Blueprints/Bastion_treasure_corners_edge_middle_blueprint
,@(let ([segments (string-split title "/")])
(add-between
(for/list ([segment (in-list segments)]
[depth (in-naturals 1)])
(define anti-depth (- (length segments) depth))
(define up-href (string-join (make-list anti-depth "..") "/"))
(if (non-empty-string? up-href)
`(a (@ (href ,(format "~a/~a" up-href segment))) ,segment)
segment))
"/")))
(nav (@ (class "sitesearch")) (nav (@ (class "sitesearch"))
(form (@ (action ,(format "/~a/search" wikiname)) (form (@ (action ,(format "/~a/search" wikiname))
(class "bw-search-form") (class "bw-search-form")

View file

@ -49,6 +49,12 @@
(feature_offline::only . "false") (feature_offline::only . "false")
(feature_offline::search . "fandom") (feature_offline::search . "fandom")
(feature_jsonp::enabled . "true")
(captcha::enabled . "false")
(captcha::log . "false")
(captcha::ip_header . "")
(access_log::enabled . "false") (access_log::enabled . "false")
(promotions::indie_wiki_buddy . "banner home"))) (promotions::indie_wiki_buddy . "banner home")))

View file

@ -20,6 +20,7 @@
(struct-out license^) (struct-out license^)
(struct-out head-data^) (struct-out head-data^)
(struct-out user-cookies^) (struct-out user-cookies^)
data->siteinfo
siteinfo-fetch siteinfo-fetch
siteinfo-default siteinfo-default
license-default license-default
@ -66,12 +67,15 @@
("formatversion" . "2")))) ("formatversion" . "2"))))
(cond [(= (easy:response-status-code res) 200) (cond [(= (easy:response-status-code res) 200)
(define data (easy:response-json res)) (define data (easy:response-json res))
(siteinfo^ (jp "/query/general/sitename" data) (data->siteinfo 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])])) [else siteinfo-default])]))
(define (data->siteinfo data)
(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))))
(define/memoize (head-data-getter wikiname) #:hash hash (define/memoize (head-data-getter wikiname) #:hash hash
;; data will be stored here, can be referenced by the memoized closure ;; data will be stored here, can be referenced by the memoized closure
(define this-data head-data-default) (define this-data head-data-default)

View file

@ -55,11 +55,17 @@
(define tree (define tree
(sequencer:make (sequencer:make
subdomain-dispatcher subdomain-dispatcher
(pathprocedure:make "/buddyfight/wiki/It_Doesn't_Work!!" (page ds page-it-works))
(pathprocedure:make "/" (page ds page-home)) (pathprocedure:make "/" (page ds page-home))
(filter:make #rx"^/static/" (hash-ref ds 'static-dispatcher))
(filter:make (pregexp "^/captcha/img/[0-9]+/[0-9]+$") (lift:make (page ds page-captcha-image)))
(filter:make (pregexp "^/captcha/verify/[0-9]+/[0-9]+/[0-9]+/[0-9]+$") (lift:make (page ds page-captcha-verify)))
(if (config-true? 'captcha::enabled)
(lift:make (page ds page-captcha))
(λ (_conn _req) (next-dispatcher)))
(pathprocedure:make "/proxy" (page ds page-proxy)) (pathprocedure:make "/proxy" (page ds page-proxy))
(pathprocedure:make "/search" (page ds page-global-search)) (pathprocedure:make "/search" (page ds page-global-search))
(pathprocedure:make "/set-user-settings" (page ds page-set-user-settings)) (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/Category:.+$" px-wikiname)) (lift:make (page ds page-category)))
(filter:make (pregexp (format "^/~a/wiki/File:.+$" px-wikiname)) (lift:make (page ds page-file))) (filter:make (pregexp (format "^/~a/wiki/File:.+$" px-wikiname)) (lift:make (page ds page-file)))
(if (config-true? 'feature_offline::enabled) (if (config-true? 'feature_offline::enabled)

248
src/endpoints.rkt Normal file
View file

@ -0,0 +1,248 @@
#lang racket/base
(require racket/format
racket/match
racket/string
; libs
(prefix-in easy: net/http-easy)
json
; html libs
html-writing
; web server libs
web-server/http
web-server/dispatchers/dispatch
; my libs
"application-globals.rkt"
"config.rkt"
"data.rkt"
"fandom-request.rkt"
"static-data.rkt"
"../lib/archive-file-mappings.rkt"
"../lib/thread-utils.rkt"
"../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt")
(require (for-syntax racket/base syntax/parse))
(provide
define-endpoint
define-standard-handler
define-post-data-handler
define-jsonp-handler
make-switch-handler
(all-from-out racket/format
racket/match
json
net/http-easy
web-server/dispatchers/dispatch
"../lib/thread-utils.rkt"
"../lib/archive-file-mappings.rkt"
"config.rkt"
"data.rkt"
"fandom-request.rkt"
"static-data.rkt"))
(define-for-syntax (fix-scopes here orig stx)
(define remover (make-syntax-delta-introducer here #f))
(define introducer (make-syntax-delta-introducer orig #f))
(introducer (remover stx 'remove)))
(define-syntax (define-endpoint stx)
(syntax-parse stx
[(_ name
(~and (~seq all ...)
(~seq
((~datum variables) variable ...)
((~datum endpoints) endpoint ...)
((~datum render) render ...))))
#'(define-syntax name #'(all ...))]))
(define ((make-switch-handler #:standard standard #:jsonp jsonp #:post post) req)
(cond
[(equal? (request-method req) #"POST")
(post req)]
[(config-true? 'feature_jsonp::enabled)
(jsonp req)]
[else
(standard req)]))
(define-syntax (define-standard-handler stx)
(syntax-parse stx
[(_ (~and name-args (name:id arg:id ...)) endpoint-ref:expr fn-body ...)
#:with endpoint-syntax (syntax-local-value (cadr (syntax->list #'endpoint-ref)))
(syntax-parse #'endpoint-syntax
#:context 'endpoint-data
[(((~datum variables) variable ...)
((~datum endpoints) (endpoint-id:id (endpoint-param:expr ...)) ...)
((~datum render) render ...))
#:with endpoint-getters (for/list ([id (syntax-e #'(endpoint-id ...))]
[params (syntax-e #'((endpoint-param ...) ...))])
(with-syntax ([params (append (syntax-e params) #;'(("callback" . "proxy.wikipage")))])
(if (eq? (syntax-e id) 'siteinfo)
#'(λ ()
(siteinfo-fetch wikiname))
#'(λ ()
(fandom-get-api
wikiname
`params
#:headers `#hasheq((cookie . ,(format "theme=~a" (user-cookies^-theme user-cookies)))))))))
#:with endpoint-intermediates->values (for/list ([id (syntax-e #'(endpoint-id ...))])
(with-syntax ([id id])
(if (eq? (syntax-e #'id) 'siteinfo)
#'id
#'(easy:response-json id))))
(fix-scopes
#'here stx
#'(define name-args
(response-handler
(let/cc k
variable ...
(define user-cookies (user-cookies-getter req))
(define-values (endpoint-id ...)
(let-values ([(endpoint-id ...) (thread-values (~@ . endpoint-getters))])
(for ([response (list endpoint-id ...)]
#:when (easy:response? response))
(match (easy:response-status-code response)
[404
(next-dispatcher)]
[(or 403 406)
(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 title
#:siteinfo siteinfo))
(k (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))))]
[(not 200)
(k (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 response)
(easy:response-body response)))]
[_ (void)]))
(values (~@ . endpoint-intermediates->values))))
fn-body ...
render ...))))])]))
(define-syntax (define-post-data-handler stx)
(syntax-parse stx
[(_ (~and name-args (name:id arg:id ...)) endpoint-ref:expr fn-body ...)
#:with endpoint-syntax (syntax-local-value (cadr (syntax->list #'endpoint-ref)))
(syntax-parse #'endpoint-syntax
#:context 'endpoint-data
[(((~datum variables) variable ...)
((~datum endpoints) (endpoint-id:id (endpoint-param:expr ...)) ...)
((~datum render) render ...))
#:with endpoint-getters (for/list ([id (syntax-e #'(endpoint-id ...))]
[params (syntax-e #'((endpoint-param ...) ...))])
(with-syntax ([id id])
(if (eq? (syntax-e #'id) 'siteinfo)
#'(data->siteinfo (hash-ref post-data 'id))
#'(hash-ref post-data 'id))))
(fix-scopes
#'here stx
#'(define name-args
(response-handler
(let/cc k
(define (k-validation-error message)
(k (response/jsexpr
#:code 400
#:headers always-headers
`#hasheq((error .
#hasheq((code . "breezewiki")
(info . ,message)))))))
(define post-data/bytes (request-post-data/raw req))
(when (not post-data/bytes)
(k-validation-error "POST requests only, please."))
(define origin-header
(or (headers-assq* #"origin" (request-headers/raw req))
(headers-assq* #"referer" (request-headers/raw req))))
(when (or (not origin-header) (not (string-prefix? (bytes->string/latin-1 (header-value origin-header)) (config-get 'canonical_origin))))
(k-validation-error "Origin/Referer header failed validation - cross-origin requests are not allowed here"))
(define post-data/string (bytes->string/utf-8 post-data/bytes))
(define post-data (string->jsexpr post-data/string))
(define-values (endpoint-id ...) (values (~@ . endpoint-getters)))
(define wikiname (hash-ref post-data 'wikiname))
(define path (hash-ref post-data 'path))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path))
fn-body ...
render ...))))])]))
(define-syntax (define-jsonp-handler stx)
(syntax-parse stx
[(_ (~and name-args (name:id arg:id ...)) endpoint-ref:expr fn-body ...)
#:with endpoint-syntax (syntax-local-value (cadr (syntax->list #'endpoint-ref)))
(syntax-parse #'endpoint-syntax
#:context 'endpoint-data
[(((~datum variables) variable ...)
((~datum endpoints) (endpoint-id:id (endpoint-param:expr ...)) ...)
((~datum render) render ...))
#:with endpoint-scripts (for/list ([id (syntax-e #'(endpoint-id ...))]
[params (syntax-e #'((endpoint-param ...) ...))])
(with-syntax ([id id]
[params (append (syntax-e params)
`(("callback" . ,(format "proxy.~a" (syntax-e id)))))])
#'(script (@ (async)
(src ,(format "https://~a.fandom.com/api.php?~a"
wikiname
(params->query `params)))
(data-jsonp-var ,(format "~a" 'id))
(onerror ,(format "proxy.~a({error: {code: 'disconnected', info: 'Fandom connection failed or was blocked by your browser. Check any browser extensions that may block third-party scripts, then reload the page.'}})" 'id))))))
(fix-scopes
#'here stx
#'(define name-args
(response-handler
(let/cc k
variable ...
fn-body ...
(define body
(generate-wiki-page
`(div
(noscript "You have to enable JavaScript to load wiki pages. Sorry!")
(div (@ (id "loading")))
(div (@ (id "progress-bar") (style "margin-bottom: 50vh"))
(progress))
(script #<<END
var jsonpData = {}
var proxy = new Proxy(jsonpData, {get(obj, prop) { return value => obj[prop] = value }})
END
)
endpoint-scripts
(script (@ (type "module") (src ,(get-static-url "jsonp.js")))))
#:req req
#:source-url source-url
#:wikiname wikiname
#:title title
#:siteinfo siteinfo-default
#:path path
#:jsonp #t))
(when (config-true? 'debug)
(xexp->html body))
(response/output
#:code 200
#:headers always-headers
(λ (out)
(write-html body out)))))))])]))

View file

@ -92,6 +92,16 @@
(λ (props) (λ (props)
'())) '()))
'Undertale
(extwiki-group^
"Undertale"
'(("Reasons and community vote" . "https://undertale.fandom.com/f/p/4400000000000078624")
("Moving announcement" . "https://undertale.fandom.com/f/p/4400000000000078715")
("About the new wiki" . "https://undertale.wiki/w/Undertale_Wiki:About"))
(λ (props)
'((p "Following a community vote on March 26, 2025, most of the wiki's editors have moved to a new host. As a result, this wiki's content may be outdated or incorrect.")
(p "The Undertale & Deltarune Wikis voted to move off their previous host, Fandom. Their reasons included bad user experience, lack of editorial freedom, and disagreement with the sporadic AI Quick Answers that were visible to logged off readers on August 19, 2023. Both wikis moved, and are now independently hosted by the wikis' staff."))))
'empty 'empty
(extwiki-group^ (extwiki-group^
"Misc" "Misc"
@ -533,6 +543,24 @@
(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 "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.")))) (p "If you are interested, please add more articles related to other Rainverse stories."))))
(extwiki^
'("undertale") 'default
'Undertale
"Undertale Wiki"
"https://undertale.wiki/w/Undertale_Wiki"
"https://static.wikia.nocookie.net/undertale/images/e/e6/Site-logo.png/revision/latest?cb=20220717174821"
(λ (props)
`()))
(extwiki^
'("deltarune") 'default
'Undertale
"Deltarune Wiki"
"https://deltarune.wiki/w/Deltarune_Wiki"
"https://deltarune.wiki/images/Deltarune_Wiki_logo.png?cb=cvvhwg&h=thumb.php&f=Deltarune_Wiki_logo.png"
(λ (props)
`()))
;; fandom wikinames * empty * empty * Name * Home Page ;; fandom wikinames * empty * empty * Name * Home Page
(extwiki^ '("aether") 'empty 'empty "Aether Wiki" "https://aether.wiki.gg/wiki/Aether_Wiki" #f #f) (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^ '("before-darkness-falls") 'empty 'empty "Before Darkness Falls Wiki" "https://beforedarknessfalls.wiki.gg/wiki/Before_Darkness_Falls_Wiki" #f #f)
@ -569,6 +597,7 @@
(extwiki^ '("totherescue") 'empty 'empty "To The Rescue!" "https://totherescue.wiki.gg/wiki/To_The_Rescue%21_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^ '("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^ '("undermine") 'empty 'empty "Official UnderMine Wiki" "https://undermine.wiki.gg/wiki/UnderMine_Wiki" #f #f)
(extwiki^ '("undertaleyellow") 'empty 'empty "Undertale Yellow Wiki" "https://undertaleyellow.wiki.gg/wiki/Undetale_Yellow_Wiki" #f #f)
(extwiki^ '("westofloathing" "loathing") 'empty 'empty "Wiki of Loathing" "https://loathing.wiki.gg/wiki/Wiki_of_Loathing" #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^ '("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))) (extwiki^ '("yumenikki" "yume-nikki-dream-diary") 'empty 'empty "Yume Wiki" "https://yume.wiki/Main_Page" #f #f)))

View file

@ -85,46 +85,47 @@
(define/memoize (get-redirect-content wikiname) #:hash hash (define/memoize (get-redirect-content wikiname) #:hash hash
(define wiki (hash-ref wikis-hash wikiname #f)) (define wiki (hash-ref wikis-hash wikiname #f))
(cond (with-handlers ([exn:fail:http-easy:timeout? (λ (e) #f)])
[wiki (cond
(define display-name (cadr wiki)) [wiki
(define endpoint (string-append (get-api-endpoint wiki) "?action=parse&page=MediaWiki:BreezeWikiRedirect&prop=text&formatversion=2&format=json")) (define display-name (cadr wiki))
(define res (get endpoint)) (define endpoint (string-append (get-api-endpoint wiki) "?action=parse&page=MediaWiki:BreezeWikiRedirect&prop=text&formatversion=2&format=json"))
(define html (jp "/parse/text" (response-json res))) (define res (get endpoint))
(define content ((query-selector (λ (t a c) (has-class? "mw-parser-output" a)) (define html (jp "/parse/text" (response-json res)))
(html->xexp html)))) (define content ((query-selector (λ (t a c) (has-class? "mw-parser-output" a))
(define body (for/list ([p (in-producer (query-selector (λ (t a c) (eq? t 'p)) content) #f)]) p)) (html->xexp html))))
(define table (parse-table ((query-selector (λ (t a c) (eq? t 'table)) content)))) (define body (for/list ([p (in-producer (query-selector (λ (t a c) (eq? t 'p)) content) #f)]) p))
(define-values (links links-errors) (table->links table)) (define table (parse-table ((query-selector (λ (t a c) (eq? t 'table)) content))))
(define-values (logo logo-errors) (table->logo table)) (define-values (links links-errors) (table->links table))
(define construct-errors (append links-errors logo-errors)) (define-values (logo logo-errors) (table->logo table))
(λ (title) (define construct-errors (append links-errors logo-errors))
(define go (λ (title)
(string-append (get-search-page wiki) (define go
"?" (string-append (get-search-page wiki)
(params->query `(("search" . ,title) "?"
("go" . "Go"))))) (params->query `(("search" . ,title)
`(aside (@ (class "niwa__notice")) ("go" . "Go")))))
(h1 (@ (class "niwa__header")) ,display-name " has its own website separate from Fandom.") `(aside (@ (class "niwa__notice"))
(div (@ (class "niwa__cols")) (h1 (@ (class "niwa__header")) ,display-name " has its own website separate from Fandom.")
(div (@ (class "niwa__left")) (div (@ (class "niwa__cols"))
(a (@ (class "niwa__go") (href ,go)) "Read " ,title " on " ,display-name "") (div (@ (class "niwa__left"))
,@body (a (@ (class "niwa__go") (href ,go)) "Read " ,title " on " ,display-name "")
(p "This external wiki is a helpful alternative to Fandom. You should " ,@body
(a (@ (href ,go)) "check it out now!"))) (p "This external wiki is a helpful alternative to Fandom. You should "
,(if logo (a (@ (href ,go)) "check it out now!")))
`(div (@ (class "niwa__right")) ,(if logo
(img (@ (class "niwa__logo") (src ,logo)))) `(div (@ (class "niwa__right"))
"")) (img (@ (class "niwa__logo") (src ,logo))))
,(if (pair? links) ""))
`(p (@ (class "niwa__feedback")) ,(if (pair? links)
,@(add-between links " / ")) `(p (@ (class "niwa__feedback"))
"") ,@(add-between links " / "))
,(if (pair? construct-errors) "")
`(ul ,(if (pair? construct-errors)
,@(for/list ([error construct-errors]) `(ul
`(li ,error))) ,@(for/list ([error construct-errors])
"")))] `(li ,error)))
[#t #f])) "")))]
[#t #f])))
(module+ test (module+ test
(check-not-false ((get-redirect-content "gallowmere") "MediEvil Wiki"))) (check-not-false ((get-redirect-content "gallowmere") "MediEvil Wiki")))

View file

@ -1,12 +1,16 @@
#lang typed/racket/base #lang typed/racket/base
(require "config.rkt" (require racket/format
racket/string
"config.rkt"
"../lib/url-utils.rkt") "../lib/url-utils.rkt")
(define-type Headers (HashTable Symbol (U Bytes String))) (define-type Headers (HashTable Symbol (U Bytes String)))
(require/typed net/http-easy (require/typed net/http-easy
[#:opaque Timeout-Config timeout-config?] [#:opaque Timeout-Config timeout-config?]
[#:opaque Response response?] [#:opaque Response response?]
[#:opaque Session session?] [#:opaque Session session?]
[response-status-code (Response -> Natural)]
[current-session (Parameter Session)] [current-session (Parameter Session)]
[current-user-agent (Parameter (U Bytes String))]
[make-timeout-config ([#:lease Positive-Real] [#:connect Positive-Real] -> Timeout-Config)] [make-timeout-config ([#:lease Positive-Real] [#:connect Positive-Real] -> Timeout-Config)]
[get ((U Bytes String) [get ((U Bytes String)
[#:close? Boolean] [#:close? Boolean]
@ -22,19 +26,41 @@
fandom-get-api fandom-get-api
timeouts) 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)) (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) (: no-headers Headers)
(define no-headers '#hasheq()) (define no-headers '#hasheq())
(: fandom-get (String String [#:headers (Option Headers)] -> Response)) (: fandom-get (String String [#:headers (Option Headers)] -> Response))
(define (fandom-get wikiname path #:headers [headers #f]) (define (fandom-get wikiname path #:headers [headers #f])
(define dest-url (string-append "https://www.fandom.com" path)) (or
(define host (string-append wikiname ".fandom.com")) (and ((current-inexact-milliseconds) . < . (+ last-failure failure-persist-time)) stored-failure)
(log-outgoing wikiname path) (let ()
(get dest-url (define dest-url (string-append "https://www.fandom.com" path))
#:timeouts timeouts (define host (string-append wikiname ".fandom.com"))
#:headers (hash-set (or headers no-headers) 'Host host))) (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)) (: fandom-get-api (String (Listof (Pair String String)) [#:headers (Option Headers)] -> Response))
(define (fandom-get-api wikiname params #:headers [headers #f]) (define (fandom-get-api wikiname params #:headers [headers #f])

179
src/page-captcha.rkt Normal file
View file

@ -0,0 +1,179 @@
#lang racket/base
(require racket/class
racket/dict
racket/draw
pict
file/convertible
racket/format
racket/list
racket/math
racket/match
web-server/http
(only-in web-server/dispatchers/dispatch next-dispatcher)
net/url
(only-in net/cookies/server cookie->set-cookie-header cookie-header->alist)
html-writing
"application-globals.rkt"
"data.rkt"
"config.rkt"
"static-data.rkt"
"../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt")
(provide
page-captcha
page-captcha-image
page-captcha-verify)
(define (get-ip req)
(define header
(if (config-true? 'captcha::ip_header)
(headers-assq* (string->bytes/utf-8 (config-get 'captcha::ip_header)) (request-headers/raw req))
#f))
(if header
(~a (header-value header))
(request-client-ip req)))
(define (get-rng req)
(parameterize ([current-pseudo-random-generator (make-pseudo-random-generator)])
(define ip-segments (regexp-match* "[0-9]+" (get-ip req)))
(define seed
(modulo
(for/sum ([i (in-naturals)]
[s ip-segments])
(* (add1 i) (add1 (string->number s))))
(expt 2 32)))
(random-seed seed)
(current-pseudo-random-generator)))
(define (get-key-solution req)
(parameterize ([current-pseudo-random-generator (get-rng req)])
(random 1 (add1 9))))
(define diameter 35)
(define font (make-object font% 12 'system))
(define msg "I'm not a robot!")
(define checkbox (filled-ellipse diameter diameter #:color "Red"))
(define assembly
(frame
(inset
(hc-append
8
checkbox
(text msg font))
8)))
(define-values (inner-x inner-y) (cc-find assembly checkbox))
(define-values (lt-x lt-y) (lt-find assembly checkbox))
(define-values (rb-x rb-y) (rb-find assembly checkbox))
(define (get-coordinate-solution req w h)
(parameterize ([current-pseudo-random-generator (get-rng req)])
(values (random (exact-truncate lt-x) (exact-truncate (- w rb-x)))
(random (exact-truncate lt-y) (exact-truncate (- h rb-y))))))
(define (page-captcha 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))
(for ([pair cookies-alist])
(match pair
[(cons "captcha" method)
(when (config-true? 'captcha::log)
(printf "captcha skip - via ~a [~a] - ~v~n" method (get-ip req) (url->string (request-uri req))))
(next-dispatcher)]
[_ (void)]))
(response-handler
(define body
`(*TOP*
(*DECL* DOCTYPE html)
(html
(head
(meta (@ (name "viewport") (content "width=device-width, initial-scale=1")))
(title "Checking you're not a bot...")
(link (@ (rel "stylesheet") (type "text/css") (href ,(get-static-url "internal.css"))))
(link (@ (rel "stylesheet") (type "text/css") (href ,(get-static-url "main.css"))))
(link (@ (rel "icon") (href ,(head-data^-icon-url head-data-default))))
(script (@ (defer) (src ,(get-static-url "captcha.js")))))
(body (@ (class "skin-fandomdesktop theme-fandomdesktop-light internal"))
(div (@ (class "main-container"))
(div (@ (class "fandom-community-header__background tileBoth header")))
(div (@ (class "page"))
(main (@ (class "page__main"))
(div (@ (class "custom-top"))
(h1 (@ (class "page-title"))
"Checking you're not a bot..."))
(div (@ (id "content") #;(class "page-content"))
(div (@ (id "mw-content-text"))
(p "To confirm, please click directly in the circle, or hold down the " ,(~a (get-key-solution req)) " key on your keyboard.")
(noscript (p "JavaScript is required for the captcha. Sorry!"))
(div (@ (id "captcha-area")))))
,(application-footer #f))))))))
(when (config-true? 'debug)
(xexp->html body))
(response/output
#:code 200
#:headers always-headers
(λ (out)
(write-html body out)))))
(define (page-captcha-image req)
(response-handler
(define w (string->number (path/param-path (third (url-path (request-uri req))))))
(define h (string->number (path/param-path (fourth (url-path (request-uri req))))))
(define-values (at-x at-y) (get-coordinate-solution req w h))
(when (config-true? 'captcha::log)
(printf "captcha show - size ~a x ~a - solution ~a x ~a [~a]~n" w h at-x at-y (get-ip req)))
#;(printf "target: ~a x ~a~ncanvas: ~a x ~a~npict size: ~a-~a ~a-~a~n" at-x at-y x y lt-x rb-x lt-y rb-y)
(define dc (make-object bitmap-dc% #f))
(send dc set-font font)
(define bm (make-object bitmap% w h #f #f))
(send dc set-bitmap bm)
(draw-pict
assembly
dc
(- at-x inner-x)
(- at-y inner-y))
(define image (convert bm 'png-bytes))
(response/output
#:mime-type #"image/png"
#:headers (list (header #"Cache-Control" #"no-cache"))
(λ (out) (display image out)))))
(define (page-captcha-verify req)
(response-handler
(match-define (list w h x y) (for/list ([segment (drop (url-path (request-uri req)) 2)])
(string->number (path/param-path segment))))
#;(printf "solution: ~a x ~a~ncoordinate: ~a x ~a~ndist^2: ~a~n" solution-x solution-y x y dist)
(define headers
(build-headers
always-headers
(cond
[(and (= y 0) (= x (get-key-solution req)))
(when (config-true? 'captcha::log)
(printf "captcha pass - key ~a [~a]~n" x (get-ip req)))
(header #"Set-Cookie" (cookie->set-cookie-header (make-cookie "captcha" "key" #:path "/" #:max-age (* 60 60 24 365 10) #:http-only? #t #:secure? #t)))]
[(= y 0)
(when (config-true? 'captcha::log)
(printf "captcha fail - key ~a instead of ~a [~a]~n" x (get-key-solution req) (get-ip req)))]
[else (void)])
(when (> y 0)
(let-values ([(solution-x solution-y) (get-coordinate-solution req w h)])
(let ([dist (+ (expt (- x solution-x) 2) (expt (- y solution-y) 2))])
(cond
[(dist . < . (expt (/ diameter 2) 2))
(when (config-true? 'captcha::log)
(printf "captcha pass - coordinate, dist^2 ~a [~a]~n" dist (get-ip req)))
(header #"Set-Cookie" (cookie->set-cookie-header (make-cookie "captcha" "coordinate" #:path "/" #:max-age (* 60 60 24 365 10))))]
[else
(when (config-true? 'captcha::log)
(printf "captcha pass - coordinate, dist^2 ~a [~a]~n" dist (get-ip req)))]))))))
(match (dict-ref (url-query (request-uri req)) 'from #f)
[(? string? dest)
(response/output
#:code 302
#:mime-type #"text/plain"
#:headers (cons (header #"Location" (string->bytes/utf-8 dest)) headers)
(λ (out)
(displayln "Checking your answer..." out)))]
[#f (next-dispatcher)])))

View file

@ -1,8 +1,5 @@
#lang racket/base #lang racket/base
(require racket/dict (require racket/string
racket/list
racket/match
racket/string
(prefix-in easy: net/http-easy) (prefix-in easy: net/http-easy)
; html libs ; html libs
html-parsing html-parsing
@ -11,14 +8,9 @@
net/url net/url
web-server/http web-server/http
(only-in web-server/dispatchers/dispatch next-dispatcher) (only-in web-server/dispatchers/dispatch next-dispatcher)
#;(only-in web-server/http/redirect redirect-to)
"application-globals.rkt" "application-globals.rkt"
"config.rkt" "endpoints.rkt"
"data.rkt" "../lib/tree-updater.rkt"
"fandom-request.rkt"
"page-wiki.rkt"
"../lib/syntax.rkt"
"../lib/thread-utils.rkt"
"../lib/url-utils.rkt" "../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt") "../lib/xexpr-utils.rkt")
@ -63,47 +55,47 @@
,title))) ,title)))
members)))))) members))))))
(define (page-category req)
(response-handler
(define wikiname (path/param-path (first (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))
(define-values (members-data page-data siteinfo) (define-endpoint
(thread-values category-endpoint
(λ () [variables
(easy:response-json (define wikiname (path/param-path (car (url-path (request-uri req)))))
(fandom-get-api (define prefixed-category (string-join (map path/param-path (cddr (url-path (request-uri req)))) "/"))
wikiname (define segments (map path/param-path (cdr (url-path (request-uri req)))))
`(("action" . "query") (define title (url-segments->guess-title segments))
("list" . "categorymembers") (define path (string-join (cdr segments) "/"))
("cmtitle" . ,prefixed-category) (define origin (format "https://~a.fandom.com" wikiname))
("cmlimit" . "max") (define source-url (format "~a/wiki/~a" origin prefixed-category))]
("formatversion" . "2") [endpoints
("format" . "json"))))) (membersdata
(λ () (("action" . "query")
(easy:response-json ("list" . "categorymembers")
(fandom-get-api ("cmtitle" . ,prefixed-category)
wikiname ("cmlimit" . "max")
`(("action" . "parse") ("formatversion" . "2")
("page" . ,prefixed-category) ("format" . "json")))
("prop" . "text|headhtml|langlinks") (pagedata
("formatversion" . "2") (("action" . "parse")
("format" . "json"))))) ("page" . ,prefixed-category)
(λ () ("prop" . "text|headhtml|langlinks")
(siteinfo-fetch wikiname)))) ("formatversion" . "2")
("format" . "json")))
(define title (preprocess-html-wiki (jp "/parse/title" page-data prefixed-category))) (siteinfo
(define page-html (preprocess-html-wiki (jp "/parse/text" page-data ""))) (("action" . "query")
("meta" . "siteinfo")
("siprop" . "general|rightsinfo")
("format" . "json")
("formatversion" . "2")))]
[render
(define page-html (preprocess-html-wiki (jp "/parse/text" pagedata "")))
(define page (html->xexp page-html)) (define page (html->xexp page-html))
(define head-data ((head-data-getter wikiname) page-data)) (define head-data ((head-data-getter wikiname) pagedata))
(define body (generate-results-page (define body (generate-results-page
#:req req #:req req
#:source-url source-url #:source-url source-url
#:wikiname wikiname #:wikiname wikiname
#:title title #:title (preprocess-html-wiki (jp "/parse/title" pagedata prefixed-category))
#:members-data members-data #:members-data membersdata
#:page page #:page page
#:head-data head-data #:head-data head-data
#:siteinfo siteinfo)) #:siteinfo siteinfo))
@ -116,7 +108,22 @@
#:code 200 #:code 200
#:headers (build-headers always-headers) #:headers (build-headers always-headers)
(λ (out) (λ (out)
(write-html body out))))) (write-html body out)))])
(define-standard-handler (page-category-standard req)
#'category-endpoint)
(define-jsonp-handler (page-category-jsonp req)
#'category-endpoint)
(define-post-data-handler (page-category-with-data req)
#'category-endpoint
(define prefixed-category path))
(define page-category (make-switch-handler #:standard page-category-standard
#:jsonp page-category-jsonp
#:post page-category-with-data))
(module+ test (module+ test
(check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Ankle_Monitor") (check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Ankle_Monitor")
(generate-results-page (generate-results-page

View file

@ -1,9 +1,6 @@
#lang racket/base #lang racket/base
(require racket/dict (require racket/list
racket/list
racket/match
racket/string racket/string
(prefix-in easy: net/http-easy)
; html libs ; html libs
html-parsing html-parsing
html-writing html-writing
@ -11,97 +8,189 @@
net/url net/url
web-server/http web-server/http
(only-in web-server/dispatchers/dispatch next-dispatcher) (only-in web-server/dispatchers/dispatch next-dispatcher)
#;(only-in web-server/http/redirect redirect-to)
"application-globals.rkt" "application-globals.rkt"
"config.rkt" "endpoints.rkt"
"data.rkt" "../lib/tree-updater.rkt"
"fandom-request.rkt"
"page-wiki.rkt"
"../lib/syntax.rkt"
"../lib/thread-utils.rkt"
"../lib/url-utils.rkt" "../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt") "../lib/xexpr-utils.rkt"
"../lib/make-json.rkt")
(provide page-file) (provide page-file)
(module+ test (module+ test
(require rackunit (require rackunit
"test-utils.rkt") "test-utils.rkt"
(define test-media-detail "../lib/make-json.rkt")
'#hasheq((fileTitle . "Example file") (define test-wikipage
(videoEmbedCode . "") (make-json
(imageUrl . "https://static.wikia.nocookie.net/examplefile") '(: parse
(rawImageUrl . "https://static.wikia.nocookie.net/examplefile") (: title "File:Sailor Cinnamoroll.jpg"
(userName . "blankie") pageid 4448
(isPostedIn . #t) revid 13121
(smallerArticleList . (#hasheq((titleText . "Test:Example article")))) text
(articleListIsSmaller . 0) (: * "<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\">\n<!-- \nNewPP limit report\nCached time: 20251122101032\nCache expiry: 1209600\nReduced expiry: false\nComplications: []\nCPU time usage: 0.001 seconds\nReal time usage: 0.001 seconds\nPreprocessor visited node count: 0/1000000\nPost\u2010expand include size: 0/2097152 bytes\nTemplate argument size: 0/2097152 bytes\nHighest expansion depth: 0/100\nExpensive parser function count: 0/100\nUnstrip recursion depth: 0/20\nUnstrip post\u2010expand size: 0/5000000 bytes\n-->\n<!--\nTransclusion expansion time report (%,ms,calls,template)\n100.00% 0.000 1 -total\n-->\n\n<!-- Saved in parser cache with key 1.43.1_prod_squishmallowsquad:pcache:idhash:4448-0!sseVary=RegularPage!FandomDesktop!LegacyGalleries and timestamp 20251122101032 and revision id 13121. Rendering was triggered because: api-parse\n -->\n</div>")
(exists . #t) langlinks ()
(imageDescription . #f)))) categories ()
links ()
templates ()
images ()
externallinks ()
sections ()
parsewarnings ()
displaytitle "<span class=\"mw-page-title-namespace\">File</span><span class=\"mw-page-title-separator\">:</span><span class=\"mw-page-title-main\">Sailor Cinnamoroll.jpg</span>"
iwlinks ()
properties ()))))
(define test-imageinfo-outer
(make-json
`(: continue
(: iistart "2022-01-23T03:44:17Z"
fucontinue "455"
continue "||")
query
(: pages
((: pageid 198
ns 6
title "File:Rainbow Flag1.svg"
imageinfo
((: timestamp "2025-03-10T07:24:50Z"
user "DogeMcMeow"
url "https://static.wikia.nocookie.net/lgbtqia-sandbox/images/f/f8/Rainbow_Flag1.svg/revision/latest?cb=20250310072450"
descriptionurl "https://lgbtqia.fandom.com/wiki/File:Rainbow_Flag1.svg"
descriptionshorturl "https://lgbtqia.fandom.com/index.php?curid=198"
mime "image/svg+xml"
mediatype "DRAWING"))
fileusage
((: pageid 191
ns 0
title "Gay")
(: pageid 215
ns 0
title "LGBTQIA+")))))))))
(define (url-content-type url) ;; https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/core/+/refs/heads/REL1_44/includes/libs/mime/defines.php
(define dest-res (easy:head url)) ;; (define possible-mediatypes '("UNKNOWN" "BITMAP" "DRAWING" "AUDIO" "VIDEO" "MULTIMEDIA" "OFFICE" "TEXT" "EXECUTABLE" "ARCHIVE" "3D"))
(easy:response-headers-ref dest-res 'content-type))
(define (get-media-html url content-type) (define (get-media-html url imageinfo-inner)
(define maybe-proxied-url (if (config-true? 'strict_proxy) (u-proxy-url url) url)) (define maybe-proxied-url (if (config-true? 'strict_proxy) (u-proxy-url url) url))
(cond (define mediatype (jp "/mediatype" imageinfo-inner #f))
[(eq? content-type #f) `""] (case mediatype
[(regexp-match? #rx"(?i:^image/)" content-type) `(img (@ (src ,maybe-proxied-url)))] [("BITMAP" "DRAWING")
[(regexp-match? #rx"(?i:^audio/|^application/ogg(;|$))" content-type) (match imageinfo-inner
`(audio (@ (src ,maybe-proxied-url) (controls)))] [(hash* ['width width] ['height height])
[(regexp-match? #rx"(?i:^video/)" content-type) `(video (@ (src ,maybe-proxied-url) (controls)))] `(img (@ (src ,maybe-proxied-url) (width ,(~a width)) (height ,(~a height))))]
[else `""])) [else
`(img (@ (src ,maybe-proxied-url)))])]
[("AUDIO") `(audio (@ (src ,maybe-proxied-url) (controls)))]
[("VIDEO") `(video (@ (src ,maybe-proxied-url) (controls)))]
[else ""]))
(define (generate-results-page #:req req (define (generate-results-page #:req req
#:source-url source-url #:source-url source-url
#:wikiname wikiname #:wikiname wikiname
#:title title #:title title
#:media-detail media-detail #:wikipage wikipage
#:image-content-type image-content-type #:imageinfo imageinfo-outer
#:siteinfo [siteinfo #f]) #:siteinfo [siteinfo #f])
(define video-embed-code (jp "/videoEmbedCode" media-detail "")) (define imageinfo-inner (jp "/query/pages/0" imageinfo-outer))
(define raw-image-url (jp "/rawImageUrl" media-detail)) (define fileusage-continues? (jp "/continue/fucontinue" imageinfo-outer #f))
(define image-url (jp "/imageUrl" media-detail raw-image-url)) (define fileusage-titles (for/list ([page (jp "/fileusage" imageinfo-inner)]) (jp "/title" page)))
(define username (jp "/userName" media-detail)) (define image-url (jp "/imageinfo/0/url" imageinfo-inner))
(define is-posted-in (jp "/isPostedIn" media-detail #f)) (define username (jp "/imageinfo/0/user" imageinfo-inner))
(define smaller-article-list (jp "/smallerArticleList" media-detail)) (define maybe-proxied-image-url
(define article-list-is-smaller (jp "/articleListIsSmaller" media-detail)) (if (config-true? 'strict_proxy) (u-proxy-url image-url) image-url))
(define image-description (jp "/imageDescription" media-detail #f))
(define maybe-proxied-raw-image-url
(if (config-true? 'strict_proxy) (u-proxy-url raw-image-url) raw-image-url))
(generate-wiki-page (generate-wiki-page
#:req req #:req req
#:source-url source-url #:source-url source-url
#:wikiname wikiname #:wikiname wikiname
#:title title #:title title
#:siteinfo siteinfo #:siteinfo siteinfo
`(div ,(if (non-empty-string? video-embed-code) `(div ,(get-media-html maybe-proxied-image-url (jp "/imageinfo/0" imageinfo-inner))
(update-tree-wiki (html->xexp (preprocess-html-wiki video-embed-code)) wikiname) (p (a (@ (href ,maybe-proxied-image-url)) "Download original file"))
(get-media-html image-url image-content-type)) ,(if (pair? (jp "/parse/sections" wikipage))
(p ,(if (non-empty-string? video-embed-code) (update-tree-wiki (html->xexp (preprocess-html-wiki (jp "/parse/text" wikipage ""))) wikiname)
`"" ; file license info wasn't displayed in the description (example: /lgbtqia/wiki/File:Rainbow_Flag1.svg)
`(span (a (@ (href ,maybe-proxied-raw-image-url)) "View original file") ". ")) `(p "This file may be copyrighted. Consider licensing and fair use law before reusing it."))
"Uploaded by " (p "Uploaded by "
(a (@ (href ,(format "/~a/wiki/User:~a" wikiname username))) ,username) (a (@ (href ,(format "/~a/wiki/User:~a" wikiname username))) ,username)
".") ".")
,(if (string? image-description) ,(if (pair? fileusage-titles)
(update-tree-wiki (html->xexp (preprocess-html-wiki image-description)) wikiname)
; file license info might be displayed in the description, example: /lgbtqia/wiki/File:Rainbow_Flag1.svg
`(p "This file is likely protected by copyright. Consider the file's license and fair use law before reusing it."))
,(if is-posted-in
`(p "This file is used in " `(p "This file is used in "
,@(map (λ (article) ,@(add-between
(define title (jp "/titleText" article)) (for/list ([title fileusage-titles]
(define page-path (regexp-replace* #rx" " title "_")) [i (in-naturals)])
`(span ,(if (eq? (car smaller-article-list) article) "" ", ") (define page-path (regexp-replace* #rx" " title "_"))
(a (@ (href ,(format "/~a/wiki/~a" wikiname page-path))) `(a (@ (href ,(format "/~a/wiki/~a" wikiname page-path)))
,title))) ,title))
smaller-article-list) ", ")
,(if (eq? article-list-is-smaller 1) "" ".")) ,(if fileusage-continues? "" "."))
`"")))) `""))))
(define (page-file req) (define-endpoint
file-endpoint
[variables
(define wikiname (path/param-path (first (url-path (request-uri req)))))
(define prefixed-file (path/param-path (caddr (url-path (request-uri req)))))
(define segments (map path/param-path (cdr (url-path (request-uri req)))))
(define title (url-segments->guess-title segments))
(define path (string-join (cdr segments) "/"))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname prefixed-file))]
[endpoints
(wikipage
(("action" . "parse")
("page" . ,prefixed-file)
("prop" . "text|headhtml|langlinks|sections")
("formatversion" . "2")
("format" . "json")))
(siteinfo
(("action" . "query")
("meta" . "siteinfo")
("siprop" . "general|rightsinfo")
("format" . "json")
("formatversion" . "2")))
(imageinfo
(("action" . "query")
("titles" . ,prefixed-file)
("prop" . "imageinfo|fileusage")
("iiprop" . "timestamp|user|canonicaltitle|url|size|mime|mediatype")
("iilocalonly" . "true")
("format" . "json")
("formatversion" . "2")))]
[render
(response-handler
(define title (jp "/parse/title" wikipage prefixed-file))
(define body
(generate-results-page #:req req
#:source-url source-url
#:wikiname wikiname
#:title title
#:wikipage wikipage
#:imageinfo imageinfo
#: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-standard-handler (page-file-standard req)
#'file-endpoint
(when (equal? "missingtitle" (jp "/error/code" wikipage #f))
(next-dispatcher)))
(define-jsonp-handler (page-file-jsonp req)
#'file-endpoint)
(define-post-data-handler (page-file-with-data req)
#'file-endpoint
(define prefixed-file path))
(define page-file (make-switch-handler #:standard page-file-standard
#:jsonp page-file-jsonp
#:post page-file-with-data))
#;(define (page-file req)
(response-handler (response-handler
(define wikiname (path/param-path (first (url-path (request-uri 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 prefixed-title (path/param-path (caddr (url-path (request-uri req)))))
@ -147,28 +236,28 @@
(λ (out) (write-html body out))))))) (λ (out) (write-html body out)))))))
(module+ test (module+ test
(parameterize ([(config-parameter 'strict_proxy) "true"]) (parameterize ([(config-parameter 'strict_proxy) "true"])
(check-equal? (get-media-html "https://static.wikia.nocookie.net/a" "image/jpeg") (check-equal? (get-media-html "https://static.wikia.nocookie.net/a" (make-json '(: mediatype "BITMAP")))
`(img (@ (src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fa")))) `(img (@ (src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fa"))))
(check-equal? (get-media-html "https://static.wikia.nocookie.net/b" "audio/mp3") (check-equal? (get-media-html "https://static.wikia.nocookie.net/b" (make-json '(: mediatype "AUDIO")))
`(audio (@ (src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fb") `(audio (@ (src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fb")
(controls))))) (controls)))))
(parameterize ([(config-parameter 'strict_proxy) "false"]) (parameterize ([(config-parameter 'strict_proxy) "false"])
(check-equal? (get-media-html "https://static.wikia.nocookie.net/c" "application/ogg") (check-equal? (get-media-html "https://static.wikia.nocookie.net/c" (make-json '(: mediatype "AUDIO")))
`(audio (@ (src "https://static.wikia.nocookie.net/c") `(audio (@ (src "https://static.wikia.nocookie.net/c")
(controls)))) (controls))))
(check-equal? (get-media-html "https://static.wikia.nocookie.net/d" "video/mp4") (check-equal? (get-media-html "https://static.wikia.nocookie.net/d" (make-json '(: mediatype "VIDEO")))
`(video (@ (src "https://static.wikia.nocookie.net/d") `(video (@ (src "https://static.wikia.nocookie.net/d")
(controls))))) (controls)))))
(check-equal? (get-media-html "https://example.com" "who knows") `"") (check-equal? (get-media-html "https://example.com" "who knows") "")
(check-equal? (get-media-html "https://example.com" #f) `"")) (check-equal? (get-media-html "https://example.com" #f) ""))
(module+ test (module+ test
(parameterize ([(config-parameter 'strict_proxy) "true"]) (parameterize ([(config-parameter 'strict_proxy) "true"])
(check-not-false (check-not-false
((query-selector ((query-selector
(attribute-selector 'src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fexamplefile") (attribute-selector 'src "/proxy?dest=https%3A%2F%2Fstatic.wikia.nocookie.net%2Flgbtqia-sandbox%2Fimages%2Ff%2Ff8%2FRainbow_Flag1.svg%2Frevision%2Flatest%3Fcb%3D20250310072450")
(generate-results-page #:req test-req (generate-results-page #:req test-req
#:source-url "" #:source-url ""
#:wikiname "test" #:wikiname "test"
#:title "File:Example file" #:title "File:Example file"
#:media-detail test-media-detail #:wikipage test-wikipage
#:image-content-type "image/jpeg")))))) #:imageinfo test-imageinfo-outer))))))

View file

@ -23,8 +23,7 @@
page-search) page-search)
(define search-providers (define search-providers
(hash "fandom" search-fandom (hash "solr" search-solr))
"solr" search-solr))
;; this takes the info we gathered from fandom and makes the big fat x-expression page ;; 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]) (define (generate-results-page req source-url wikiname query results-content #:siteinfo [siteinfo #f])
@ -40,7 +39,7 @@
results-content)) results-content))
;; will be called when the web browser asks to load the page ;; will be called when the web browser asks to load the page
(define (page-search req) (define (page-search-solr req)
;; this just means, catch any errors and display them in the browser. it's a function somewhere else ;; this just means, catch any errors and display them in the browser. it's a function somewhere else
(response-handler (response-handler
;; the URL will look like "/minecraft/wiki/Special:Search?q=Spawner" ;; the URL will look like "/minecraft/wiki/Special:Search?q=Spawner"
@ -84,3 +83,9 @@
(λ (out) (λ (out)
(write-html body out))))) (write-html body out)))))
(define (page-search req)
(if (equal? (config-get 'feature_offline::search) "fandom")
(page-search-fandom req)
(page-search-solr req)))

View file

@ -1,11 +1,6 @@
#lang racket/base #lang racket/base
(require racket/dict (require racket/dict
racket/function
racket/list
racket/match
racket/string racket/string
; libs
(prefix-in easy: net/http-easy)
; html libs ; html libs
"../lib/html-parsing/main.rkt" "../lib/html-parsing/main.rkt"
html-writing html-writing
@ -15,30 +10,125 @@
web-server/dispatchers/dispatch web-server/dispatchers/dispatch
; my libs ; my libs
"application-globals.rkt" "application-globals.rkt"
"config.rkt" "endpoints.rkt"
"data.rkt"
"fandom-request.rkt"
"../lib/pure-utils.rkt"
"../lib/syntax.rkt"
"../lib/thread-utils.rkt"
"../lib/tree-updater.rkt" "../lib/tree-updater.rkt"
"../lib/url-utils.rkt" "../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt") "../lib/xexpr-utils.rkt")
(require (for-syntax racket/base syntax/parse))
(provide (provide
; used by the web server page-wiki)
page-wiki
; used by page-category, and similar pages that are partially wiki pages
update-tree-wiki
preprocess-html-wiki)
(module+ test (define-endpoint
(require rackunit)) wiki-endpoint
[variables
(define wikiname (path/param-path (car (url-path (request-uri req)))))
(define segments (map path/param-path (cdr (url-path (request-uri req)))))
(define title (url-segments->guess-title segments))
(define path (string-join (cdr segments) "/"))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path))]
[endpoints
(wikipage (("action" . "parse")
("page" . ,path)
("prop" . "text|headhtml|langlinks")
("formatversion" . "2")
("format" . "json")))
(siteinfo (("action" . "query")
("meta" . "siteinfo")
("siprop" . "general|rightsinfo")
("format" . "json")
("formatversion" . "2")))]
[render
(define page-html (preprocess-html-wiki (jp "/parse/text" wikipage "")))
(define page (html->xexp page-html))
(define head-data ((head-data-getter wikiname) wikipage))
(define body
(generate-wiki-page
(update-tree-wiki page wikiname)
#:req req
#:source-url source-url
#:wikiname wikiname
#:title (jp "/parse/title" wikipage "")
#: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 html (xexp->html-bytes body))
(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)))))
(response/full
200
#"OK"
(current-seconds)
#"text/html; charset=utf-8"
headers
(list html))])
(define (page-wiki req) (define-standard-handler (page-wiki-standard req)
#'wiki-endpoint
(when (equal? "missingtitle" (jp "/error/code" wikipage #f))
(next-dispatcher)))
(define-jsonp-handler (page-wiki-jsonp req)
#'wiki-endpoint)
(define-post-data-handler (page-wiki-with-data req)
#'wiki-endpoint)
(define page-wiki (make-switch-handler #:standard page-wiki-standard
#:jsonp page-wiki-jsonp
#:post page-wiki-with-data))
#;(define (page-wiki-with-data req)
(response-handler
(let/cc return
(define post-data/bytes (request-post-data/raw req))
(when (not post-data/bytes)
(k (response/jsexpr
#:code 400
#:headers always-headers
'#hasheq((error .
#hasheq((code . "breezewiki")
(info . "POST requests only, please.")))))))
(define origin-header
(or (headers-assq* #"origin" (request-headers/raw req))
(headers-assq* #"referer" (request-headers/raw req))))
(when (or (not origin-header) (not (string-prefix? (bytes->string/latin-1 (header-value origin-header)) (config-get 'canonical_origin))))
(k (response/jsexpr
#:code 400
#:headers always-headers
'#hasheq((error .
#hasheq((code . "breezewiki")
(info . "Origin/Referer header failed validation - cross-origin requests are not allowed here")))))))
(define post-data/string (bytes->string/utf-8 post-data/bytes))
(define post-data (string->jsexpr post-data/string))
(define wikiname (jp "/wikiname" post-data))
(define path (jp "/path" post-data))
(take-json-rewrite-and-return-page
#:req req
#:wikiname wikiname
#:source-url (format "https://~a.fandom.com/wiki/~a" wikiname path)
#:data (jp "/data" post-data)
#:siteinfo (data->siteinfo (jp "/siteinfo" post-data))))))
#;(define (page-wiki req)
(define wikiname (path/param-path (first (url-path (request-uri req))))) (define wikiname (path/param-path (first (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 user-cookies (user-cookies-getter req))
(define path (string-join (map path/param-path (cddr (url-path (request-uri req)))) "/")) (define path (string-join (cdr segments) "/"))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path)) (define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path))
(define-values (dest-res siteinfo) (define-values (dest-res siteinfo)
@ -57,54 +147,39 @@
(cond (cond
[(eq? 200 (easy:response-status-code dest-res)) [(eq? 200 (easy:response-status-code dest-res))
(let* ([data (easy:response-json 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)) (if (equal? "missingtitle" (jp "/error/code" data #f))
(next-dispatcher) (next-dispatcher)
(response-handler (take-json-rewrite-and-return-page data)))]
(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)) [(eq? 404 (easy:response-status-code dest-res))
(next-dispatcher)] (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 [else
(response-handler (response-handler
(error 'page-wiki "Tried to load page ~a/~v~nSadly, the page didn't load because Fandom returned status code ~a with response:~n~a" (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 wikiname
path path
(easy:response-status-code dest-res) (easy:response-status-code dest-res)
(easy:response-body dest-res)))])) (easy:response-body dest-res)))]))

View file

@ -1,59 +1,95 @@
#lang racket/base #lang racket/base
(require racket/string (require racket/dict
racket/string
(prefix-in easy: net/http-easy) (prefix-in easy: net/http-easy)
net/url
web-server/http
html-writing
"application-globals.rkt" "application-globals.rkt"
"config.rkt" "config.rkt"
"endpoints.rkt"
"fandom-request.rkt" "fandom-request.rkt"
"../lib/url-utils.rkt" "../lib/url-utils.rkt"
"../lib/xexpr-utils.rkt") "../lib/xexpr-utils.rkt")
(provide (provide
search-fandom) page-search-fandom)
(module+ test (define-endpoint
(require rackunit search-endpoint
"test-utils.rkt") [variables
(define search-results-data (define wikiname (path/param-path (car (url-path (request-uri req)))))
'(#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 params (url-query (request-uri req)))
(define query (dict-ref params 'q #f))
(define title "Search")
(define path (format "Special:Search?~a" (params->query `(("query" . ,query)
("search" . "internal")))))
(define source-url (format "https://~a.fandom.com/wiki/~a" wikiname path))]
[endpoints
(search
(("action" . "query")
("list" . "search")
("srsearch" . ,query)
("formatversion" . "2")
("format" . "json")))
(siteinfo
(("action" . "query")
("meta" . "siteinfo")
("siprop" . "general|rightsinfo")
("format" . "json")
("formatversion" . "2")))]
[render
(define search-results (jp "/query/search" search))
(define body
(generate-wiki-page
#:req req
#:source-url source-url
#:wikiname wikiname
#:title query
#:siteinfo siteinfo
`(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)))))))))))
(when (config-true? 'debug)
(xexp->html body))
(response/output
#:code 200
#:headers (build-headers always-headers)
(λ (out)
(write-html body out)))])
(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-standard-handler (page-search-standard req)
(define (generate-results-content-fandom wikiname query search-results) #'search-endpoint)
`(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 (define-jsonp-handler (page-search-jsonp req)
(parameterize ([(config-parameter 'feature_offline::only) "false"]) #'search-endpoint)
(check-not-false ((query-selector (attribute-selector 'href "/test/wiki/Gacha_Capsule")
(generate-results-content-fandom "test" "Gacha" search-results-data)))))) (define-post-data-handler (page-search-with-data req)
#'search-endpoint
(define params (url-query (request-uri req)))
(define query (dict-ref params 'q #f))
(define title "Search"))
(define page-search-fandom
(make-switch-handler #:standard page-search-standard
#:jsonp page-search-jsonp
#:post page-search-with-data))

View file

@ -1,16 +1,20 @@
#lang typed/racket/base #lang typed/racket/base
(require racket/path (require racket/path
racket/runtime-path racket/runtime-path
racket/string) racket/string
typed/json)
(provide (provide
get-static-url get-static-url
importmap
link-header) link-header)
(define-runtime-path path-static "../static") (define-runtime-path path-static "../static")
(define static-data (define static-data
(for/hash : (Immutable-HashTable Path Nonnegative-Integer)([f (directory-list path-static)]) (for/hash : (Immutable-HashTable Path Nonnegative-Integer)
([f (directory-list path-static)]
#:when (not (regexp-match? #rx"^.#|^#|~$" (path->string f))))
(define built (simple-form-path (build-path path-static f))) (define built (simple-form-path (build-path path-static f)))
(values built (file-or-directory-modify-seconds built)))) (values built (file-or-directory-modify-seconds built))))
@ -21,12 +25,21 @@
(build-path path-static path-or-filename)))) (build-path path-static path-or-filename))))
(format "/static/~a?t=~a" (file-name-from-path the-path) (hash-ref static-data the-path))) (format "/static/~a?t=~a" (file-name-from-path the-path) (hash-ref static-data the-path)))
(: importmap String)
(define importmap
(jsexpr->string
`#hasheq((imports . ,(for/hasheq : (Immutable-HashTable Symbol String)
([(k v) (in-hash static-data)]
#:when (equal? (path-get-extension k) #".js"))
(values (string->symbol (path->string (path-replace-extension (assert (file-name-from-path k) path?) #"")))
(get-static-url k)))))))
; https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload ; https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload
(: link-header String) (: link-header String)
(define link-header (define link-header
(let* ([with-t '(("main.css" "as=style"))] (let* ([with-t '(("main.css" "as=style")
[without-t '(("preact.js" "as=script") ("preact.js" "as=script"))]
("source-sans-pro-v21-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2" "as=font" "crossorigin" "type=font/woff2"))] [without-t '(("source-sans-pro-v21-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2" "as=font" "crossorigin" "type=font/woff2"))]
[with-t-full (map (λ ([path : (Listof String)]) (cons (get-static-url (car path)) (cdr path))) with-t)] [with-t-full (map (λ ([path : (Listof String)]) (cons (get-static-url (car path)) (cdr path))) with-t)]
[without-t-full (map (λ ([path : (Listof String)]) (cons (format "/static/~a" (car path)) (cdr path))) without-t)] [without-t-full (map (λ ([path : (Listof String)]) (cons (format "/static/~a" (car path)) (cdr path))) without-t)]
[all (append with-t-full without-t-full)] [all (append with-t-full without-t-full)]

24
static/captcha.js Normal file
View file

@ -0,0 +1,24 @@
const u = new URL(location)
const from = u.searchParams.get("from") || location.href
let answered = false
const area = document.getElementById("captcha-area")
const areaBox = area.getBoundingClientRect()
const width = Math.floor(areaBox.width)
const height = Math.floor(window.innerHeight - areaBox.bottom - areaBox.left)
const img = document.createElement("img")
img.src = `/captcha/img/${width}/${height}`
img.addEventListener("click", event => {
if (answered) return
answered = true
location = `/captcha/verify/${width}/${height}/${event.offsetX}/${event.offsetY}?` + new URLSearchParams({from})
})
area.appendChild(img)
document.addEventListener("keydown", event => {
if (event.repeat) {
if (answered) return
answered = true
location = `/captcha/verify/0/0/${event.key}/0?` + new URLSearchParams({from})
}
})

View file

@ -2,7 +2,7 @@
// sample: bandori/wiki/BanG_Dream!_Wikia // sample: bandori/wiki/BanG_Dream!_Wikia
// sample: ensemble-stars/wiki/The_English_Ensemble_Stars_Wiki // sample: ensemble-stars/wiki/The_English_Ensemble_Stars_Wiki
import {h, htm, render, signal, computed, effect} from "./preact.js" import {h, htm, render, signal, computed, effect} from "preact"
const html = htm.bind(h) const html = htm.bind(h)
const now = signal(Date.now()) const now = signal(Date.now())

140
static/jsonp.js Normal file
View file

@ -0,0 +1,140 @@
import {h, htm, render, signal, computed, effect} from "preact"
const html = htm.bind(h)
// *** Status
const loaded = signal(false)
export {loaded}
// *** Loading indicator
render(html`Loading, please wait...`, document.getElementById("loading"))
// *** Progress bar
const progress = signal(null)
const progressBar = document.getElementById("progress-bar")
while (progressBar.childNodes[0] !== undefined) progressBar.childNodes[0].remove() // clear out loading indicators
render(html`<progress value="${progress}" max="1"></progress>`, progressBar)
// *** Incoming data processing
// Handle case where data is immediately available
cont()
// Handle case where data may become available in the future
window.proxy = new Proxy(jsonpData, {
get(obj, prop) {
return value => {
obj[prop] = value
cont()
}
}
})
// *** Data upload and download
async function cont() {
// Check for errors
for (const v of Object.values(jsonpData)) {
if (v.error) return error(v)
}
// Check for completion
const dependencies = [...document.querySelectorAll("[data-jsonp-var]")].map(e => e.getAttribute("data-jsonp-var"))
for (const d of dependencies) {
if (!(d in jsonpData)) return
}
const xhr = new XMLHttpRequest();
const uploadFraction = 0.7
const pkg = {
url: location.href,
init: {
method: "POST",
body: JSON.stringify({
wikiname: BWData.wikiname,
path: BWData.path,
...jsonpData
})
}
}
// Upload progress
xhr.upload.addEventListener("progress", event => {
if (event.lengthComputable) {
progress.value = (event.loaded / event.total) * uploadFraction
}
})
// Download progress
xhr.addEventListener("progress", event => {
if (event.lengthComputable) {
progress.value = (event.loaded / event.total) * (1 - uploadFraction) + uploadFraction
}
})
xhr.addEventListener("load", () => {
console.log(xhr)
// check for errors
if (xhr.status === 500) return fetch(pkg.url, pkg.init).then(res => res.text()).then(error)
// check for captcha screen
if (xhr.responseXML.head.querySelector('script[src*="captcha.js"]')) return location.reload()
// page -> #content
const imported = document.importNode(xhr.responseXML.getElementById("content"), true)
document.getElementById("content").replaceWith(imported)
// page theme
// Try to make it a bit more accurate. Normally this is done server-side by sending a `theme=x` cookie with the parse request. But that's not possible in jsonp mode, and there's no equivalent URL query string to set the theme.
// Helps on Minecraft wiki. Might not be complete on some wikis.
cookieStore.get("theme").then(cookie => {
if (cookie && cookie.value !== "default") {
document.body.classList.remove("theme-fandomdesktop-light")
document.body.classList.remove("theme-fandomdesktop-dark")
document.body.removeAttribute("data-theme")
document.body.classList.add(`theme-fandomdesktop-${cookie.value}`)
document.body.setAttribute("data-theme", cookie.value)
}
})
// <head>
document.title = xhr.responseXML.title
document.body.className = xhr.responseXML.body.className
for (const e of xhr.responseXML.head.children) {
const alreadyImported = [...document.querySelectorAll("link[href]")].map(e => e.href)
if (e.tagName === "LINK" && !alreadyImported.includes(e.href)) {
const imported = document.importNode(e, true)
document.head.appendChild(imported)
}
}
// scroll
if (location.hash) {
document.getElementById(location.hash.slice(1)).scrollIntoView({behavior: "instant"})
}
// redirects
const redirectTo = document.querySelector("#content .redirectMsg a")
if (redirectTo) {
redirectTo.click()
}
loaded.value = true
})
xhr.open(pkg.init.method, pkg.url)
xhr.responseType = "document"
xhr.send(pkg.init.body);
}
function error(data) {
const eContent = document.getElementById("content")
while (eContent.childNodes[0] !== undefined) eContent.childNodes[0].remove() // clear out loading indicators
document.title = `Error | BreezeWiki`
if (typeof data === "string") {
render(html`<p><strong>BreezeWiki ran into an error on this page.</strong></p><p>Try reloading the page.</p><p>If this keeps happening, you could <a href="mailto:~cadence/breezewiki-discuss@lists.sr.ht">send a public bug report</a>. Please include the following information:</p><pre>URL: ${window.location.href}${"\n"}${data}</pre>`, eContent)
} else if (data.error.code === "missingtitle") {
render(html`<p><strong>This page doesn't exist on Fandom.</strong></p><p><small><a href="/${BWData.wikiname}/wiki/Main_Page">Return to the wiki's main page</a></small></p>`, eContent)
} else {
render(html`<p>BreezeWiki wasn't able to load this page.</p><p><strong>${data.error.code}: ${data.error.info}</strong></p>`, eContent)
}
}

View file

@ -120,6 +120,9 @@ p {
background-color: var(--theme-page-background-color); background-color: var(--theme-page-background-color);
padding: 3vw; padding: 3vw;
} }
.fandom-community-header__background {
transform: none; /* fandom offsets this 46px by default due to their position: fixed top bar */
}
/* table of contents */ /* table of contents */
.toc { .toc {
@ -174,13 +177,13 @@ img {
} }
/* indicate wikipedia links */ /* indicate wikipedia links */
.extiw::after, .external::after { svg.external {
vertical-align: super;
}
.external[href*="wikipedia.org"]::after, .extiw[href*="wikipedia.org"]::after {
vertical-align: super; vertical-align: super;
content: "[🡕]";
font-family: serif; font-family: serif;
font-size: smaller; font-size: smaller;
}
.extiw[href*="wikipedia.org"]::after {
content: "[W]"; content: "[W]";
} }
@ -288,7 +291,6 @@ a.ext-audiobutton { /* see hearthstone/wiki/Diablo_(Duels_hero) */
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
background: white; background: white;
color: black;
border: solid #808080; border: solid #808080;
border-width: 0px 1px 1px; border-width: 0px 1px 1px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5); box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
@ -325,6 +327,7 @@ a.ext-audiobutton { /* see hearthstone/wiki/Diablo_(Duels_hero) */
border: none; border: none;
margin: 0; margin: 0;
line-height: inherit; line-height: inherit;
color: black;
background: none; background: none;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
@ -500,6 +503,10 @@ a.ext-audiobutton { /* see hearthstone/wiki/Diablo_(Duels_hero) */
.page-title { .page-title {
color: var(--theme-body-text-color); color: var(--theme-body-text-color);
} }
/* don't squeeze text alongside infoboxes */
.infobox, .portable-infobox {
float: none;
}
} }
/* ***** /* *****

View file

@ -2,3 +2,4 @@ User-Agent: *
Disallow: /*/wiki/* Disallow: /*/wiki/*
Disallow: /proxy Disallow: /proxy
Disallow: /set-user-settings Disallow: /set-user-settings
Disallow: /captcha

View file

@ -1,4 +1,4 @@
import {h, htm, render, signal, computed, effect} from "./preact.js" import {h, htm, render, signal, computed, effect, useSignalEffect} from "preact"
const html = htm.bind(h) const html = htm.bind(h)
const classNames = classArr => classArr.filter(el => el).join(" ") const classNames = classArr => classArr.filter(el => el).join(" ")
@ -13,6 +13,7 @@ const query = signal("")
const focus = signal(false) const focus = signal(false)
const st = signal("ready") const st = signal("ready")
const suggestions = signal([]) const suggestions = signal([])
const enterWasLastKey = signal(false)
// processing functions // processing functions
@ -49,13 +50,25 @@ function acceptSuggestion(hit) {
// suggestion list view // suggestion list view
function Suggestion(hit) { function Suggestion(hit) {
useSignalEffect(() => {
if (enterWasLastKey.value && st.value === "ready") {
enterWasLastKey.value.preventDefault()
acceptSuggestion(hit)
}
})
return html`<li class="bw-ss__item"><button type="button" class="bw-ss__button" onClick=${() => acceptSuggestion(hit)}>${hit.title}</button></li>` return html`<li class="bw-ss__item"><button type="button" class="bw-ss__button" onClick=${() => acceptSuggestion(hit)}>${hit.title}</button></li>`
} }
function DefaultSearch({q}) {
if (!q) return ""
return html`<li class="bw-ss__item"><button type="submit" class="bw-ss__button">Results for "${q}"</button></li>`
}
function SuggestionList() { function SuggestionList() {
return html` return html`
<ul class=${classNames(["bw-ss__list", focus.value && "bw-ss__list--focus", `bw-ss__list--${st.value}`])}> <ul class=${classNames(["bw-ss__list", focus.value && "bw-ss__list--focus", `bw-ss__list--${st.value}`])}>
${suggestions.value.map(hit => html`<${Suggestion} ...${hit} />`)} ${suggestions.value.map(hit => html`<${Suggestion} ...${hit} />`)}
<${DefaultSearch} q=${query.value} />
</ul>` </ul>`
} }
@ -79,9 +92,15 @@ window.addEventListener("pageshow", () => {
st.value = "ready" // unlock results from changing after returning to page st.value = "ready" // unlock results from changing after returning to page
}) })
effect(() => {
if (enterWasLastKey.value && (st.value === "loading" || st.value === "accepted")) {
enterWasLastKey.value.preventDefault() // wait for results before going
}
})
function SuggestionInput() { function SuggestionInput() {
return html` return html`
<input type="text" name="q" id="bw-search-input" autocomplete="off" onInput=${e => query.value = e.target.value} value=${query.value} class=${classNames(["bw-ss__input", `bw-ss__input--${st.value}`])} />` <input type="text" name="q" id="bw-search-input" autocomplete="off" onInput=${e => query.value = e.target.value} onKeyDown=${e => enterWasLastKey.value = e.key === "Enter" && e} value=${query.value} class=${classNames(["bw-ss__input", `bw-ss__input--${st.value}`])} />`
} }
render(html`<${SuggestionInput} />`, eInput) render(html`<${SuggestionInput} />`, eInput)

View file

@ -1,19 +1,22 @@
"use strict"; import {effect} from "preact"
import {loaded} from "jsonp"
const tabFromHash = location.hash.length > 1 ? location.hash.substring(1) : null const tabFromHash = location.hash.length > 1 ? location.hash.substring(1) : null
for (const tabber of document.body.querySelectorAll(".wds-tabber")) { function setUpAllTabs() {
for (const [tab, content] of getTabberTabs(tabber)) { for (const tabber of document.body.querySelectorAll(".wds-tabber")) {
// set up click listener on every tab for (const [tab, content] of getTabberTabs(tabber)) {
tab.addEventListener("click", e => { // set up click listener on every tab
setCurrentTab(tabber, tab, content) tab.addEventListener("click", e => {
e.preventDefault() setCurrentTab(tabber, tab, content)
}) e.preventDefault()
})
// re-open a specific tab on page load based on the URL hash // re-open a specific tab on page load based on the URL hash
if (tab.dataset.hash === tabFromHash) { if (tab.dataset.hash === tabFromHash) {
setCurrentTab(tabber, tab, content) setCurrentTab(tabber, tab, content)
tab.scrollIntoView() tab.scrollIntoView()
}
} }
} }
} }
@ -37,4 +40,16 @@ function setCurrentTab(tabber, tab, content) {
} }
} }
if (!BWData.jsonp) {
setUpAllTabs()
} else if (loaded.value) {
setUpAllTabs()
} else {
effect(() => {
if (loaded.value) {
setUpAllTabs()
}
})
}
document.body.classList.remove("bw-tabs-nojs") document.body.classList.remove("bw-tabs-nojs")