diff --git a/quartz.layout.ts b/quartz.layout.ts index 71c0885c6..0bcc1d413 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -15,6 +15,7 @@ const tagListComponent = Plugin.TagList() as QuartzComponent const pageTitleComponent = Plugin.PageTitle() as QuartzComponent const darkmodeComponent = Plugin.Darkmode() as QuartzComponent const readerModeComponent = Plugin.ReaderMode() as QuartzComponent +const breadcrumbsComponent = Plugin.Breadcrumbs() as QuartzComponent // components shared across all pages export const sharedPageComponents: SharedLayout = { @@ -37,7 +38,7 @@ export const sharedPageComponents: SharedLayout = { export const defaultContentPageLayout: PageLayout = { beforeBody: [ Component.ConditionalRender({ - component: Component.Breadcrumbs(), + component: breadcrumbsComponent, condition: (page) => page.fileData.slug !== "index", }), articleTitleComponent, @@ -64,7 +65,7 @@ export const defaultContentPageLayout: PageLayout = { // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.Breadcrumbs(), articleTitleComponent, contentMetaComponent], + beforeBody: [breadcrumbsComponent, articleTitleComponent, contentMetaComponent], left: [ pageTitleComponent, Component.MobileOnly(Component.Spacer()), diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx deleted file mode 100644 index 0d34457f3..000000000 --- a/quartz/components/Backlinks.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import style from "./styles/backlinks.scss" -import { resolveRelative, simplifySlug } from "../util/path" -import { i18n } from "../i18n" -import { classNames } from "../util/lang" -import OverflowListFactory from "./OverflowList" - -interface BacklinksOptions { - hideWhenEmpty: boolean -} - -const defaultOptions: BacklinksOptions = { - hideWhenEmpty: true, -} - -export default ((opts?: Partial) => { - const options: BacklinksOptions = { ...defaultOptions, ...opts } - const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory() - - const Backlinks: QuartzComponent = ({ - fileData, - allFiles, - displayClass, - cfg, - }: QuartzComponentProps) => { - const slug = simplifySlug(fileData.slug!) - const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) - if (options.hideWhenEmpty && backlinkFiles.length == 0) { - return null - } - return ( -
-

{i18n(cfg.locale).components.backlinks.title}

