HTTP Client
1
Total requests
0
HTTP errors
Clients
http_client 1
Requests
| GET | https://johnfactotum.github.io/foliate-js/view.js | |
|---|---|---|
| Response |
200
[ "info" => [ "start_time" => 1768903721.7818 "connect_time" => 0.072431087493896 "pretransfer_time" => 0.19158005714417 "starttransfer_time" => 0.19142317771912 "total_time" => 0.1924090385437 "namelookup_time" => 0.0093581676483154 "size_download" => 5684 "primary_ip" => "185.199.109.153" "primary_port" => 443 "debug" => """ * Enable the curl extension for better performance\n * Hostname was NOT found in DNS cache\n * Added johnfactotum.github.io:0:185.199.109.153 to DNS cache\n * Trying 185.199.109.153...\n > GET /foliate-js/view.js HTTP/1.1 \r\n Accept: */*\r\n Accept-Encoding: gzip\r\n User-Agent: Symfony HttpClient (Native)\r\n Host: johnfactotum.github.io\r\n \r\n < HTTP/1.1 200 OK\r\n < Connection: close\r\n < Content-Length: 5684\r\n < Server: GitHub.com\r\n < Content-Type: application/javascript; charset=utf-8\r\n < Last-Modified: Fri, 28 Nov 2025 22:40:32 GMT\r\n < Access-Control-Allow-Origin: *\r\n < Strict-Transport-Security: max-age=31556952\r\n < ETag: W/"692a24e0-5894"\r\n < expires: Tue, 20 Jan 2026 10:18:41 GMT\r\n < Cache-Control: max-age=600\r\n < Content-Encoding: gzip\r\n < x-proxy-cache: MISS\r\n < X-GitHub-Request-Id: 0EA6:1A4CBD:408935A:41A4EF2:696F5428\r\n < Accept-Ranges: bytes\r\n < Age: 0\r\n < Date: Tue, 20 Jan 2026 10:08:41 GMT\r\n < Via: 1.1 varnish\r\n < X-Served-By: cache-fra-etou8220159-FRA\r\n < X-Cache: MISS\r\n < X-Cache-Hits: 0\r\n < X-Timer: S1768903722.856273,VS0,VE115\r\n < Vary: Accept-Encoding\r\n < X-Fastly-Request-ID: 0d4fd2eef7c0133b43d84c594d1ce28dd4150439\r\n < \r\n """ "original_url" => "https://johnfactotum.github.io/foliate-js/view.js" "pause_handler" => Closure(float $duration) {#653 : "Symfony\Component\HttpClient\Response\NativeResponse" : { : 0.0 } } ] "response_headers" => [ "HTTP/1.1 200 OK" "Connection: close" "Content-Length: 5684" "Server: GitHub.com" "Content-Type: application/javascript; charset=utf-8" "Last-Modified: Fri, 28 Nov 2025 22:40:32 GMT" "Access-Control-Allow-Origin: *" "Strict-Transport-Security: max-age=31556952" "ETag: W/"692a24e0-5894"" "expires: Tue, 20 Jan 2026 10:18:41 GMT" "Cache-Control: max-age=600" "Content-Encoding: gzip" "x-proxy-cache: MISS" "X-GitHub-Request-Id: 0EA6:1A4CBD:408935A:41A4EF2:696F5428" "Accept-Ranges: bytes" "Age: 0" "Date: Tue, 20 Jan 2026 10:08:41 GMT" "Via: 1.1 varnish" "X-Served-By: cache-fra-etou8220159-FRA" "X-Cache: MISS" "X-Cache-Hits: 0" "X-Timer: S1768903722.856273,VS0,VE115" "Vary: Accept-Encoding" "X-Fastly-Request-ID: 0d4fd2eef7c0133b43d84c594d1ce28dd4150439" ] "response_content" => [ """ import * as CFI from './epubcfi.js'\n import { TOCProgress, SectionProgress } from './progress.js'\n import { Overlayer } from './overlayer.js'\n import { textWalker } from './text-walker.js'\n \n const SEARCH_PREFIX = 'foliate-search:'\n \n const isZip = async file => {\n const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())\n return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04\n }\n \n const isPDF = async file => {\n const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())\n return arr[0] === 0x25\n && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46\n && arr[4] === 0x2d\n }\n \n const isCBZ = ({ name, type }) =>\n type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')\n \n const isFB2 = ({ name, type }) =>\n type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')\n \n const isFBZ = ({ name, type }) =>\n type === 'application/x-zip-compressed-fb2'\n || name.endsWith('.fb2.zip') || name.endsWith('.fbz')\n \n const makeZipLoader = async file => {\n const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =\n await import('./vendor/zip.js')\n configure({ useWebWorkers: false })\n const reader = new ZipReader(new BlobReader(file))\n const entries = await reader.getEntries()\n const map = new Map(entries.map(entry => [entry.filename, entry]))\n const load = f => (name, ...args) =>\n map.has(name) ? f(map.get(name), ...args) : null\n const loadText = load(entry => entry.getData(new TextWriter()))\n const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))\n const getSize = name => map.get(name)?.uncompressedSize ?? 0\n return { entries, loadText, loadBlob, getSize }\n }\n \n const getFileEntries = async entry => entry.isFile ? entry\n : (await Promise.all(Array.from(\n await new Promise((resolve, reject) => entry.createReader()\n .readEntries(entries => resolve(entries), error => reject(error))),\n getFileEntries))).flat()\n \n const makeDirectoryLoader = async entry => {\n const entries = await getFileEntries(entry)\n const files = await Promise.all(\n entries.map(entry => new Promise((resolve, reject) =>\n entry.file(file => resolve([file, entry.fullPath]),\n error => reject(error)))))\n const map = new Map(files.map(([file, path]) =>\n [path.replace(entry.fullPath + '/', ''), file]))\n const decoder = new TextDecoder()\n const decode = x => x ? decoder.decode(x) : null\n const getBuffer = name => map.get(name)?.arrayBuffer() ?? null\n const loadText = async name => decode(await getBuffer(name))\n const loadBlob = name => map.get(name)\n const getSize = name => map.get(name)?.size ?? 0\n return { loadText, loadBlob, getSize }\n }\n \n export class ResponseError extends Error {}\n export class NotFoundError extends Error {}\n export class UnsupportedTypeError extends Error {}\n \n const fetchFile = async url => {\n const res = await fetch(url)\n if (!res.ok) throw new ResponseError(\n `${res.status} ${res.statusText}`, { cause: res })\n return new File([await res.blob()], new URL(res.url).pathname)\n }\n \n export const makeBook = async file => {\n if (typeof file === 'string') file = await fetchFile(file)\n let book\n if (file.isDirectory) {\n const loader = await makeDirectoryLoader(file)\n const { EPUB } = await import('./epub.js')\n book = await new EPUB(loader).init()\n }\n else if (!file.size) throw new NotFoundError('File not found')\n else if (await isZip(file)) {\n const loader = await makeZipLoader(file)\n if (isCBZ(file)) {\n const { makeComicBook } = await import('./comic-book.js')\n book = makeComicBook(loader, file)\n }\n else if (isFBZ(file)) {\n const { makeFB2 } = await import('./fb2.js')\n const { entries } = loader\n const entry = entries.find(entry => entry.filename.endsWith('.fb2'))\n const blob = await loader.loadBlob((entry ?? entries[0]).filename)\n book = await makeFB2(blob)\n }\n else {\n const { EPUB } = await import('./epub.js')\n book = await new EPUB(loader).init()\n }\n }\n else if (await isPDF(file)) {\n const { makePDF } = await import('./pdf.js')\n book = await makePDF(file)\n }\n else {\n const { isMOBI, MOBI } = await import('./mobi.js')\n if (await isMOBI(file)) {\n const fflate = await import('./vendor/fflate.js')\n book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)\n }\n else if (isFB2(file)) {\n const { makeFB2 } = await import('./fb2.js')\n book = await makeFB2(file)\n }\n }\n if (!book) throw new UnsupportedTypeError('File type not supported')\n return book\n }\n \n class CursorAutohider {\n #timeout\n #el\n #check\n #state\n constructor(el, check, state = {}) {\n this.#el = el\n this.#check = check\n this.#state = state\n if (this.#state.hidden) this.hide()\n this.#el.addEventListener('mousemove', ({ screenX, screenY }) => {\n // check if it actually moved\n if (screenX === this.#state.x && screenY === this.#state.y) return\n this.#state.x = screenX, this.#state.y = screenY\n this.show()\n if (this.#timeout) clearTimeout(this.#timeout)\n if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000)\n }, false)\n }\n cloneFor(el) {\n return new CursorAutohider(el, this.#check, this.#state)\n }\n hide() {\n this.#el.style.cursor = 'none'\n this.#state.hidden = true\n }\n show() {\n this.#el.style.removeProperty('cursor')\n this.#state.hidden = false\n }\n }\n \n class History extends EventTarget {\n #arr = []\n #index = -1\n pushState(x) {\n const last = this.#arr[this.#index]\n if (last === x || last?.fraction && last.fraction === x.fraction) return\n this.#arr[++this.#index] = x\n this.#arr.length = this.#index + 1\n this.dispatchEvent(new Event('index-change'))\n }\n replaceState(x) {\n const index = this.#index\n this.#arr[index] = x\n }\n back() {\n const index = this.#index\n if (index <= 0) return\n const detail = { state: this.#arr[index - 1] }\n this.#index = index - 1\n this.dispatchEvent(new CustomEvent('popstate', { detail }))\n this.dispatchEvent(new Event('index-change'))\n }\n forward() {\n const index = this.#index\n if (index >= this.#arr.length - 1) return\n const detail = { state: this.#arr[index + 1] }\n this.#index = index + 1\n this.dispatchEvent(new CustomEvent('popstate', { detail }))\n this.dispatchEvent(new Event('index-change'))\n }\n get canGoBack() {\n return this.#index > 0\n }\n get canGoForward() {\n return this.#index < this.#arr.length - 1\n }\n clear() {\n this.#arr = []\n this.#index = -1\n }\n }\n \n const languageInfo = lang => {\n if (!lang) return {}\n try {\n const canonical = Intl.getCanonicalLocales(lang)[0]\n const locale = new Intl.Locale(canonical)\n const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)\n const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction\n return { canonical, locale, isCJK, direction }\n } catch (e) {\n console.warn(e)\n return {}\n }\n }\n \n export class View extends HTMLElement {\n #root = this.attachShadow({ mode: 'closed' })\n #sectionProgress\n #tocProgress\n #pageProgress\n #searchResults = new Map()\n #cursorAutohider = new CursorAutohider(this, () =>\n this.hasAttribute('autohide-cursor'))\n isFixedLayout = false\n lastLocation\n history = new History()\n constructor() {\n super()\n this.history.addEventListener('popstate', ({ detail }) => {\n const resolved = this.resolveNavigation(detail.state)\n this.renderer.goTo(resolved)\n })\n }\n async open(book) {\n if (typeof book === 'string'\n || typeof book.arrayBuffer === 'function'\n || book.isDirectory) book = await makeBook(book)\n this.book = book\n this.language = languageInfo(book.metadata?.language)\n \n if (book.splitTOCHref && book.getTOCFragment) {\n const ids = book.sections.map(s => s.id)\n this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)\n const splitHref = book.splitTOCHref.bind(book)\n const getFragment = book.getTOCFragment.bind(book)\n this.#tocProgress = new TOCProgress()\n await this.#tocProgress.init({\n toc: book.toc ?? [], ids, splitHref, getFragment })\n this.#pageProgress = new TOCProgress()\n await this.#pageProgress.init({\n toc: book.pageList ?? [], ids, splitHref, getFragment })\n }\n \n this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'\n if (this.isFixedLayout) {\n await import('./fixed-layout.js')\n this.renderer = document.createElement('foliate-fxl')\n } else {\n await import('./paginator.js')\n this.renderer = document.createElement('foliate-paginator')\n }\n this.renderer.setAttribute('exportparts', 'head,foot,filter')\n this.renderer.addEventListener('load', e => this.#onLoad(e.detail))\n this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))\n this.renderer.addEventListener('create-overlayer', e =>\n e.detail.attach(this.#createOverlayer(e.detail)))\n this.renderer.open(book)\n this.#root.append(this.renderer)\n \n if (book.sections.some(section => section.mediaOverlay)) {\n const activeClass = book.media.activeClass\n const playbackActiveClass = book.media.playbackActiveClass\n this.mediaOverlay = book.getMediaOverlay()\n let lastActive\n this.mediaOverlay.addEventListener('highlight', e => {\n const resolved = this.resolveNavigation(e.detail.text)\n this.renderer.goTo(resolved)\n .then(() => {\n const { doc } = this.renderer.getContents()\n .find(x => x.index = resolved.index)\n const el = resolved.anchor(doc)\n el.classList.add(activeClass)\n if (playbackActiveClass) el.ownerDocument\n .documentElement.classList.add(playbackActiveClass)\n lastActive = new WeakRef(el)\n })\n })\n this.mediaOverlay.addEventListener('unhighlight', () => {\n const el = lastActive?.deref()\n if (el) {\n el.classList.remove(activeClass)\n if (playbackActiveClass) el.ownerDocument\n .documentElement.classList.remove(playbackActiveClass)\n }\n })\n }\n }\n close() {\n this.renderer?.destroy()\n this.renderer?.remove()\n this.#sectionProgress = null\n this.#tocProgress = null\n this.#pageProgress = null\n this.#searchResults = new Map()\n this.lastLocation = null\n this.history.clear()\n this.tts = null\n this.mediaOverlay = null\n }\n goToTextStart() {\n return this.goTo(this.book.landmarks\n ?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))\n ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))\n }\n async init({ lastLocation, showTextStart }) {\n const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null\n if (resolved) {\n await this.renderer.goTo(resolved)\n this.history.pushState(lastLocation)\n }\n else if (showTextStart) await this.goToTextStart()\n else {\n this.history.pushState(0)\n await this.next()\n }\n }\n #emit(name, detail, cancelable) {\n return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))\n }\n #onRelocate({ reason, range, index, fraction, size }) {\n const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}\n const tocItem = this.#tocProgress?.getProgress(index, range)\n const pageItem = this.#pageProgress?.getProgress(index, range)\n const cfi = this.getCFI(index, range)\n this.lastLocation = { ...progress, tocItem, pageItem, cfi, range }\n if (reason === 'snap' || reason === 'page' || reason === 'scroll')\n this.history.replaceState(cfi)\n this.#emit('relocate', this.lastLocation)\n }\n #onLoad({ doc, index }) {\n // set language and dir if not already set\n doc.documentElement.lang ||= this.language.canonical ?? ''\n if (!this.language.isCJK)\n doc.documentElement.dir ||= this.language.direction ?? ''\n \n this.#handleLinks(doc, index)\n this.#cursorAutohider.cloneFor(doc.documentElement)\n \n this.#emit('load', { doc, index })\n }\n #handleLinks(doc, index) {\n const { book } = this\n const section = book.sections[index]\n doc.addEventListener('click', e => {\n const a = e.target.closest('a[href]')\n if (!a) return\n e.preventDefault()\n const href_ = a.getAttribute('href')\n const href = section?.resolveHref?.(href_) ?? href_\n if (book?.isExternal?.(href))\n Promise.resolve(this.#emit('external-link', { a, href }, true))\n .then(x => x ? globalThis.open(href, '_blank') : null)\n .catch(e => console.error(e))\n else Promise.resolve(this.#emit('link', { a, href }, true))\n .then(x => x ? this.goTo(href) : null)\n .catch(e => console.error(e))\n })\n }\n async addAnnotation(annotation, remove) {\n const { value } = annotation\n if (value.startsWith(SEARCH_PREFIX)) {\n const cfi = value.replace(SEARCH_PREFIX, '')\n const { index, anchor } = await this.resolveNavigation(cfi)\n const obj = this.#getOverlayer(index)\n if (obj) {\n const { overlayer, doc } = obj\n if (remove) {\n overlayer.remove(value)\n return\n }\n const range = doc ? anchor(doc) : anchor\n overlayer.add(value, range, Overlayer.outline)\n }\n return\n }\n const { index, anchor } = await this.resolveNavigation(value)\n const obj = this.#getOverlayer(index)\n if (obj) {\n const { overlayer, doc } = obj\n overlayer.remove(value)\n if (!remove) {\n const range = doc ? anchor(doc) : anchor\n const draw = (func, opts) => overlayer.add(value, range, func, opts)\n this.#emit('draw-annotation', { draw, annotation, doc, range })\n }\n }\n const label = this.#tocProgress.getProgress(index)?.label ?? ''\n return { index, label }\n }\n deleteAnnotation(annotation) {\n return this.addAnnotation(annotation, true)\n }\n #getOverlayer(index) {\n return this.renderer.getContents()\n .find(x => x.index === index && x.overlayer)\n }\n #createOverlayer({ doc, index }) {\n const overlayer = new Overlayer()\n doc.addEventListener('click', e => {\n const [value, range] = overlayer.hitTest(e)\n if (value && !value.startsWith(SEARCH_PREFIX)) {\n this.#emit('show-annotation', { value, index, range })\n }\n }, false)\n \n const list = this.#searchResults.get(index)\n if (list) for (const item of list) this.addAnnotation(item)\n \n this.#emit('create-overlay', { index })\n return overlayer\n }\n async showAnnotation(annotation) {\n const { value } = annotation\n const resolved = await this.goTo(value)\n if (resolved) {\n const { index, anchor } = resolved\n const { doc } = this.#getOverlayer(index)\n const range = anchor(doc)\n this.#emit('show-annotation', { value, index, range })\n }\n }\n getCFI(index, range) {\n const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)\n if (!range) return baseCFI\n return CFI.joinIndir(baseCFI, CFI.fromRange(range))\n }\n resolveCFI(cfi) {\n if (this.book.resolveCFI)\n return this.book.resolveCFI(cfi)\n else {\n const parts = CFI.parse(cfi)\n const index = CFI.fake.toIndex((parts.parent ?? parts).shift())\n const anchor = doc => CFI.toRange(doc, parts)\n return { index, anchor }\n }\n }\n resolveNavigation(target) {\n try {\n if (typeof target === 'number') return { index: target }\n if (typeof target.fraction === 'number') {\n const [index, anchor] = this.#sectionProgress.getSection(target.fraction)\n return { index, anchor }\n }\n if (CFI.isCFI.test(target)) return this.resolveCFI(target)\n return this.book.resolveHref(target)\n } catch (e) {\n console.error(e)\n console.error(`Could not resolve target ${target}`)\n }\n }\n async goTo(target) {\n const resolved = this.resolveNavigation(target)\n try {\n await this.renderer.goTo(resolved)\n this.history.pushState(target)\n return resolved\n } catch(e) {\n console.error(e)\n console.error(`Could not go to ${target}`)\n }\n }\n async goToFraction(frac) {\n const [index, anchor] = this.#sectionProgress.getSection(frac)\n await this.renderer.goTo({ index, anchor })\n this.history.pushState({ fraction: frac })\n }\n async select(target) {\n try {\n const obj = await this.resolveNavigation(target)\n await this.renderer.goTo({ ...obj, select: true })\n this.history.pushState(target)\n } catch(e) {\n console.error(e)\n console.error(`Could not go to ${target}`)\n }\n }\n deselect() {\n for (const { doc } of this.renderer.getContents())\n doc.defaultView.getSelection().removeAllRanges()\n }\n getSectionFractions() {\n return (this.#sectionProgress?.sectionFractions ?? [])\n .map(x => x + Number.EPSILON)\n }\n getProgressOf(index, range) {\n const tocItem = this.#tocProgress?.getProgress(index, range)\n const pageItem = this.#pageProgress?.getProgress(index, range)\n return { tocItem, pageItem }\n }\n async getTOCItemOf(target) {\n try {\n const { index, anchor } = await this.resolveNavigation(target)\n const doc = await this.book.sections[index].createDocument()\n const frag = anchor(doc)\n const isRange = frag instanceof Range\n const range = isRange ? frag : doc.createRange()\n if (!isRange) range.selectNodeContents(frag)\n return this.#tocProgress.getProgress(index, range)\n } catch(e) {\n console.error(e)\n console.error(`Could not get ${target}`)\n }\n }\n async prev(distance) {\n await this.renderer.prev(distance)\n }\n async next(distance) {\n await this.renderer.next(distance)\n }\n goLeft() {\n return this.book.dir === 'rtl' ? this.next() : this.prev()\n }\n goRight() {\n return this.book.dir === 'rtl' ? this.prev() : this.next()\n }\n async * #searchSection(matcher, query, index) {\n const doc = await this.book.sections[index].createDocument()\n for (const { range, excerpt } of matcher(doc, query))\n yield { cfi: this.getCFI(index, range), excerpt }\n }\n async * #searchBook(matcher, query) {\n const { sections } = this.book\n for (const [index, { createDocument }] of sections.entries()) {\n if (!createDocument) continue\n const doc = await createDocument()\n const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) =>\n ({ cfi: this.getCFI(index, range), excerpt }))\n const progress = (index + 1) / sections.length\n yield { progress }\n if (subitems.length) yield { index, subitems }\n }\n }\n async * search(opts) {\n this.clearSearch()\n const { searchMatcher } = await import('./search.js')\n const { query, index } = opts\n const matcher = searchMatcher(textWalker,\n { defaultLocale: this.language, ...opts })\n const iter = index != null\n ? this.#searchSection(matcher, query, index)\n : this.#searchBook(matcher, query)\n \n const list = []\n this.#searchResults.set(index, list)\n \n for await (const result of iter) {\n if (result.subitems){\n const list = result.subitems\n .map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi }))\n this.#searchResults.set(result.index, list)\n for (const item of list) this.addAnnotation(item)\n yield {\n label: this.#tocProgress.getProgress(result.index)?.label ?? '',\n subitems: result.subitems,\n }\n }\n else {\n if (result.cfi) {\n const item = { value: SEARCH_PREFIX + result.cfi }\n list.push(item)\n this.addAnnotation(item)\n }\n yield result\n }\n }\n yield 'done'\n }\n clearSearch() {\n for (const list of this.#searchResults.values())\n for (const item of list) this.deleteAnnotation(item)\n this.#searchResults.clear()\n }\n async initTTS(granularity = 'word', highlight) {\n const doc = this.renderer.getContents()[0].doc\n if (this.tts && this.tts.doc === doc) return\n const { TTS } = await import('./tts.js')\n this.tts = new TTS(doc, textWalker, highlight || (range =>\n this.renderer.scrollToAnchor(range, true)), granularity)\n }\n startMediaOverlay() {\n const { index } = this.renderer.getContents()[0]\n return this.mediaOverlay.start(index)\n }\n }\n \n customElements.define('foliate-view', View)\n """ ] ] |
|