mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-23 06:25:41 -05:00
refactor: delete 6 internal component duplicates (Phase A)
Remove Backlinks, Breadcrumbs, RecentNotes, Search, TableOfContents, Comments, and OverflowList — all replaced by community plugins. Delete associated styles (6) and scripts (3). Switch layout to use Plugin.Breadcrumbs() instead of Component.Breadcrumbs().
This commit is contained in:
parent
4c2b48ba5c
commit
e6d3695657
@ -15,6 +15,7 @@ const tagListComponent = Plugin.TagList() as QuartzComponent
|
|||||||
const pageTitleComponent = Plugin.PageTitle() as QuartzComponent
|
const pageTitleComponent = Plugin.PageTitle() as QuartzComponent
|
||||||
const darkmodeComponent = Plugin.Darkmode() as QuartzComponent
|
const darkmodeComponent = Plugin.Darkmode() as QuartzComponent
|
||||||
const readerModeComponent = Plugin.ReaderMode() as QuartzComponent
|
const readerModeComponent = Plugin.ReaderMode() as QuartzComponent
|
||||||
|
const breadcrumbsComponent = Plugin.Breadcrumbs() as QuartzComponent
|
||||||
|
|
||||||
// components shared across all pages
|
// components shared across all pages
|
||||||
export const sharedPageComponents: SharedLayout = {
|
export const sharedPageComponents: SharedLayout = {
|
||||||
@ -37,7 +38,7 @@ export const sharedPageComponents: SharedLayout = {
|
|||||||
export const defaultContentPageLayout: PageLayout = {
|
export const defaultContentPageLayout: PageLayout = {
|
||||||
beforeBody: [
|
beforeBody: [
|
||||||
Component.ConditionalRender({
|
Component.ConditionalRender({
|
||||||
component: Component.Breadcrumbs(),
|
component: breadcrumbsComponent,
|
||||||
condition: (page) => page.fileData.slug !== "index",
|
condition: (page) => page.fileData.slug !== "index",
|
||||||
}),
|
}),
|
||||||
articleTitleComponent,
|
articleTitleComponent,
|
||||||
@ -64,7 +65,7 @@ export const defaultContentPageLayout: PageLayout = {
|
|||||||
|
|
||||||
// components for pages that display lists of pages (e.g. tags or folders)
|
// components for pages that display lists of pages (e.g. tags or folders)
|
||||||
export const defaultListPageLayout: PageLayout = {
|
export const defaultListPageLayout: PageLayout = {
|
||||||
beforeBody: [Component.Breadcrumbs(), articleTitleComponent, contentMetaComponent],
|
beforeBody: [breadcrumbsComponent, articleTitleComponent, contentMetaComponent],
|
||||||
left: [
|
left: [
|
||||||
pageTitleComponent,
|
pageTitleComponent,
|
||||||
Component.MobileOnly(Component.Spacer()),
|
Component.MobileOnly(Component.Spacer()),
|
||||||
|
|||||||
@ -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<BacklinksOptions>) => {
|
|
||||||
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 (
|
|
||||||
<div class={classNames(displayClass, "backlinks")}>
|
|
||||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
|
||||||
<OverflowList>
|
|
||||||
{backlinkFiles.length > 0 ? (
|
|
||||||
backlinkFiles.map((f) => (
|
|
||||||
<li>
|
|
||||||
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
|
|
||||||
{f.frontmatter?.title}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
|
||||||
)}
|
|
||||||
</OverflowList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Backlinks.css = style
|
|
||||||
Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
|
|
||||||
|
|
||||||
return Backlinks
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -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<BreadcrumbOptions>) => {
|
|
||||||
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 (
|
|
||||||
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
|
||||||
{crumbs.map((crumb, index) => (
|
|
||||||
<div class="breadcrumb-element">
|
|
||||||
<a href={crumb.path}>{crumb.displayName}</a>
|
|
||||||
{index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Breadcrumbs.css = breadcrumbsStyle
|
|
||||||
|
|
||||||
return Breadcrumbs
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -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 (
|
|
||||||
<div
|
|
||||||
class={classNames(displayClass, "giscus")}
|
|
||||||
data-repo={opts.options.repo}
|
|
||||||
data-repo-id={opts.options.repoId}
|
|
||||||
data-category={opts.options.category}
|
|
||||||
data-category-id={opts.options.categoryId}
|
|
||||||
data-mapping={opts.options.mapping ?? "url"}
|
|
||||||
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
|
||||||
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
|
||||||
data-input-position={opts.options.inputPosition ?? "bottom"}
|
|
||||||
data-light-theme={opts.options.lightTheme ?? "light"}
|
|
||||||
data-dark-theme={opts.options.darkTheme ?? "dark"}
|
|
||||||
data-theme-url={
|
|
||||||
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
|
|
||||||
}
|
|
||||||
data-lang={opts.options.lang ?? "en"}
|
|
||||||
></div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Comments.afterDOMLoaded = script
|
|
||||||
|
|
||||||
return Comments
|
|
||||||
}) satisfies QuartzComponentConstructor<Options>
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { JSX } from "preact"
|
|
||||||
|
|
||||||
const OverflowList = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
|
||||||
return (
|
|
||||||
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
|
|
||||||
{children}
|
|
||||||
<li class="overflow-end" />
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let numLists = 0
|
|
||||||
export default () => {
|
|
||||||
const id = `list-${numLists++}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
|
||||||
<OverflowList {...props} id={id} />
|
|
||||||
),
|
|
||||||
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())
|
|
||||||
})
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<Options>) => {
|
|
||||||
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 (
|
|
||||||
<div class={classNames(displayClass, "recent-notes")}>
|
|
||||||
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
|
||||||
<ul class="recent-ul">
|
|
||||||
{pages.slice(0, opts.limit).map((page) => {
|
|
||||||
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
|
||||||
const tags = page.frontmatter?.tags ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li class="recent-li">
|
|
||||||
<div class="section">
|
|
||||||
<div class="desc">
|
|
||||||
<h3>
|
|
||||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
|
||||||
{title}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{page.dates && (
|
|
||||||
<p class="meta">
|
|
||||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{opts.showTags && (
|
|
||||||
<ul class="tags">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="internal tag-link"
|
|
||||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
{opts.linkToMore && remaining > 0 && (
|
|
||||||
<p>
|
|
||||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
|
||||||
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecentNotes.css = style
|
|
||||||
return RecentNotes
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -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<SearchOptions>) => {
|
|
||||||
const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
|
||||||
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
|
||||||
return (
|
|
||||||
<div class={classNames(displayClass, "search")}>
|
|
||||||
<button class="search-button">
|
|
||||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
|
|
||||||
<title>Search</title>
|
|
||||||
<g class="search-path" fill="none">
|
|
||||||
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
|
|
||||||
<circle cx="8" cy="8" r="7" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<p>{i18n(cfg.locale).components.search.title}</p>
|
|
||||||
</button>
|
|
||||||
<div class="search-container">
|
|
||||||
<div class="search-space">
|
|
||||||
<input
|
|
||||||
autocomplete="off"
|
|
||||||
class="search-bar"
|
|
||||||
name="search"
|
|
||||||
type="text"
|
|
||||||
aria-label={searchPlaceholder}
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
/>
|
|
||||||
<div class="search-layout" data-preview={opts.enablePreview}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Search.afterDOMLoaded = script
|
|
||||||
Search.css = style
|
|
||||||
|
|
||||||
return Search
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -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<Options>) => {
|
|
||||||
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 (
|
|
||||||
<div class={classNames(displayClass, "toc")}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
|
|
||||||
aria-controls={id}
|
|
||||||
aria-expanded={!fileData.collapseToc}
|
|
||||||
>
|
|
||||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="fold"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<OverflowList
|
|
||||||
id={id}
|
|
||||||
class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}
|
|
||||||
>
|
|
||||||
{fileData.toc.map((tocEntry) => (
|
|
||||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
|
||||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
|
||||||
{tocEntry.text}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</OverflowList>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TableOfContents.css = modernStyle
|
|
||||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
|
||||||
|
|
||||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
|
||||||
if (!fileData.toc) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<details class="toc" open={!fileData.collapseToc}>
|
|
||||||
<summary>
|
|
||||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
|
||||||
</summary>
|
|
||||||
<ul>
|
|
||||||
{fileData.toc.map((tocEntry) => (
|
|
||||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
|
||||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
|
||||||
{tocEntry.text}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LegacyTableOfContents.css = legacyStyle
|
|
||||||
|
|
||||||
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -4,14 +4,8 @@ import FolderContent from "./pages/FolderContent"
|
|||||||
import NotFound from "./pages/404"
|
import NotFound from "./pages/404"
|
||||||
import Head from "./Head"
|
import Head from "./Head"
|
||||||
import Spacer from "./Spacer"
|
import Spacer from "./Spacer"
|
||||||
import TableOfContents from "./TableOfContents"
|
|
||||||
import Backlinks from "./Backlinks"
|
|
||||||
import Search from "./Search"
|
|
||||||
import DesktopOnly from "./DesktopOnly"
|
import DesktopOnly from "./DesktopOnly"
|
||||||
import MobileOnly from "./MobileOnly"
|
import MobileOnly from "./MobileOnly"
|
||||||
import RecentNotes from "./RecentNotes"
|
|
||||||
import Breadcrumbs from "./Breadcrumbs"
|
|
||||||
import Comments from "./Comments"
|
|
||||||
import Flex from "./Flex"
|
import Flex from "./Flex"
|
||||||
import ConditionalRender from "./ConditionalRender"
|
import ConditionalRender from "./ConditionalRender"
|
||||||
|
|
||||||
@ -26,15 +20,9 @@ export {
|
|||||||
FolderContent,
|
FolderContent,
|
||||||
Head,
|
Head,
|
||||||
Spacer,
|
Spacer,
|
||||||
TableOfContents,
|
|
||||||
Backlinks,
|
|
||||||
Search,
|
|
||||||
DesktopOnly,
|
DesktopOnly,
|
||||||
MobileOnly,
|
MobileOnly,
|
||||||
RecentNotes,
|
|
||||||
NotFound,
|
NotFound,
|
||||||
Breadcrumbs,
|
|
||||||
Comments,
|
|
||||||
Flex,
|
Flex,
|
||||||
ConditionalRender,
|
ConditionalRender,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<HTMLElement, "dataset"> & {
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
@ -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<Item>({
|
|
||||||
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<FullSlug, Element[]> = 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, `<span class="highlight">$&</span>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 `<li><p class="match-tag">#${tag}</p></li>`
|
|
||||||
} else {
|
|
||||||
return `<li><p>#${tag}</p></li>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
|
||||||
const itemTile = document.createElement("a")
|
|
||||||
itemTile.classList.add("result-card")
|
|
||||||
itemTile.id = slug
|
|
||||||
itemTile.href = resolveUrl(slug).toString()
|
|
||||||
itemTile.innerHTML = `
|
|
||||||
<h3 class="card-title">${title}</h3>
|
|
||||||
${htmlTags}
|
|
||||||
<p class="card-description">${content}</p>
|
|
||||||
`
|
|
||||||
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 = `<a class="result-card no-match">
|
|
||||||
<h3>No results.</h3>
|
|
||||||
<p>Try another search term?</p>
|
|
||||||
</a>`
|
|
||||||
} 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<Element[]> {
|
|
||||||
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<Item>
|
|
||||||
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<number> = 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<Promise<unknown>> = []
|
|
||||||
for (const [slug, fileData] of Object.entries<ContentDetails>(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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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))
|
|
||||||
})
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user