- - {backlinkFiles.length > 0 ? ( - backlinkFiles.map((f) => ( -
  • - - {f.frontmatter?.title} - -
  • - )) - ) : ( -
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • - )} -
    -
    - ) - } - - Backlinks.css = style - Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded - - return Backlinks -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx deleted file mode 100644 index 5144a314d..000000000 --- a/quartz/components/Breadcrumbs.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import breadcrumbsStyle from "./styles/breadcrumbs.scss" -import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path" -import { classNames } from "../util/lang" -import { trieFromAllFiles } from "../util/ctx" - -type CrumbData = { - displayName: string - path: string -} - -interface BreadcrumbOptions { - /** - * Symbol between crumbs - */ - spacerSymbol: string - /** - * Name of first crumb - */ - rootName: string - /** - * Whether to look up frontmatter title for folders (could cause performance problems with big vaults) - */ - resolveFrontmatterTitle: boolean - /** - * Whether to display the current page in the breadcrumbs. - */ - showCurrentPage: boolean -} - -const defaultOptions: BreadcrumbOptions = { - spacerSymbol: "❯", - rootName: "Home", - resolveFrontmatterTitle: true, - showCurrentPage: true, -} - -function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData { - return { - displayName: displayName.replaceAll("-", " "), - path: resolveRelative(baseSlug, currentSlug), - } -} - -export default ((opts?: Partial) => { - const options: BreadcrumbOptions = { ...defaultOptions, ...opts } - const Breadcrumbs: QuartzComponent = ({ - fileData, - allFiles, - displayClass, - ctx, - }: QuartzComponentProps) => { - const trie = (ctx.trie ??= trieFromAllFiles(allFiles)) - const slugParts = fileData.slug!.split("/") - const pathNodes = trie.ancestryChain(slugParts) - - if (!pathNodes) { - return null - } - - const crumbs: CrumbData[] = pathNodes.map((node, idx) => { - const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug)) - if (idx === 0) { - crumb.displayName = options.rootName - } - - // For last node (current page), set empty path - if (idx === pathNodes.length - 1) { - crumb.path = "" - } - - return crumb - }) - - if (!options.showCurrentPage) { - crumbs.pop() - } - - return ( - - ) - } - Breadcrumbs.css = breadcrumbsStyle - - return Breadcrumbs -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Comments.tsx b/quartz/components/Comments.tsx deleted file mode 100644 index a7315214f..000000000 --- a/quartz/components/Comments.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { classNames } from "../util/lang" -// @ts-ignore -import script from "./scripts/comments.inline" - -type Options = { - provider: "giscus" - options: { - repo: `${string}/${string}` - repoId: string - category: string - categoryId: string - themeUrl?: string - lightTheme?: string - darkTheme?: string - mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname" - strict?: boolean - reactionsEnabled?: boolean - inputPosition?: "top" | "bottom" - lang?: string - } -} - -function boolToStringBool(b: boolean): string { - return b ? "1" : "0" -} - -export default ((opts: Options) => { - const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => { - // check if comments should be displayed according to frontmatter - const disableComment: boolean = - typeof fileData.frontmatter?.comments !== "undefined" && - (!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false") - if (disableComment) { - return <> - } - - return ( -
    - ) - } - - Comments.afterDOMLoaded = script - - return Comments -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/OverflowList.tsx b/quartz/components/OverflowList.tsx deleted file mode 100644 index 12d97b6d4..000000000 --- a/quartz/components/OverflowList.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { JSX } from "preact" - -const OverflowList = ({ - children, - ...props -}: JSX.HTMLAttributes & { id: string }) => { - return ( -
      - {children} -
    • -
    - ) -} - -let numLists = 0 -export default () => { - const id = `list-${numLists++}` - - return { - OverflowList: (props: JSX.HTMLAttributes) => ( - - ), - overflowListAfterDOMLoaded: ` -document.addEventListener("nav", (e) => { - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const parentUl = entry.target.parentElement - if (!parentUl) return - if (entry.isIntersecting) { - parentUl.classList.remove("gradient-active") - } else { - parentUl.classList.add("gradient-active") - } - } - }) - - const ul = document.getElementById("${id}") - if (!ul) return - - const end = ul.querySelector(".overflow-end") - if (!end) return - - observer.observe(end) - window.addCleanup(() => observer.disconnect()) -}) -`, - } -} diff --git a/quartz/components/RecentNotes.tsx b/quartz/components/RecentNotes.tsx deleted file mode 100644 index 2c32feadf..000000000 --- a/quartz/components/RecentNotes.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" -import { QuartzPluginData } from "../plugins/vfile" -import { byDateAndAlphabetical } from "./PageList" -import style from "./styles/recentNotes.scss" -import { Date, getDate } from "./Date" -import { GlobalConfiguration } from "../cfg" -import { i18n } from "../i18n" -import { classNames } from "../util/lang" - -interface Options { - title?: string - limit: number - linkToMore: SimpleSlug | false - showTags: boolean - filter: (f: QuartzPluginData) => boolean - sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number -} - -const defaultOptions = (cfg: GlobalConfiguration): Options => ({ - limit: 3, - linkToMore: false, - showTags: true, - filter: () => true, - sort: byDateAndAlphabetical(cfg), -}) - -export default ((userOpts?: Partial) => { - const RecentNotes: QuartzComponent = ({ - allFiles, - fileData, - displayClass, - cfg, - }: QuartzComponentProps) => { - const opts = { ...defaultOptions(cfg), ...userOpts } - const pages = allFiles.filter(opts.filter).sort(opts.sort) - const remaining = Math.max(0, pages.length - opts.limit) - return ( -
    -

    {opts.title ?? i18n(cfg.locale).components.recentNotes.title}

    -
      - {pages.slice(0, opts.limit).map((page) => { - const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title - const tags = page.frontmatter?.tags ?? [] - - return ( -
    • -
      - - {page.dates && ( -

      - -

      - )} - {opts.showTags && ( - - )} -
      -
    • - ) - })} -
    - {opts.linkToMore && remaining > 0 && ( -

    - - {i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })} - -

    - )} -
    - ) - } - - RecentNotes.css = style - return RecentNotes -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Search.tsx b/quartz/components/Search.tsx deleted file mode 100644 index 6e932d2ef..000000000 --- a/quartz/components/Search.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import style from "./styles/search.scss" -// @ts-ignore -import script from "./scripts/search.inline" -import { classNames } from "../util/lang" -import { i18n } from "../i18n" - -export interface SearchOptions { - enablePreview: boolean -} - -const defaultOptions: SearchOptions = { - enablePreview: true, -} - -export default ((userOpts?: Partial) => { - const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { - const opts = { ...defaultOptions, ...userOpts } - const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder - return ( -
    - -
    -
    - -
    -
    -
    -
    - ) - } - - Search.afterDOMLoaded = script - Search.css = style - - return Search -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx deleted file mode 100644 index bbccf82a2..000000000 --- a/quartz/components/TableOfContents.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import legacyStyle from "./styles/legacyToc.scss" -import modernStyle from "./styles/toc.scss" -import { classNames } from "../util/lang" - -// @ts-ignore -import script from "./scripts/toc.inline" -import { i18n } from "../i18n" -import OverflowListFactory from "./OverflowList" -import { concatenateResources } from "../util/resources" - -interface Options { - layout: "modern" | "legacy" -} - -const defaultOptions: Options = { - layout: "modern", -} - -let numTocs = 0 -export default ((opts?: Partial) => { - const layout = opts?.layout ?? defaultOptions.layout - const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory() - const TableOfContents: QuartzComponent = ({ - fileData, - displayClass, - cfg, - }: QuartzComponentProps) => { - if (!fileData.toc) { - return null - } - - const id = `toc-${numTocs++}` - return ( -
    - - - {fileData.toc.map((tocEntry) => ( -
  • - - {tocEntry.text} - -
  • - ))} -
    -
    - ) - } - - TableOfContents.css = modernStyle - TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) - - const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { - if (!fileData.toc) { - return null - } - return ( -
    - -

    {i18n(cfg.locale).components.tableOfContents.title}

    -
    - -
    - ) - } - LegacyTableOfContents.css = legacyStyle - - return layout === "modern" ? TableOfContents : LegacyTableOfContents -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 164baa5e5..536b010c7 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -4,14 +4,8 @@ import FolderContent from "./pages/FolderContent" import NotFound from "./pages/404" import Head from "./Head" import Spacer from "./Spacer" -import TableOfContents from "./TableOfContents" -import Backlinks from "./Backlinks" -import Search from "./Search" import DesktopOnly from "./DesktopOnly" import MobileOnly from "./MobileOnly" -import RecentNotes from "./RecentNotes" -import Breadcrumbs from "./Breadcrumbs" -import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" @@ -26,15 +20,9 @@ export { FolderContent, Head, Spacer, - TableOfContents, - Backlinks, - Search, DesktopOnly, MobileOnly, - RecentNotes, NotFound, - Breadcrumbs, - Comments, Flex, ConditionalRender, } diff --git a/quartz/components/scripts/comments.inline.ts b/quartz/components/scripts/comments.inline.ts deleted file mode 100644 index 2b876bf6b..000000000 --- a/quartz/components/scripts/comments.inline.ts +++ /dev/null @@ -1,92 +0,0 @@ -const changeTheme = (e: CustomEventMap["themechange"]) => { - const theme = e.detail.theme - const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement - if (!iframe) { - return - } - - if (!iframe.contentWindow) { - return - } - - iframe.contentWindow.postMessage( - { - giscus: { - setConfig: { - theme: getThemeUrl(getThemeName(theme)), - }, - }, - }, - "https://giscus.app", - ) -} - -const getThemeName = (theme: string) => { - if (theme !== "dark" && theme !== "light") { - return theme - } - const giscusContainer = document.querySelector(".giscus") as GiscusElement - if (!giscusContainer) { - return theme - } - const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark" - const lightGiscus = giscusContainer.dataset.lightTheme ?? "light" - return theme === "dark" ? darkGiscus : lightGiscus -} - -const getThemeUrl = (theme: string) => { - const giscusContainer = document.querySelector(".giscus") as GiscusElement - if (!giscusContainer) { - return `https://giscus.app/themes/${theme}.css` - } - return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css` -} - -type GiscusElement = Omit & { - dataset: DOMStringMap & { - repo: `${string}/${string}` - repoId: string - category: string - categoryId: string - themeUrl: string - lightTheme: string - darkTheme: string - mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname" - strict: string - reactionsEnabled: string - inputPosition: "top" | "bottom" - lang: string - } -} - -document.addEventListener("nav", () => { - const giscusContainer = document.querySelector(".giscus") as GiscusElement - if (!giscusContainer) { - return - } - - const giscusScript = document.createElement("script") - giscusScript.src = "https://giscus.app/client.js" - giscusScript.async = true - giscusScript.crossOrigin = "anonymous" - giscusScript.setAttribute("data-loading", "lazy") - giscusScript.setAttribute("data-emit-metadata", "0") - giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo) - giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId) - giscusScript.setAttribute("data-category", giscusContainer.dataset.category) - giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId) - giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping) - giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict) - giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled) - giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition) - giscusScript.setAttribute("data-lang", giscusContainer.dataset.lang) - const theme = document.documentElement.getAttribute("saved-theme") - if (theme) { - giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme))) - } - - giscusContainer.appendChild(giscusScript) - - document.addEventListener("themechange", changeTheme) - window.addCleanup(() => document.removeEventListener("themechange", changeTheme)) -}) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts deleted file mode 100644 index 717f17f00..000000000 --- a/quartz/components/scripts/search.inline.ts +++ /dev/null @@ -1,540 +0,0 @@ -import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch" -import { ContentDetails } from "../../plugins/emitters/contentIndex" -import { registerEscapeHandler, removeAllChildren } from "./util" -import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" - -interface Item { - id: number - slug: FullSlug - title: string - content: string - tags: string[] - [key: string]: any -} - -// Can be expanded with things like "term" in the future -type SearchType = "basic" | "tags" -let searchType: SearchType = "basic" -let currentSearchTerm: string = "" -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 -} - -let index = new FlexSearch.Document({ - encode: encoder, - document: { - id: "id", - tag: "tags", - index: [ - { - field: "title", - tokenize: "forward", - }, - { - field: "content", - tokenize: "forward", - }, - { - field: "tags", - tokenize: "forward", - }, - ], - }, -}) - -const p = new DOMParser() -const fetchContentCache: Map = new Map() -const contextWindowWords = 30 -const numSearchResults = 8 -const numTagResults = 5 - -const tokenizeTerm = (term: string) => { - const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") - const tokenLen = tokens.length - if (tokenLen > 1) { - for (let i = 1; i < tokenLen; i++) { - tokens.push(tokens.slice(0, i + 1).join(" ")) - } - } - - return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first -} - -function highlight(searchTerm: string, text: string, trim?: boolean) { - const tokenizedTerms = tokenizeTerm(searchTerm) - let tokenizedText = text.split(/\s+/).filter((t) => t !== "") - - let startIndex = 0 - let endIndex = tokenizedText.length - 1 - if (trim) { - const includesCheck = (tok: string) => - tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase())) - const occurrencesIndices = tokenizedText.map(includesCheck) - - let bestSum = 0 - let bestIndex = 0 - for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) { - const window = occurrencesIndices.slice(i, i + contextWindowWords) - const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0) - if (windowSum >= bestSum) { - bestSum = windowSum - bestIndex = i - } - } - - startIndex = Math.max(bestIndex - contextWindowWords, 0) - endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1) - tokenizedText = tokenizedText.slice(startIndex, endIndex) - } - - const slice = tokenizedText - .map((tok) => { - // see if this tok is prefixed by any search terms - for (const searchTok of tokenizedTerms) { - if (tok.toLowerCase().includes(searchTok.toLowerCase())) { - const regex = new RegExp(searchTok.toLowerCase(), "gi") - return tok.replace(regex, `$&`) - } - } - return tok - }) - .join(" ") - - return `${startIndex === 0 ? "" : "..."}${slice}${ - endIndex === tokenizedText.length - 1 ? "" : "..." - }` -} - -function highlightHTML(searchTerm: string, el: HTMLElement) { - const p = new DOMParser() - const tokenizedTerms = tokenizeTerm(searchTerm) - const html = p.parseFromString(el.innerHTML, "text/html") - - const createHighlightSpan = (text: string) => { - const span = document.createElement("span") - span.className = "highlight" - span.textContent = text - return span - } - - const highlightTextNodes = (node: Node, term: string) => { - if (node.nodeType === Node.TEXT_NODE) { - const nodeText = node.nodeValue ?? "" - const regex = new RegExp(term.toLowerCase(), "gi") - const matches = nodeText.match(regex) - if (!matches || matches.length === 0) return - const spanContainer = document.createElement("span") - let lastIndex = 0 - for (const match of matches) { - const matchIndex = nodeText.indexOf(match, lastIndex) - spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex))) - spanContainer.appendChild(createHighlightSpan(match)) - lastIndex = matchIndex + match.length - } - spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex))) - node.parentNode?.replaceChild(spanContainer, node) - } else if (node.nodeType === Node.ELEMENT_NODE) { - if ((node as HTMLElement).classList.contains("highlight")) return - Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term)) - } - } - - for (const term of tokenizedTerms) { - highlightTextNodes(html.body, term) - } - - return html.body -} - -async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) { - const container = searchElement.querySelector(".search-container") as HTMLElement - if (!container) return - - const sidebar = container.closest(".sidebar") as HTMLElement | null - - const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement - if (!searchButton) return - - const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement - if (!searchBar) return - - const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement - if (!searchLayout) return - - const idDataMap = Object.keys(data) as FullSlug[] - const appendLayout = (el: HTMLElement) => { - searchLayout.appendChild(el) - } - - const enablePreview = searchLayout.dataset.preview === "true" - let preview: HTMLDivElement | undefined = undefined - let previewInner: HTMLDivElement | undefined = undefined - const results = document.createElement("div") - results.className = "results-container" - appendLayout(results) - - if (enablePreview) { - preview = document.createElement("div") - preview.className = "preview-container" - appendLayout(preview) - } - - function hideSearch() { - container.classList.remove("active") - searchBar.value = "" // clear the input when we dismiss the search - if (sidebar) sidebar.style.zIndex = "" - removeAllChildren(results) - if (preview) { - removeAllChildren(preview) - } - searchLayout.classList.remove("display-results") - searchType = "basic" // reset search type after closing - searchButton.focus() - } - - function showSearch(searchTypeNew: SearchType) { - searchType = searchTypeNew - if (sidebar) sidebar.style.zIndex = "1" - container.classList.add("active") - searchBar.focus() - } - - let currentHover: HTMLInputElement | null = null - async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { - if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { - e.preventDefault() - const searchBarOpen = container.classList.contains("active") - searchBarOpen ? hideSearch() : showSearch("basic") - return - } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { - // Hotkey to open tag search - e.preventDefault() - const searchBarOpen = container.classList.contains("active") - searchBarOpen ? hideSearch() : showSearch("tags") - - // add "#" prefix for tag search - searchBar.value = "#" - return - } - - if (currentHover) { - currentHover.classList.remove("focus") - } - - // If search is active, then we will render the first result and display accordingly - if (!container.classList.contains("active")) return - if (e.key === "Enter" && !e.isComposing) { - // If result has focus, navigate to that one, otherwise pick first result - if (results.contains(document.activeElement)) { - const active = document.activeElement as HTMLInputElement - if (active.classList.contains("no-match")) return - await displayPreview(active) - active.click() - } else { - const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null - if (!anchor || anchor.classList.contains("no-match")) return - await displayPreview(anchor) - anchor.click() - } - } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { - e.preventDefault() - if (results.contains(document.activeElement)) { - // If an element in results-container already has focus, focus previous one - const currentResult = currentHover - ? currentHover - : (document.activeElement as HTMLInputElement | null) - const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null - currentResult?.classList.remove("focus") - prevResult?.focus() - if (prevResult) currentHover = prevResult - await displayPreview(prevResult) - } - } else if (e.key === "ArrowDown" || e.key === "Tab") { - e.preventDefault() - // The results should already been focused, so we need to find the next one. - // The activeElement is the search bar, so we need to find the first result and focus it. - if (document.activeElement === searchBar || currentHover !== null) { - const firstResult = currentHover - ? currentHover - : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null) - const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null - firstResult?.classList.remove("focus") - secondResult?.focus() - if (secondResult) currentHover = secondResult - await displayPreview(secondResult) - } - } - } - - const formatForDisplay = (term: string, id: number) => { - const slug = idDataMap[id] - return { - id, - slug, - title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), - content: highlight(term, data[slug].content ?? "", true), - tags: highlightTags(term.substring(1), data[slug].tags), - } - } - - function highlightTags(term: string, tags: string[]) { - if (!tags || searchType !== "tags") { - return [] - } - - return tags - .map((tag) => { - if (tag.toLowerCase().includes(term.toLowerCase())) { - return `
  • #${tag}

  • ` - } else { - return `
  • #${tag}

  • ` - } - }) - .slice(0, numTagResults) - } - - function resolveUrl(slug: FullSlug): URL { - return new URL(resolveRelative(currentSlug, slug), location.toString()) - } - - const resultToHTML = ({ slug, title, content, tags }: Item) => { - const htmlTags = tags.length > 0 ? `
      ${tags.join("")}
    ` : `` - const itemTile = document.createElement("a") - itemTile.classList.add("result-card") - itemTile.id = slug - itemTile.href = resolveUrl(slug).toString() - itemTile.innerHTML = ` -

    ${title}

    - ${htmlTags} -

    ${content}

    - ` - itemTile.addEventListener("click", (event) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - hideSearch() - }) - - const handler = (event: MouseEvent) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - hideSearch() - } - - async function onMouseEnter(ev: MouseEvent) { - if (!ev.target) return - const target = ev.target as HTMLInputElement - await displayPreview(target) - } - - itemTile.addEventListener("mouseenter", onMouseEnter) - window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter)) - itemTile.addEventListener("click", handler) - window.addCleanup(() => itemTile.removeEventListener("click", handler)) - - return itemTile - } - - async function displayResults(finalResults: Item[]) { - removeAllChildren(results) - if (finalResults.length === 0) { - results.innerHTML = ` -

    No results.

    -

    Try another search term?

    -
    ` - } else { - results.append(...finalResults.map(resultToHTML)) - } - - if (finalResults.length === 0 && preview) { - // no results, clear previous preview - removeAllChildren(preview) - } else { - // focus on first result, then also dispatch preview immediately - const firstChild = results.firstElementChild as HTMLElement - firstChild.classList.add("focus") - currentHover = firstChild as HTMLInputElement - await displayPreview(firstChild) - } - } - - async function fetchContent(slug: FullSlug): Promise { - if (fetchContentCache.has(slug)) { - return fetchContentCache.get(slug) as Element[] - } - - const targetUrl = resolveUrl(slug).toString() - const contents = await fetch(targetUrl) - .then((res) => res.text()) - .then((contents) => { - if (contents === undefined) { - throw new Error(`Could not fetch ${targetUrl}`) - } - const html = p.parseFromString(contents ?? "", "text/html") - normalizeRelativeURLs(html, targetUrl) - return [...html.getElementsByClassName("popover-hint")] - }) - - fetchContentCache.set(slug, contents) - return contents - } - - async function displayPreview(el: HTMLElement | null) { - if (!searchLayout || !enablePreview || !el || !preview) return - const slug = el.id as FullSlug - const innerDiv = await fetchContent(slug).then((contents) => - contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]), - ) - previewInner = document.createElement("div") - previewInner.classList.add("preview-inner") - previewInner.append(...innerDiv) - preview.replaceChildren(previewInner) - - // scroll to longest - const highlights = [...preview.getElementsByClassName("highlight")].sort( - (a, b) => b.innerHTML.length - a.innerHTML.length, - ) - highlights[0]?.scrollIntoView({ block: "start" }) - } - - async function onType(e: HTMLElementEventMap["input"]) { - if (!searchLayout || !index) return - currentSearchTerm = (e.target as HTMLInputElement).value - searchLayout.classList.toggle("display-results", currentSearchTerm !== "") - searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic" - - let searchResults: DefaultDocumentSearchResults - if (searchType === "tags") { - currentSearchTerm = currentSearchTerm.substring(1).trim() - const separatorIndex = currentSearchTerm.indexOf(" ") - if (separatorIndex != -1) { - // search by title and content index and then filter by tag (implemented in flexsearch) - const tag = currentSearchTerm.substring(0, separatorIndex) - const query = currentSearchTerm.substring(separatorIndex + 1).trim() - searchResults = await index.searchAsync({ - query: query, - // return at least 10000 documents, so it is enough to filter them by tag (implemented in flexsearch) - limit: Math.max(numSearchResults, 10000), - index: ["title", "content"], - tag: { tags: tag }, - }) - for (let searchResult of searchResults) { - searchResult.result = searchResult.result.slice(0, numSearchResults) - } - // set search type to basic and remove tag from term for proper highlightning and scroll - searchType = "basic" - currentSearchTerm = query - } else { - // default search by tags index - searchResults = await index.searchAsync({ - query: currentSearchTerm, - limit: numSearchResults, - index: ["tags"], - }) - } - } else if (searchType === "basic") { - searchResults = await index.searchAsync({ - query: currentSearchTerm, - limit: numSearchResults, - index: ["title", "content"], - }) - } - - const getByField = (field: string): number[] => { - const results = searchResults.filter((x) => x.field === field) - return results.length === 0 ? [] : ([...results[0].result] as number[]) - } - - // order titles ahead of content - const allIds: Set = new Set([ - ...getByField("title"), - ...getByField("content"), - ...getByField("tags"), - ]) - const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)) - await displayResults(finalResults) - } - - document.addEventListener("keydown", shortcutHandler) - window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) - searchButton.addEventListener("click", () => showSearch("basic")) - window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic"))) - searchBar.addEventListener("input", onType) - window.addCleanup(() => searchBar.removeEventListener("input", onType)) - - registerEscapeHandler(container, hideSearch) - await fillDocument(data) -} - -/** - * Fills flexsearch document with data - * @param index index to fill - * @param data data to fill index with - */ -let indexPopulated = false -async function fillDocument(data: ContentIndex) { - if (indexPopulated) return - let id = 0 - const promises: Array> = [] - for (const [slug, fileData] of Object.entries(data)) { - promises.push( - index.addAsync(id++, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }), - ) - } - - await Promise.all(promises) - indexPopulated = true -} - -document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { - const currentSlug = e.detail.url - const data = await fetchData - const searchElement = document.getElementsByClassName("search") - for (const element of searchElement) { - await setupSearch(element, currentSlug, data) - } -}) diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts deleted file mode 100644 index 4148fa235..000000000 --- a/quartz/components/scripts/toc.inline.ts +++ /dev/null @@ -1,44 +0,0 @@ -const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const slug = entry.target.id - const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`) - const windowHeight = entry.rootBounds?.height - if (windowHeight && tocEntryElements.length > 0) { - if (entry.boundingClientRect.y < windowHeight) { - tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view")) - } else { - tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view")) - } - } - } -}) - -function toggleToc(this: HTMLElement) { - this.classList.toggle("collapsed") - this.setAttribute( - "aria-expanded", - this.getAttribute("aria-expanded") === "true" ? "false" : "true", - ) - const content = this.nextElementSibling as HTMLElement | undefined - if (!content) return - content.classList.toggle("collapsed") -} - -function setupToc() { - for (const toc of document.getElementsByClassName("toc")) { - const button = toc.querySelector(".toc-header") - const content = toc.querySelector(".toc-content") - if (!button || !content) return - button.addEventListener("click", toggleToc) - window.addCleanup(() => button.removeEventListener("click", toggleToc)) - } -} - -document.addEventListener("nav", () => { - setupToc() - - // update toc entry highlighting - observer.disconnect() - const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") - headers.forEach((header) => observer.observe(header)) -}) diff --git a/quartz/components/styles/backlinks.scss b/quartz/components/styles/backlinks.scss deleted file mode 100644 index 478e118d2..000000000 --- a/quartz/components/styles/backlinks.scss +++ /dev/null @@ -1,24 +0,0 @@ -@use "../../styles/variables.scss" as *; - -.backlinks { - flex-direction: column; - - & > h3 { - font-size: 1rem; - margin: 0; - } - - & > ul.overflow { - list-style: none; - padding: 0; - margin: 0.5rem 0; - max-height: calc(100% - 2rem); - overscroll-behavior: contain; - - & > li { - & > a { - background-color: transparent; - } - } - } -} diff --git a/quartz/components/styles/breadcrumbs.scss b/quartz/components/styles/breadcrumbs.scss deleted file mode 100644 index 789808baf..000000000 --- a/quartz/components/styles/breadcrumbs.scss +++ /dev/null @@ -1,22 +0,0 @@ -.breadcrumb-container { - margin: 0; - margin-top: 0.75rem; - padding: 0; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 0.5rem; -} - -.breadcrumb-element { - p { - margin: 0; - margin-left: 0.5rem; - padding: 0; - line-height: normal; - } - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; -} diff --git a/quartz/components/styles/legacyToc.scss b/quartz/components/styles/legacyToc.scss deleted file mode 100644 index 3513e9ffd..000000000 --- a/quartz/components/styles/legacyToc.scss +++ /dev/null @@ -1,27 +0,0 @@ -details.toc { - & summary { - cursor: pointer; - - &::marker { - color: var(--dark); - } - - & > * { - padding-left: 0.25rem; - display: inline-block; - margin: 0; - } - } - - & ul { - list-style: none; - margin: 0.5rem 1.25rem; - padding: 0; - } - - @for $i from 1 through 6 { - & .depth-#{$i} { - padding-left: calc(1rem * #{$i}); - } - } -} diff --git a/quartz/components/styles/recentNotes.scss b/quartz/components/styles/recentNotes.scss deleted file mode 100644 index 726767198..000000000 --- a/quartz/components/styles/recentNotes.scss +++ /dev/null @@ -1,24 +0,0 @@ -.recent-notes { - & > h3 { - margin: 0.5rem 0 0 0; - font-size: 1rem; - } - - & > ul.recent-ul { - list-style: none; - margin-top: 1rem; - padding-left: 0; - - & > li { - margin: 1rem 0; - .section > .desc > h3 > a { - background-color: transparent; - } - - .section > .meta { - margin: 0 0 0.5rem 0; - opacity: 0.6; - } - } - } -} diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss deleted file mode 100644 index 3c5994693..000000000 --- a/quartz/components/styles/search.scss +++ /dev/null @@ -1,242 +0,0 @@ -@use "../../styles/variables.scss" as *; - -.search { - min-width: fit-content; - max-width: 14rem; - @media all and ($mobile) { - flex-grow: 0.3; - } - - & > .search-button { - background-color: transparent; - border: 1px var(--lightgray) solid; - border-radius: 4px; - font-family: inherit; - font-size: inherit; - height: 2rem; - padding: 0 1rem 0 0; - display: flex; - align-items: center; - text-align: inherit; - cursor: pointer; - white-space: nowrap; - width: 100%; - - & > p { - display: inline; - color: var(--gray); - text-wrap: unset; - } - - & svg { - cursor: pointer; - width: 18px; - min-width: 18px; - margin: 0 0.5rem; - - .search-path { - stroke: var(--darkgray); - stroke-width: 1.5px; - transition: stroke 0.5s ease; - } - } - } - - & > .search-container { - position: fixed; - contain: layout; - z-index: 999; - left: 0; - top: 0; - width: 100vw; - height: 100vh; - overflow-y: auto; - display: none; - backdrop-filter: blur(4px); - - &.active { - display: inline-block; - } - - & > .search-space { - width: 65%; - margin-top: 12vh; - margin-left: auto; - margin-right: auto; - - @media all and not ($desktop) { - width: 90%; - } - - & > * { - width: 100%; - border-radius: 7px; - background: var(--light); - box-shadow: - 0 14px 50px rgba(27, 33, 48, 0.12), - 0 10px 30px rgba(27, 33, 48, 0.16); - margin-bottom: 2em; - } - - & > input { - box-sizing: border-box; - padding: 0.5em 1em; - font-family: var(--bodyFont); - color: var(--dark); - font-size: 1.1em; - border: 1px solid var(--lightgray); - - &:focus { - outline: none; - } - } - - & > .search-layout { - display: none; - flex-direction: row; - border: 1px solid var(--lightgray); - flex: 0 0 100%; - box-sizing: border-box; - - &.display-results { - display: flex; - } - - &[data-preview] > .results-container { - flex: 0 0 min(30%, 450px); - } - - @media all and not ($mobile) { - &[data-preview] { - & .result-card > p.preview { - display: none; - } - - & > div { - &:first-child { - border-right: 1px solid var(--lightgray); - border-top-right-radius: unset; - border-bottom-right-radius: unset; - } - - &:last-child { - border-top-left-radius: unset; - border-bottom-left-radius: unset; - } - } - } - } - - & > div { - height: calc(75vh - 12vh); - border-radius: 5px; - } - - @media all and ($mobile) { - flex-direction: column; - - & > .preview-container { - display: none !important; - } - - &[data-preview] > .results-container { - width: 100%; - height: auto; - flex: 0 0 100%; - } - } - - & .highlight { - background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0)); - border-radius: 5px; - scroll-margin-top: 2rem; - } - - & > .preview-container { - flex-grow: 1; - display: block; - overflow: hidden; - font-family: inherit; - color: var(--dark); - line-height: 1.5em; - font-weight: $normalWeight; - overflow-y: auto; - padding: 0 2rem; - - & .preview-inner { - margin: 0 auto; - width: min($pageWidth, 100%); - } - - a[role="anchor"] { - background-color: transparent; - } - } - - & > .results-container { - overflow-y: auto; - - & .result-card { - overflow: hidden; - padding: 1em; - cursor: pointer; - transition: background 0.2s ease; - border-bottom: 1px solid var(--lightgray); - width: 100%; - display: block; - box-sizing: border-box; - - // normalize card props - font-family: inherit; - font-size: 100%; - line-height: 1.15; - margin: 0; - text-transform: none; - text-align: left; - outline: none; - font-weight: inherit; - - &:hover, - &:focus, - &.focus { - background: var(--lightgray); - } - - & > h3 { - margin: 0; - } - - @media all and not ($mobile) { - & > p.card-description { - display: none; - } - } - - & > ul.tags { - margin-top: 0.45rem; - margin-bottom: 0; - } - - & > ul > li > p { - border-radius: 8px; - background-color: var(--highlight); - padding: 0.2rem 0.4rem; - margin: 0 0.1rem; - line-height: 1.4rem; - font-weight: $boldWeight; - color: var(--secondary); - - &.match-tag { - color: var(--tertiary); - } - } - - & > p { - margin-bottom: 0; - } - } - } - } - } - } -} diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss deleted file mode 100644 index 6a7723bdc..000000000 --- a/quartz/components/styles/toc.scss +++ /dev/null @@ -1,66 +0,0 @@ -@use "../../styles/variables.scss" as *; - -.toc { - display: flex; - flex-direction: column; - overflow-y: hidden; - min-height: 1.4rem; - flex: 0 0.5 auto; - &:has(button.toc-header.collapsed) { - flex: 0 1 1.4rem; - } -} - -button.toc-header { - background-color: transparent; - border: none; - text-align: left; - cursor: pointer; - padding: 0; - color: var(--dark); - display: flex; - align-items: center; - - & h3 { - font-size: 1rem; - display: inline-block; - margin: 0; - } - - & .fold { - margin-left: 0.5rem; - transition: transform 0.3s ease; - opacity: 0.8; - } - - &.collapsed .fold { - transform: rotateZ(-90deg); - } -} - -ul.toc-content.overflow { - list-style: none; - position: relative; - margin: 0.5rem 0; - padding: 0; - max-height: calc(100% - 2rem); - overscroll-behavior: contain; - list-style: none; - - & > li > a { - color: var(--dark); - opacity: 0.35; - transition: - 0.5s ease opacity, - 0.3s ease color; - &.in-view { - opacity: 0.75; - } - } - - @for $i from 0 through 6 { - & .depth-#{$i} { - padding-left: calc(1rem * #{$i}); - } - } -}