-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
- Loading branch information
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
/* | ||
creates link next to each heading that links to that section. | ||
*/ | ||
|
||
{ | ||
const onLoad = () => { | ||
// for each heading | ||
const headings = document.querySelectorAll( | ||
"h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]" | ||
); | ||
for (const heading of headings) { | ||
// create anchor link | ||
const link = document.createElement("a"); | ||
link.classList.add("icon", "fa-solid", "fa-link", "anchor"); | ||
link.href = "#" + heading.id; | ||
link.setAttribute("aria-label", "link to this section"); | ||
heading.append(link); | ||
|
||
// if first heading in the section, move id to parent section | ||
if (heading.matches("section > :first-child")) { | ||
heading.parentElement.id = heading.id; | ||
heading.removeAttribute("id"); | ||
} | ||
} | ||
}; | ||
|
||
// scroll to target of url hash | ||
const scrollToTarget = () => { | ||
const id = window.location.hash.replace("#", ""); | ||
const target = document.getElementById(id); | ||
|
||
if (!target) return; | ||
const offset = document.querySelector("header").clientHeight || 0; | ||
window.scrollTo({ | ||
top: target.getBoundingClientRect().top + window.scrollY - offset, | ||
behavior: "smooth", | ||
}); | ||
}; | ||
|
||
// after page loads | ||
window.addEventListener("load", onLoad); | ||
window.addEventListener("load", scrollToTarget); | ||
window.addEventListener("tagsfetched", scrollToTarget); | ||
|
||
// when hash nav happens | ||
window.addEventListener("hashchange", scrollToTarget); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
/* | ||
manages light/dark mode. | ||
*/ | ||
|
||
{ | ||
// immediately load saved (or default) mode before page renders | ||
document.documentElement.dataset.dark = | ||
window.localStorage.getItem("dark-mode") ?? "false"; | ||
|
||
const onLoad = () => { | ||
// update toggle button to match loaded mode | ||
document.querySelector(".dark-toggle").checked = | ||
document.documentElement.dataset.dark === "true"; | ||
}; | ||
|
||
// after page loads | ||
window.addEventListener("load", onLoad); | ||
|
||
// when user toggles mode button | ||
window.onDarkToggleChange = (event) => { | ||
const value = event.target.checked; | ||
document.documentElement.dataset.dark = value; | ||
window.localStorage.setItem("dark-mode", value); | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
/* | ||
fetches tags (aka "topics") from a given GitHub repo and adds them to row of | ||
tag buttons. specify repo in data-repo attribute on row. | ||
*/ | ||
|
||
{ | ||
const onLoad = async () => { | ||
// get tag rows with specified repos | ||
const rows = document.querySelectorAll("[data-repo]"); | ||
|
||
// for each repo | ||
for (const row of rows) { | ||
// get props from tag row | ||
const repo = row.dataset.repo.trim(); | ||
const link = row.dataset.link.trim(); | ||
|
||
// get tags from github | ||
if (!repo) continue; | ||
let tags = await fetchTags(repo); | ||
|
||
// filter out tags already present in row | ||
let existing = [...row.querySelectorAll(".tag")].map((tag) => | ||
window.normalizeTag(tag.innerText) | ||
); | ||
tags = tags.filter((tag) => !existing.includes(normalizeTag(tag))); | ||
|
||
// add tags to row | ||
for (const tag of tags) { | ||
const a = document.createElement("a"); | ||
a.classList.add("tag"); | ||
a.innerHTML = tag; | ||
a.href = `${link}?search="tag: ${tag}"`; | ||
a.dataset.tooltip = `Show items with the tag "${tag}"`; | ||
row.append(a); | ||
} | ||
|
||
// delete tags container if empty | ||
if (!row.innerText.trim()) row.remove(); | ||
} | ||
|
||
// emit "tags done" event for other scripts to listen for | ||
window.dispatchEvent(new Event("tagsfetched")); | ||
}; | ||
|
||
// after page loads | ||
window.addEventListener("load", onLoad); | ||
|
||
// GitHub topics endpoint | ||
const api = "https://api.github.com/repos/REPO/topics"; | ||
const headers = new Headers(); | ||
headers.set("Accept", "application/vnd.github+json"); | ||
|
||
// get tags from GitHub based on repo name | ||
const fetchTags = async (repo) => { | ||
const url = api.replace("REPO", repo); | ||
try { | ||
const response = await (await fetch(url)).json(); | ||
if (response.names) return response.names; | ||
else throw new Error(JSON.stringify(response)); | ||
} catch (error) { | ||
console.groupCollapsed("GitHub fetch tags error"); | ||
console.log(error); | ||
console.groupEnd(); | ||
return []; | ||
} | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
/* | ||
filters elements on page based on url or search box. | ||
syntax: term1 term2 "full phrase 1" "full phrase 2" "tag: tag 1" | ||
match if: all terms AND at least one phrase AND at least one tag | ||
*/ | ||
{ | ||
// elements to filter | ||
const elementSelector = ".card, .citation, .post-excerpt"; | ||
// search box element | ||
const searchBoxSelector = ".search-box"; | ||
// results info box element | ||
const infoBoxSelector = ".search-info"; | ||
// tags element | ||
const tagSelector = ".tag"; | ||
|
||
// split search query into terms, phrases, and tags | ||
const splitQuery = (query) => { | ||
// split into parts, preserve quotes | ||
const parts = query.match(/"[^"]*"|\S+/g) || []; | ||
|
||
// bins | ||
const terms = []; | ||
const phrases = []; | ||
const tags = []; | ||
|
||
// put parts into bins | ||
for (let part of parts) { | ||
if (part.startsWith('"')) { | ||
part = part.replaceAll('"', "").trim(); | ||
if (part.startsWith("tag:")) | ||
tags.push(normalizeTag(part.replace(/tag:\s*/, ""))); | ||
else phrases.push(part.toLowerCase()); | ||
} else terms.push(part.toLowerCase()); | ||
} | ||
|
||
return { terms, phrases, tags }; | ||
}; | ||
|
||
// normalize tag string for comparison | ||
window.normalizeTag = (tag) => | ||
tag.trim().toLowerCase().replaceAll(/-|\s+/g, " "); | ||
|
||
// get data attribute contents of element and children | ||
const getAttr = (element, attr) => | ||
[element, ...element.querySelectorAll(`[data-${attr}]`)] | ||
.map((element) => element.dataset[attr]) | ||
.join(" "); | ||
|
||
// determine if element should show up in results based on query | ||
const elementMatches = (element, { terms, phrases, tags }) => { | ||
// tag elements within element | ||
const tagElements = [...element.querySelectorAll(".tag")]; | ||
|
||
// check if text content exists in element | ||
const hasText = (string) => | ||
( | ||
element.innerText + | ||
getAttr(element, "tooltip") + | ||
getAttr(element, "search") | ||
) | ||
.toLowerCase() | ||
.includes(string); | ||
// check if text matches a tag in element | ||
const hasTag = (string) => | ||
tagElements.some((tag) => normalizeTag(tag.innerText) === string); | ||
|
||
// match logic | ||
return ( | ||
(terms.every(hasText) || !terms.length) && | ||
(phrases.some(hasText) || !phrases.length) && | ||
(tags.some(hasTag) || !tags.length) | ||
); | ||
}; | ||
|
||
// loop through elements, hide/show based on query, and return results info | ||
const filterElements = (parts) => { | ||
let elements = document.querySelectorAll(elementSelector); | ||
|
||
// results info | ||
let x = 0; | ||
let n = elements.length; | ||
let tags = parts.tags; | ||
|
||
// filter elements | ||
for (const element of elements) { | ||
if (elementMatches(element, parts)) { | ||
element.style.display = ""; | ||
x++; | ||
} else element.style.display = "none"; | ||
} | ||
|
||
return [x, n, tags]; | ||
}; | ||
|
||
// highlight search terms | ||
const highlightMatches = async ({ terms, phrases }) => { | ||
// make sure Mark library available | ||
if (typeof Mark === "undefined") return; | ||
|
||
// reset | ||
new Mark(document.body).unmark(); | ||
|
||
// limit number of highlights to avoid slowdown | ||
let counter = 0; | ||
const filter = () => counter++ < 100; | ||
|
||
// highlight terms and phrases | ||
new Mark(elementSelector) | ||
.mark(terms, { separateWordSearch: true, filter }) | ||
.mark(phrases, { separateWordSearch: false, filter }); | ||
}; | ||
|
||
// update search box based on query | ||
const updateSearchBox = (query = "") => { | ||
const boxes = document.querySelectorAll(searchBoxSelector); | ||
|
||
for (const box of boxes) { | ||
const input = box.querySelector("input"); | ||
const button = box.querySelector("button"); | ||
const icon = box.querySelector("button i"); | ||
input.value = query; | ||
icon.className = input.value.length | ||
? "icon fa-solid fa-xmark" | ||
: "icon fa-solid fa-magnifying-glass"; | ||
button.disabled = input.value.length ? false : true; | ||
} | ||
}; | ||
|
||
// update info box based on query and results | ||
const updateInfoBox = (query, x, n) => { | ||
const boxes = document.querySelectorAll(infoBoxSelector); | ||
|
||
if (query.trim()) { | ||
// show all info boxes | ||
boxes.forEach((info) => (info.style.display = "")); | ||
|
||
// info template | ||
let info = ""; | ||
info += `Showing ${x.toLocaleString()} of ${n.toLocaleString()} results<br>`; | ||
info += "<a href='./'>Clear search</a>"; | ||
|
||
// set info HTML string | ||
boxes.forEach((el) => (el.innerHTML = info)); | ||
} | ||
// if nothing searched | ||
else { | ||
// hide all info boxes | ||
boxes.forEach((info) => (info.style.display = "none")); | ||
} | ||
}; | ||
|
||
// update tags based on query | ||
const updateTags = (query) => { | ||
const { tags } = splitQuery(query); | ||
document.querySelectorAll(tagSelector).forEach((tag) => { | ||
// set active if tag is in query | ||
if (tags.includes(normalizeTag(tag.innerText))) | ||
tag.setAttribute("data-active", ""); | ||
else tag.removeAttribute("data-active"); | ||
}); | ||
}; | ||
|
||
// run search with query | ||
const runSearch = (query = "") => { | ||
const parts = splitQuery(query); | ||
const [x, n] = filterElements(parts); | ||
updateSearchBox(query); | ||
updateInfoBox(query, x, n); | ||
updateTags(query); | ||
highlightMatches(parts); | ||
}; | ||
|
||
// update url based on query | ||
const updateUrl = (query = "") => { | ||
const url = new URL(window.location); | ||
let params = new URLSearchParams(url.search); | ||
params.set("search", query); | ||
url.search = params.toString(); | ||
window.history.replaceState(null, null, url); | ||
}; | ||
|
||
// search based on url param | ||
const searchFromUrl = () => { | ||
const query = | ||
new URLSearchParams(window.location.search).get("search") || ""; | ||
runSearch(query); | ||
}; | ||
|
||
// return func that runs after delay | ||
const debounce = (callback, delay = 250) => { | ||
let timeout; | ||
return (...args) => { | ||
window.clearTimeout(timeout); | ||
timeout = window.setTimeout(() => callback(...args), delay); | ||
}; | ||
}; | ||
|
||
// when user types into search box | ||
const debouncedRunSearch = debounce(runSearch, 1000); | ||
window.onSearchInput = (target) => { | ||
debouncedRunSearch(target.value); | ||
updateUrl(target.value); | ||
}; | ||
|
||
// when user clears search box with button | ||
window.onSearchClear = () => { | ||
runSearch(); | ||
updateUrl(); | ||
}; | ||
|
||
// after page loads | ||
window.addEventListener("load", searchFromUrl); | ||
// after tags load | ||
window.addEventListener("tagsfetched", searchFromUrl); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/* | ||
for site search component. searches site/domain via google. | ||
*/ | ||
|
||
{ | ||
// when user submits site search form/box | ||
window.onSiteSearchSubmit = (event) => { | ||
event.preventDefault(); | ||
const google = "https://www.google.com/search?q=site:"; | ||
const site = window.location.origin; | ||
const query = event.target.elements.query.value; | ||
window.location = google + site + " " + query; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
/* | ||
put a wrapper around each table to allow scrolling. | ||
*/ | ||
|
||
{ | ||
const onLoad = () => { | ||
// for each top-level table | ||
const tables = document.querySelectorAll("table:not(table table)"); | ||
for (const table of tables) { | ||
// create wrapper with scroll | ||
const wrapper = document.createElement("div"); | ||
wrapper.style.overflowX = "auto"; | ||
|
||
// undo css force-text-wrap | ||
table.style.overflowWrap = "normal"; | ||
|
||
// add wrapper around table | ||
table.parentNode.insertBefore(wrapper, table); | ||
wrapper.appendChild(table); | ||
} | ||
}; | ||
|
||
// after page loads | ||
window.addEventListener("load", onLoad); | ||
} |