Initial import
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
let activeAnchor: HTMLAnchorElement | null = null
|
||||
|
||||
async function mouseEnterHandler(
|
||||
this: HTMLAnchorElement,
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = (activeAnchor = this)
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
strategy: "fixed",
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
|
||||
})
|
||||
}
|
||||
|
||||
function showPopover(popoverElement: HTMLElement) {
|
||||
clearActivePopover()
|
||||
popoverElement.classList.add("active-popover")
|
||||
setPosition(popoverElement as HTMLElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const inner = popoverElement.querySelector(".popover-inner") as HTMLElement | null
|
||||
if (inner) {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = inner.querySelector(targetAnchor) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetUrl = new URL(link.href)
|
||||
const hash = decodeURIComponent(targetUrl.hash)
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
const popoverId = `popover-${link.pathname}`
|
||||
const prevPopoverElement = document.getElementById(popoverId)
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
showPopover(prevPopoverElement as HTMLElement)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
if (!response) return
|
||||
const rawContentType = response.headers.get("Content-Type")
|
||||
if (!rawContentType) return
|
||||
const [contentType] = rawContentType.split(";")
|
||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.id = popoverId
|
||||
popoverElement.classList.add("popover")
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverInner.dataset.contentType = contentType ?? undefined
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
switch (contentTypeCategory) {
|
||||
case "image":
|
||||
const img = document.createElement("img")
|
||||
img.src = targetUrl.toString()
|
||||
img.alt = targetUrl.pathname
|
||||
|
||||
popoverInner.appendChild(img)
|
||||
break
|
||||
case "application":
|
||||
switch (typeInfo) {
|
||||
case "pdf":
|
||||
const pdf = document.createElement("iframe")
|
||||
pdf.src = targetUrl.toString()
|
||||
popoverInner.appendChild(pdf)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
default:
|
||||
const contents = await response.text()
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
// prepend all IDs inside popovers to prevent duplicates
|
||||
html.querySelectorAll("[id]").forEach((el) => {
|
||||
const targetID = `popover-internal-${el.id}`
|
||||
el.id = targetID
|
||||
})
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||
}
|
||||
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(popoverElement)
|
||||
if (activeAnchor !== this) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopover(popoverElement)
|
||||
}
|
||||
|
||||
function clearActivePopover() {
|
||||
activeAnchor = null
|
||||
const allPopoverElements = document.querySelectorAll(".popover")
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
function setupPopovers() {
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
link.addEventListener("mouseleave", clearActivePopover)
|
||||
window.addCleanup(() => {
|
||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||
link.removeEventListener("mouseleave", clearActivePopover)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupPopovers)
|
||||
document.addEventListener("render", setupPopovers)
|
||||
@@ -0,0 +1,185 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
type ScrollArg = { top: number; behavior?: ScrollBehavior }
|
||||
type FakeHeading = { offsetTop: number }
|
||||
|
||||
interface FakePopoverInner {
|
||||
scroll: (arg: ScrollArg) => void
|
||||
querySelector: (sel: string) => FakeHeading | null
|
||||
_scrolled: ScrollArg | null
|
||||
_selectorsQueried: string[]
|
||||
}
|
||||
|
||||
interface FakePopoverElement {
|
||||
classList: {
|
||||
add: (cls: string) => void
|
||||
remove: (cls: string) => void
|
||||
_added: string[]
|
||||
_removed: string[]
|
||||
}
|
||||
style: Record<string, string>
|
||||
querySelector: (sel: string) => FakePopoverInner | null
|
||||
_inner: FakePopoverInner
|
||||
}
|
||||
|
||||
function makeInner(heading: FakeHeading | null = null): FakePopoverInner {
|
||||
const inner: FakePopoverInner = {
|
||||
scroll(arg) {
|
||||
this._scrolled = arg
|
||||
},
|
||||
querySelector(sel) {
|
||||
this._selectorsQueried.push(sel)
|
||||
return heading
|
||||
},
|
||||
_scrolled: null,
|
||||
_selectorsQueried: [],
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
function makePopoverElement(inner: FakePopoverInner): FakePopoverElement {
|
||||
const added: string[] = []
|
||||
const removed: string[] = []
|
||||
return {
|
||||
classList: {
|
||||
add(cls) {
|
||||
added.push(cls)
|
||||
},
|
||||
remove(cls) {
|
||||
removed.push(cls)
|
||||
},
|
||||
_added: added,
|
||||
_removed: removed,
|
||||
},
|
||||
style: {},
|
||||
querySelector(sel) {
|
||||
return sel === ".popover-inner" ? inner : null
|
||||
},
|
||||
_inner: inner,
|
||||
}
|
||||
}
|
||||
|
||||
type SetPosition = (el: FakePopoverElement) => Promise<void>
|
||||
|
||||
function fixedShowPopover(
|
||||
popoverElement: FakePopoverElement,
|
||||
hash: string,
|
||||
setPosition: SetPosition,
|
||||
): Promise<void> {
|
||||
popoverElement.classList.add("active-popover")
|
||||
const positionResult = setPosition(popoverElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const inner = popoverElement.querySelector(".popover-inner")
|
||||
if (inner) {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = inner.querySelector(targetAnchor)
|
||||
if (heading) {
|
||||
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positionResult
|
||||
}
|
||||
|
||||
describe("showPopover on cache-hit with hash", () => {
|
||||
test("does not reference any lexical popoverInner from an outer scope", async () => {
|
||||
const heading: FakeHeading = { offsetTop: 200 }
|
||||
const inner = makeInner(heading)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#plugins", async () => {})
|
||||
|
||||
assert.ok(
|
||||
popoverElement.classList._added.includes("active-popover"),
|
||||
"active-popover class must be applied",
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
inner._scrolled,
|
||||
{ top: 200 - 12, behavior: "instant" },
|
||||
"scroll must target heading.offsetTop - 12",
|
||||
)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-plugins"])
|
||||
})
|
||||
|
||||
test("skips scroll when hash is empty", async () => {
|
||||
const inner = makeInner({ offsetTop: 123 })
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "", async () => {})
|
||||
|
||||
assert.strictEqual(inner._scrolled, null)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, [])
|
||||
})
|
||||
|
||||
test("skips scroll when heading is not found", async () => {
|
||||
const inner = makeInner(null)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#nonexistent", async () => {})
|
||||
|
||||
assert.strictEqual(inner._scrolled, null)
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-nonexistent"])
|
||||
})
|
||||
|
||||
test("decodes percent-encoded fragments when building the selector", async () => {
|
||||
const heading: FakeHeading = { offsetTop: 50 }
|
||||
const inner = makeInner(heading)
|
||||
const popoverElement = makePopoverElement(inner)
|
||||
|
||||
await fixedShowPopover(popoverElement, "#a-b", async () => {})
|
||||
|
||||
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-a-b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("buggy showPopover (lexical-capture pattern) regression guard", () => {
|
||||
test("accessing a capture-before-declaration variable throws ReferenceError (TDZ simulation)", () => {
|
||||
function simulateBuggyMouseEnter(hash: string) {
|
||||
function buggyShowPopover(popoverElement: FakePopoverElement) {
|
||||
popoverElement.classList.add("active-popover")
|
||||
if (hash !== "") {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = popoverInner.querySelector(targetAnchor)
|
||||
if (heading) {
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cachedInner = makeInner({ offsetTop: 999 })
|
||||
const cachedElement = makePopoverElement(cachedInner)
|
||||
buggyShowPopover(cachedElement)
|
||||
|
||||
const popoverInner = makeInner(null)
|
||||
return popoverInner
|
||||
}
|
||||
|
||||
assert.throws(() => simulateBuggyMouseEnter("#plugins"), {
|
||||
name: "ReferenceError",
|
||||
})
|
||||
})
|
||||
|
||||
test("same pattern does NOT throw when hash is empty (explains why first link without fragment works)", () => {
|
||||
function simulateBuggyMouseEnter(hash: string) {
|
||||
function buggyShowPopover(popoverElement: FakePopoverElement) {
|
||||
popoverElement.classList.add("active-popover")
|
||||
if (hash !== "") {
|
||||
const _unused = popoverInner.querySelector("x")
|
||||
void _unused
|
||||
}
|
||||
}
|
||||
|
||||
const cachedInner = makeInner(null)
|
||||
const cachedElement = makePopoverElement(cachedInner)
|
||||
buggyShowPopover(cachedElement)
|
||||
|
||||
const popoverInner = makeInner(null)
|
||||
return popoverInner
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => simulateBuggyMouseEnter(""))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
|
||||
// Inline the encoder function from search.inline.ts for testing
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
describe("search encoder", () => {
|
||||
describe("English text", () => {
|
||||
test("should tokenize simple English words", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle multiple spaces", () => {
|
||||
const result = encoder("hello world")
|
||||
assert.deepStrictEqual(result, ["hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle tabs and newlines", () => {
|
||||
const result = encoder("hello\tworld\ntest")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
|
||||
test("should lowercase all text", () => {
|
||||
const result = encoder("Hello WORLD Test")
|
||||
assert.deepStrictEqual(result, ["hello", "world", "test"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("CJK text", () => {
|
||||
test("should tokenize Japanese Hiragana character by character", () => {
|
||||
const result = encoder("こんにちは")
|
||||
assert.deepStrictEqual(result, ["こ", "ん", "に", "ち", "は"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Katakana character by character", () => {
|
||||
const result = encoder("コントロール")
|
||||
assert.deepStrictEqual(result, ["コ", "ン", "ト", "ロ", "ー", "ル"])
|
||||
})
|
||||
|
||||
test("should tokenize Japanese Kanji character by character", () => {
|
||||
const result = encoder("日本語")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should tokenize Korean Hangul character by character", () => {
|
||||
const result = encoder("안녕하세요")
|
||||
assert.deepStrictEqual(result, ["안", "녕", "하", "세", "요"])
|
||||
})
|
||||
|
||||
test("should tokenize Chinese characters character by character", () => {
|
||||
const result = encoder("你好世界")
|
||||
assert.deepStrictEqual(result, ["你", "好", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle mixed Hiragana/Katakana/Kanji", () => {
|
||||
const result = encoder("て以来")
|
||||
assert.deepStrictEqual(result, ["て", "以", "来"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mixed CJK and English", () => {
|
||||
test("should handle Japanese with English words", () => {
|
||||
const result = encoder("hello 世界")
|
||||
assert.deepStrictEqual(result, ["hello", "世", "界"])
|
||||
})
|
||||
|
||||
test("should handle English with Japanese words", () => {
|
||||
const result = encoder("世界 hello world")
|
||||
assert.deepStrictEqual(result, ["世", "界", "hello", "world"])
|
||||
})
|
||||
|
||||
test("should handle complex mixed content", () => {
|
||||
const result = encoder("これはtest文章です")
|
||||
assert.deepStrictEqual(result, ["こ", "れ", "は", "test", "文", "章", "で", "す"])
|
||||
})
|
||||
|
||||
test("should handle mixed Korean and English", () => {
|
||||
const result = encoder("hello 안녕 world")
|
||||
assert.deepStrictEqual(result, ["hello", "안", "녕", "world"])
|
||||
})
|
||||
|
||||
test("should handle mixed Chinese and English", () => {
|
||||
const result = encoder("你好 world")
|
||||
assert.deepStrictEqual(result, ["你", "好", "world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
test("should handle empty string", () => {
|
||||
const result = encoder("")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle only whitespace", () => {
|
||||
const result = encoder(" \t\n ")
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
test("should handle single character", () => {
|
||||
const result = encoder("a")
|
||||
assert.deepStrictEqual(result, ["a"])
|
||||
})
|
||||
|
||||
test("should handle single CJK character", () => {
|
||||
const result = encoder("あ")
|
||||
assert.deepStrictEqual(result, ["あ"])
|
||||
})
|
||||
|
||||
test("should handle CJK with trailing whitespace", () => {
|
||||
const result = encoder("日本語 ")
|
||||
assert.deepStrictEqual(result, ["日", "本", "語"])
|
||||
})
|
||||
|
||||
test("should handle English with trailing whitespace", () => {
|
||||
const result = encoder("hello ")
|
||||
assert.deepStrictEqual(result, ["hello"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
const NODE_TYPE_ELEMENT = 1
|
||||
let announcer = document.createElement("route-announcer")
|
||||
const isElement = (target: EventTarget | null): target is Element =>
|
||||
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
|
||||
const isLocalUrl = (href: string) => {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (window.location.origin === url.origin) {
|
||||
return true
|
||||
}
|
||||
} catch (e) {}
|
||||
return false
|
||||
}
|
||||
|
||||
const isSamePage = (url: URL): boolean => {
|
||||
const sameOrigin = url.origin === window.location.origin
|
||||
const samePath = url.pathname === window.location.pathname
|
||||
return sameOrigin && samePath
|
||||
}
|
||||
|
||||
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
|
||||
if (!isElement(target)) return
|
||||
if (target.attributes.getNamedItem("target")?.value === "_blank") return
|
||||
const a = target.closest("a")
|
||||
if (!a) return
|
||||
if ("routerIgnore" in a.dataset) return
|
||||
const { href } = a
|
||||
if (!isLocalUrl(href)) return
|
||||
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
|
||||
}
|
||||
|
||||
function notifyNav(url: FullSlug) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
function startLoading() {
|
||||
document.querySelector(".navigation-progress")?.remove()
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
document.body.prepend(loadingBar)
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
const loadingBar = document.querySelector(".navigation-progress")
|
||||
if (loadingBar) {
|
||||
loadingBar.remove()
|
||||
}
|
||||
}
|
||||
|
||||
let isNavigating = false
|
||||
let p: DOMParser
|
||||
async function _navigate(url: URL, isBack: boolean = false) {
|
||||
isNavigating = true
|
||||
startLoading()
|
||||
p = p || new DOMParser()
|
||||
const contents = await fetchCanonical(url)
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get("content-type")
|
||||
if (contentType?.startsWith("text/html")) {
|
||||
return res.text()
|
||||
} else {
|
||||
window.location.assign(url)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.assign(url)
|
||||
})
|
||||
|
||||
if (!contents) return
|
||||
|
||||
// notify about to nav
|
||||
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
// cleanup old
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns.clear()
|
||||
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, url)
|
||||
|
||||
let title = html.querySelector("title")?.textContent
|
||||
if (title) {
|
||||
document.title = title
|
||||
} else {
|
||||
const h1 = document.querySelector("h1")
|
||||
title = h1?.innerText ?? h1?.textContent ?? url.pathname
|
||||
}
|
||||
if (announcer.textContent !== title) {
|
||||
announcer.textContent = title
|
||||
}
|
||||
announcer.dataset.persist = ""
|
||||
html.body.appendChild(announcer)
|
||||
|
||||
document.querySelector(".navigation-progress")?.remove()
|
||||
micromorph(document.body, html.body)
|
||||
|
||||
// scroll into place and add history
|
||||
if (!isBack) {
|
||||
if (url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// now, patch head, re-executing scripts
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([data-persist])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
// at this point everything is loaded so changing the url should resolve to the correct addresses
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
if (isNavigating) return
|
||||
isNavigating = true
|
||||
try {
|
||||
await _navigate(url, isBack)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.location.assign(url)
|
||||
} finally {
|
||||
stopLoading()
|
||||
isNavigating = false
|
||||
}
|
||||
}
|
||||
|
||||
window.spaNavigate = navigate
|
||||
|
||||
function createRouter() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", async (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
// dont hijack behaviour, just let browser act normally
|
||||
if (!url || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
|
||||
if (isSamePage(url) && url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
history.pushState({}, "", url)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(url, false)
|
||||
})
|
||||
|
||||
window.addEventListener("popstate", (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
if (window.location.hash && window.location.pathname === url?.pathname) return
|
||||
navigate(new URL(window.location.toString()), true)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return new (class Router {
|
||||
go(pathname: RelativeURL) {
|
||||
const url = new URL(pathname, window.location.toString())
|
||||
return navigate(url, false)
|
||||
}
|
||||
|
||||
back() {
|
||||
return window.history.back()
|
||||
}
|
||||
|
||||
forward() {
|
||||
return window.history.forward()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
createRouter()
|
||||
notifyNav(getFullSlug(window))
|
||||
|
||||
if (!customElements.get("route-announcer")) {
|
||||
const attrs = {
|
||||
"aria-live": "assertive",
|
||||
"aria-atomic": "true",
|
||||
style:
|
||||
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"route-announcer",
|
||||
class RouteAnnouncer extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
connectedCallback() {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
|
||||
if (!outsideContainer) return
|
||||
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
|
||||
if (e.target !== this) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
|
||||
function esc(e: HTMLElementEventMap["keydown"]) {
|
||||
if (!e.key.startsWith("Esc")) return
|
||||
e.preventDefault()
|
||||
cb()
|
||||
}
|
||||
|
||||
outsideContainer?.addEventListener("click", click)
|
||||
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||
document.addEventListener("keydown", esc)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||
}
|
||||
|
||||
export function removeAllChildren(node: HTMLElement) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||
// containing the URL it's redirecting to.
|
||||
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||
// way less robust - we only care about our own generated redirects after all.
|
||||
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||
|
||||
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const res = await fetch(`${url}`)
|
||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||
return res
|
||||
}
|
||||
|
||||
// reading the body can only be done once, so we need to clone the response
|
||||
// to allow the caller to read it if it's was not a redirect
|
||||
const text = await res.clone().text()
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
Reference in New Issue
Block a user