Initial commit
This commit is contained in:
commit
3f501820b1
173 changed files with 24001 additions and 0 deletions
28
quartz/components/ArticleTitle.tsx
Executable file
28
quartz/components/ArticleTitle.tsx
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
|
||||
// Hide on specific pages
|
||||
if (
|
||||
fileData.slug === "index" ||
|
||||
fileData.slug === "link"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const title = fileData.frontmatter?.title
|
||||
if (title) {
|
||||
return <p class={classNames(displayClass, "article-title")} style="opacity: 0.3">{title}</p>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
||||
70
quartz/components/Backlinks.tsx
Executable file
70
quartz/components/Backlinks.tsx
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
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"
|
||||
|
||||
interface BacklinksOptions {
|
||||
hideWhenEmpty: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BacklinksOptions = {
|
||||
hideWhenEmpty: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
|
||||
// Hide on specific pages
|
||||
if (
|
||||
fileData.slug === "index" ||
|
||||
fileData.slug === "about-me" ||
|
||||
fileData.slug === "about-site" ||
|
||||
fileData.slug === "diary-list" ||
|
||||
fileData.slug === "folder-list" ||
|
||||
fileData.slug === "free-course" ||
|
||||
fileData.slug === "net-journey" ||
|
||||
fileData.slug === "net-manifesto" ||
|
||||
fileData.slug === "support-me" ||
|
||||
fileData.slug === "tags-list" ||
|
||||
fileData.slug === "link"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
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>
|
||||
<ul class="overflow">
|
||||
{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>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Backlinks.css = style
|
||||
|
||||
return Backlinks
|
||||
}) satisfies QuartzComponentConstructor
|
||||
13
quartz/components/Body.tsx
Executable file
13
quartz/components/Body.tsx
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
// @ts-ignore
|
||||
import clipboardScript from "./scripts/clipboard.inline"
|
||||
import clipboardStyle from "./styles/clipboard.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return <div id="quartz-body">{children}</div>
|
||||
}
|
||||
|
||||
Body.afterDOMLoaded = clipboardScript
|
||||
Body.css = clipboardStyle
|
||||
|
||||
export default (() => Body) satisfies QuartzComponentConstructor
|
||||
139
quartz/components/Breadcrumbs.tsx
Executable file
139
quartz/components/Breadcrumbs.tsx
Executable file
|
|
@ -0,0 +1,139 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
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 breadcrumbs on root `index.md`
|
||||
*/
|
||||
hideOnRoot: boolean
|
||||
/**
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BreadcrumbOptions = {
|
||||
spacerSymbol: "❯",
|
||||
rootName: "Home",
|
||||
resolveFrontmatterTitle: true,
|
||||
hideOnRoot: 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>) => {
|
||||
// Merge options with defaults
|
||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
// computed index of folder name to its associated file data
|
||||
let folderIndex: Map<string, QuartzPluginData> | undefined
|
||||
|
||||
const Breadcrumbs: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
}: QuartzComponentProps) => {
|
||||
// Hide crumbs on root if enabled
|
||||
if (options.hideOnRoot && fileData.slug === "index") {
|
||||
return <></>
|
||||
}
|
||||
|
||||
// Format entry for root element
|
||||
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
|
||||
const crumbs: CrumbData[] = [firstEntry]
|
||||
|
||||
if (!folderIndex && options.resolveFrontmatterTitle) {
|
||||
folderIndex = new Map()
|
||||
// construct the index for the first time
|
||||
for (const file of allFiles) {
|
||||
const folderParts = file.slug?.split("/")
|
||||
if (folderParts?.at(-1) === "index") {
|
||||
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split slug into hierarchy/parts
|
||||
const slugParts = fileData.slug?.split("/")
|
||||
if (slugParts) {
|
||||
// is tag breadcrumb?
|
||||
const isTagPath = slugParts[0] === "tags"
|
||||
|
||||
// full path until current part
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < slugParts.length - 1; i++) {
|
||||
let curPathSegment = slugParts[i]
|
||||
|
||||
// Try to resolve frontmatter folder title
|
||||
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
|
||||
if (currentFile) {
|
||||
const title = currentFile.frontmatter!.title
|
||||
if (title !== "index") {
|
||||
curPathSegment = title
|
||||
}
|
||||
}
|
||||
|
||||
// Add current slug to full path
|
||||
currentPath = joinSegments(currentPath, slugParts[i])
|
||||
const includeTrailingSlash = !isTagPath || i < 1
|
||||
|
||||
// Format and add current crumb
|
||||
const crumb = formatCrumb(
|
||||
curPathSegment,
|
||||
fileData.slug!,
|
||||
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
|
||||
)
|
||||
crumbs.push(crumb)
|
||||
}
|
||||
|
||||
// Add current file to crumb (can directly use frontmatter title)
|
||||
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||
crumbs.push({
|
||||
displayName: fileData.frontmatter!.title,
|
||||
path: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
60
quartz/components/Comments.tsx
Executable file
60
quartz/components/Comments.tsx
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
Comments.afterDOMLoaded = script
|
||||
|
||||
return Comments
|
||||
}) satisfies QuartzComponentConstructor<Options>
|
||||
58
quartz/components/ContentMeta.tsx
Executable file
58
quartz/components/ContentMeta.tsx
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { JSX } from "preact"
|
||||
import style from "./styles/contentMeta.scss"
|
||||
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
showComma: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
showComma: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: (string | JSX.Element)[] = []
|
||||
|
||||
// if (fileData.dates) {
|
||||
// segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||
// }
|
||||
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { minutes, words: _words } = readingTime(text)
|
||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||
minutes: Math.ceil(minutes),
|
||||
})
|
||||
segments.push(<span>{displayedTime}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||
{segments}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ContentMetadata.css = style
|
||||
|
||||
return ContentMetadata
|
||||
}) satisfies QuartzComponentConstructor
|
||||
50
quartz/components/Darkmode.tsx
Executable file
50
quartz/components/Darkmode.tsx
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
// @ts-ignore: this is safe, we don't want to actually make darkmode.inline.ts a module as
|
||||
// modules are automatically deferred and we don't want that to happen for critical beforeDOMLoads
|
||||
// see: https://v8.dev/features/modules#defer
|
||||
import darkmodeScript from "./scripts/darkmode.inline"
|
||||
import styles from "./styles/darkmode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "darkmode")} id="darkmode">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="dayIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 35 35"
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
|
||||
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="nightIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
|
||||
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Darkmode.beforeDOMLoaded = darkmodeScript
|
||||
Darkmode.css = styles
|
||||
|
||||
export default (() => Darkmode) satisfies QuartzComponentConstructor
|
||||
31
quartz/components/Date.tsx
Executable file
31
quartz/components/Date.tsx
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
import { GlobalConfiguration } from "../cfg"
|
||||
import { ValidLocale } from "../i18n"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface Props {
|
||||
date: Date
|
||||
locale?: ValidLocale
|
||||
}
|
||||
|
||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||
|
||||
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
||||
if (!cfg.defaultDateType) {
|
||||
throw new Error(
|
||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||
)
|
||||
}
|
||||
return data.dates?.[cfg.defaultDateType]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||
return d.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function Date({ date, locale }: Props) {
|
||||
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||
}
|
||||
18
quartz/components/DesktopOnly.tsx
Executable file
18
quartz/components/DesktopOnly.tsx
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component?: QuartzComponent) => {
|
||||
if (component) {
|
||||
const Component = component
|
||||
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="desktop-only" {...props} />
|
||||
}
|
||||
|
||||
DesktopOnly.displayName = component.displayName
|
||||
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
DesktopOnly.css = component?.css
|
||||
return DesktopOnly
|
||||
} else {
|
||||
return () => <></>
|
||||
}
|
||||
}) satisfies QuartzComponentConstructor
|
||||
128
quartz/components/Explorer.tsx
Executable file
128
quartz/components/Explorer.tsx
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import explorerStyle from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
||||
const defaultOptions = {
|
||||
folderClickBehavior: "collapse",
|
||||
folderDefaultState: "collapsed",
|
||||
useSavedState: true,
|
||||
mapFn: (node) => {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
if ((!a.file && !b.file) || (a.file && b.file)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
})
|
||||
}
|
||||
|
||||
if (a.file && !b.file) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
},
|
||||
filterFn: (node) => node.name !== "tags",
|
||||
order: ["filter", "map", "sort"],
|
||||
} satisfies Options
|
||||
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
// Parse config
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
|
||||
// memoized
|
||||
let fileTree: FileNode
|
||||
let jsonTree: string
|
||||
let lastBuildId: string = ""
|
||||
|
||||
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||
// Construct tree from allFiles
|
||||
fileTree = new FileNode("")
|
||||
allFiles.forEach((file) => fileTree.add(file))
|
||||
|
||||
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
||||
if (opts.order) {
|
||||
// Order is important, use loop with index instead of order.map()
|
||||
for (let i = 0; i < opts.order.length; i++) {
|
||||
const functionName = opts.order[i]
|
||||
if (functionName === "map") {
|
||||
fileTree.map(opts.mapFn)
|
||||
} else if (functionName === "sort") {
|
||||
fileTree.sort(opts.sortFn)
|
||||
} else if (functionName === "filter") {
|
||||
fileTree.filter(opts.filterFn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all folders of tree. Initialize with collapsed state
|
||||
// Stringify to pass json tree as data attribute ([data-tree])
|
||||
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||
jsonTree = JSON.stringify(folders)
|
||||
}
|
||||
|
||||
const Explorer: QuartzComponent = ({
|
||||
ctx,
|
||||
cfg,
|
||||
allFiles,
|
||||
displayClass,
|
||||
fileData,
|
||||
}: QuartzComponentProps) => {
|
||||
if (ctx.buildId !== lastBuildId) {
|
||||
lastBuildId = ctx.buildId
|
||||
constructFileTree(allFiles)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={classNames(displayClass, "explorer")}>
|
||||
<button
|
||||
type="button"
|
||||
id="explorer"
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
aria-controls="explorer-content"
|
||||
aria-expanded={opts.folderDefaultState === "open"}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="5 8 14 8"
|
||||
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>
|
||||
<div id="explorer-content">
|
||||
<ul class="overflow" id="explorer-ul">
|
||||
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
|
||||
<li id="explorer-end" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = explorerStyle
|
||||
Explorer.afterDOMLoaded = script
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
242
quartz/components/ExplorerNode.tsx
Executable file
242
quartz/components/ExplorerNode.tsx
Executable file
|
|
@ -0,0 +1,242 @@
|
|||
// @ts-ignore
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import {
|
||||
joinSegments,
|
||||
resolveRelative,
|
||||
clone,
|
||||
simplifySlug,
|
||||
SimpleSlug,
|
||||
FilePath,
|
||||
} from "../util/path"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileNode, b: FileNode) => number
|
||||
filterFn: (node: FileNode) => boolean
|
||||
mapFn: (node: FileNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
type DataWrapper = {
|
||||
file: QuartzPluginData
|
||||
path: string[]
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
|
||||
if (!fp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return fp.split("/").at(idx)
|
||||
}
|
||||
|
||||
// Structure to add all files into a tree
|
||||
export class FileNode {
|
||||
children: Array<FileNode>
|
||||
name: string // this is the slug segment
|
||||
displayName: string
|
||||
file: QuartzPluginData | null
|
||||
depth: number
|
||||
|
||||
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
|
||||
this.children = []
|
||||
this.name = slugSegment
|
||||
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
|
||||
this.file = file ? clone(file) : null
|
||||
this.depth = depth ?? 0
|
||||
}
|
||||
|
||||
private insert(fileData: DataWrapper) {
|
||||
if (fileData.path.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSegment = fileData.path[0]
|
||||
|
||||
// base case, insert here
|
||||
if (fileData.path.length === 1) {
|
||||
if (nextSegment === "") {
|
||||
// index case (we are the root and we just found index.md), set our data appropriately
|
||||
const title = fileData.file.frontmatter?.title
|
||||
if (title && title !== "index") {
|
||||
this.displayName = title
|
||||
}
|
||||
} else {
|
||||
// direct child
|
||||
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// find the right child to insert into
|
||||
fileData.path = fileData.path.splice(1)
|
||||
const child = this.children.find((c) => c.name === nextSegment)
|
||||
if (child) {
|
||||
child.insert(fileData)
|
||||
return
|
||||
}
|
||||
|
||||
const newChild = new FileNode(
|
||||
nextSegment,
|
||||
getPathSegment(fileData.file.relativePath, this.depth),
|
||||
undefined,
|
||||
this.depth + 1,
|
||||
)
|
||||
newChild.insert(fileData)
|
||||
this.children.push(newChild)
|
||||
}
|
||||
|
||||
// Add new file to tree
|
||||
add(file: QuartzPluginData) {
|
||||
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||
* @param filterFn function to filter tree with
|
||||
*/
|
||||
filter(filterFn: (node: FileNode) => boolean) {
|
||||
this.children = this.children.filter(filterFn)
|
||||
this.children.forEach((child) => child.filter(filterFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
|
||||
* @param mapFn function to use for mapping over tree
|
||||
*/
|
||||
map(mapFn: (node: FileNode) => void) {
|
||||
mapFn(this)
|
||||
this.children.forEach((child) => child.map(mapFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder representation with state of tree.
|
||||
* Intended to only be called on root node before changes to the tree are made
|
||||
* @param collapsed default state of folders (collapsed by default or not)
|
||||
* @returns array containing folder state for tree
|
||||
*/
|
||||
getFolderPaths(collapsed: boolean): FolderState[] {
|
||||
const folderPaths: FolderState[] = []
|
||||
|
||||
const traverse = (node: FileNode, currentPath: string) => {
|
||||
if (!node.file) {
|
||||
const folderPath = joinSegments(currentPath, node.name)
|
||||
if (folderPath !== "") {
|
||||
folderPaths.push({ path: folderPath, collapsed })
|
||||
}
|
||||
|
||||
node.children.forEach((child) => traverse(child, folderPath))
|
||||
}
|
||||
}
|
||||
|
||||
traverse(this, "")
|
||||
return folderPaths
|
||||
}
|
||||
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
/**
|
||||
* Sorts tree according to sort/compare function
|
||||
* @param sortFn compare function used for `.sort()`, also used recursively for children
|
||||
*/
|
||||
sort(sortFn: (a: FileNode, b: FileNode) => number) {
|
||||
this.children = this.children.sort(sortFn)
|
||||
this.children.forEach((e) => e.sort(sortFn))
|
||||
}
|
||||
}
|
||||
|
||||
type ExplorerNodeProps = {
|
||||
node: FileNode
|
||||
opts: Options
|
||||
fileData: QuartzPluginData
|
||||
fullPath?: string
|
||||
}
|
||||
|
||||
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
|
||||
// Get options
|
||||
const folderBehavior = opts.folderClickBehavior
|
||||
const isDefaultOpen = opts.folderDefaultState === "open"
|
||||
|
||||
// Calculate current folderPath
|
||||
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
|
||||
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
|
||||
|
||||
return (
|
||||
<>
|
||||
{node.file ? (
|
||||
// Single file node
|
||||
<li key={node.file.slug}>
|
||||
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
|
||||
{node.displayName}
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
{node.name !== "" && (
|
||||
// Node with entire folder
|
||||
// Render svg button + folder name, then children
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
||||
<div key={node.name} data-folderpath={folderPath}>
|
||||
{folderBehavior === "link" ? (
|
||||
<a href={href} data-for={node.name} class="folder-title">
|
||||
{node.displayName}
|
||||
</a>
|
||||
) : (
|
||||
<button class="folder-button">
|
||||
<span class="folder-title">{node.displayName}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Recursively render children of folder */}
|
||||
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
|
||||
<ul
|
||||
// Inline style for left folder paddings
|
||||
style={{
|
||||
paddingLeft: node.name !== "" ? "1.4rem" : "0",
|
||||
}}
|
||||
class="content"
|
||||
data-folderul={folderPath}
|
||||
>
|
||||
{node.children.map((childNode, i) => (
|
||||
<ExplorerNode
|
||||
node={childNode}
|
||||
key={i}
|
||||
opts={opts}
|
||||
fullPath={folderPath}
|
||||
fileData={fileData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
quartz/components/Footer.tsx
Executable file
33
quartz/components/Footer.tsx
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<p>
|
||||
{/* {i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year} */}
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(links).map(([text, link]) => (
|
||||
<li>
|
||||
<a href={link}>{text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.css = style
|
||||
return Footer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
106
quartz/components/Graph.tsx
Executable file
106
quartz/components/Graph.tsx
Executable file
|
|
@ -0,0 +1,106 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
zoom: boolean
|
||||
depth: number
|
||||
scale: number
|
||||
repelForce: number
|
||||
centerForce: number
|
||||
linkDistance: number
|
||||
fontSize: number
|
||||
opacityScale: number
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config> | undefined
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.1,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 0.9,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
{/* <h3>{i18n(cfg.locale).components.graph.title}</h3> */}
|
||||
<div class="graph-outer">
|
||||
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<button id="global-graph-icon" aria-label="Global Graph">
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 55 55"
|
||||
fill="currentColor"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
|
||||
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
|
||||
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
|
||||
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
|
||||
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
|
||||
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
|
||||
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
|
||||
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
|
||||
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
|
||||
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
|
||||
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="global-graph-outer">
|
||||
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
||||
106
quartz/components/GraphMobile.tsx
Executable file
106
quartz/components/GraphMobile.tsx
Executable file
|
|
@ -0,0 +1,106 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
zoom: boolean
|
||||
depth: number
|
||||
scale: number
|
||||
repelForce: number
|
||||
centerForce: number
|
||||
linkDistance: number
|
||||
fontSize: number
|
||||
opacityScale: number
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config> | undefined
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.1,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 0.9,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
{/* <h3>{i18n(cfg.locale).components.graph.title}</h3> */}
|
||||
<div class="graph-outer">
|
||||
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<button id="global-graph-icon" aria-label="Global Graph">
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 55 55"
|
||||
fill="currentColor"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
|
||||
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
|
||||
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
|
||||
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
|
||||
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
|
||||
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
|
||||
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
|
||||
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
|
||||
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
|
||||
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
|
||||
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="global-graph-outer">
|
||||
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
||||
209
quartz/components/Head.tsx
Executable file
209
quartz/components/Head.tsx
Executable file
|
|
@ -0,0 +1,209 @@
|
|||
import { i18n } from "../i18n"
|
||||
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { googleFontHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import satori, { SatoriOptions } from "satori"
|
||||
import fs from "fs"
|
||||
import sharp from "sharp"
|
||||
import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
|
||||
/**
|
||||
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
|
||||
* @param opts options for generating image
|
||||
*/
|
||||
async function generateSocialImage(
|
||||
{ cfg, description, fileName, fontsPromise, title, fileData }: ImageOptions,
|
||||
userOpts: SocialImageOptions,
|
||||
imageDir: string,
|
||||
) {
|
||||
const fonts = await fontsPromise
|
||||
const { width, height } = userOpts
|
||||
|
||||
// JSX that will be used to generate satori svg
|
||||
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
|
||||
|
||||
const svg = await satori(imageComponent, { width, height, fonts })
|
||||
|
||||
// Convert svg directly to webp (with additional compression)
|
||||
const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer()
|
||||
|
||||
// Write to file system
|
||||
const filePath = joinSegments(imageDir, `${fileName}.${extension}`)
|
||||
fs.writeFileSync(filePath, compressed)
|
||||
}
|
||||
|
||||
const extension = "webp"
|
||||
|
||||
const defaultOptions: SocialImageOptions = {
|
||||
colorScheme: "lightMode",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
imageStructure: defaultImage,
|
||||
excludeRoot: false,
|
||||
}
|
||||
|
||||
export default (() => {
|
||||
let fontsPromise: Promise<SatoriOptions["fonts"]>
|
||||
|
||||
let fullOptions: SocialImageOptions
|
||||
const Head: QuartzComponent = ({
|
||||
cfg,
|
||||
fileData,
|
||||
externalResources,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
// Initialize options if not set
|
||||
if (!fullOptions) {
|
||||
if (typeof cfg.generateSocialImages !== "boolean") {
|
||||
fullOptions = { ...defaultOptions, ...cfg.generateSocialImages }
|
||||
} else {
|
||||
fullOptions = defaultOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Memoize google fonts
|
||||
if (!fontsPromise && cfg.generateSocialImages) {
|
||||
fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body)
|
||||
}
|
||||
|
||||
const slug = fileData.filePath
|
||||
// since "/" is not a valid character in file names, replace with "-"
|
||||
const fileName = slug?.replaceAll("/", "-")
|
||||
|
||||
// Get file description (priority: frontmatter > fileData > default)
|
||||
const fdDescription =
|
||||
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
|
||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||
const title =
|
||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||
let description = ""
|
||||
if (fdDescription) {
|
||||
description = unescapeHTML(fdDescription)
|
||||
}
|
||||
|
||||
if (fileData.frontmatter?.socialDescription) {
|
||||
description = fileData.frontmatter?.socialDescription as string
|
||||
} else if (fileData.frontmatter?.description) {
|
||||
description = fileData.frontmatter?.description
|
||||
}
|
||||
|
||||
const fileDir = joinSegments(ctx.argv.output, "static", "social-images")
|
||||
if (cfg.generateSocialImages) {
|
||||
// Generate folders for social images (if they dont exist yet)
|
||||
if (!fs.existsSync(fileDir)) {
|
||||
fs.mkdirSync(fileDir, { recursive: true })
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
// Generate social image (happens async)
|
||||
generateSocialImage(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
fileName,
|
||||
fileDir,
|
||||
fileExt: extension,
|
||||
fontsPromise,
|
||||
cfg,
|
||||
fileData,
|
||||
},
|
||||
fullOptions,
|
||||
fileDir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { css, js } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
|
||||
|
||||
const iconPath = joinSegments(baseDir, "static/icon.png")
|
||||
|
||||
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||
// "static/social-images/slug-filename.md.webp"
|
||||
const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace(
|
||||
`${ctx.argv.output}/`,
|
||||
"",
|
||||
)}/${fileName}.${extension}`
|
||||
|
||||
// Use default og image if filePath doesnt exist (for autogenerated paths with no .md file)
|
||||
const useDefaultOgImage = fileName === undefined || !cfg.generateSocialImages
|
||||
|
||||
// Path to og/social image (priority: frontmatter > generated image (if enabled) > default image)
|
||||
let ogImagePath = useDefaultOgImage ? ogImageDefaultPath : ogImageGeneratedPath
|
||||
|
||||
// TODO: could be improved to support external images in the future
|
||||
// Aliases for image and cover handled in `frontmatter.ts`
|
||||
const frontmatterImgUrl = fileData.frontmatter?.socialImage
|
||||
|
||||
// Override with default og image if config option is set
|
||||
if (fileData.slug === "index") {
|
||||
ogImagePath = ogImageDefaultPath
|
||||
}
|
||||
|
||||
// Override with frontmatter url if existing
|
||||
if (frontmatterImgUrl) {
|
||||
ogImagePath = `https://${cfg.baseUrl}/static/${frontmatterImgUrl}`
|
||||
}
|
||||
|
||||
// Url of current page
|
||||
const socialUrl =
|
||||
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
|
||||
|
||||
return (
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
{cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||
</>
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin={"anonymous"} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{/* OG/Twitter meta tags */}
|
||||
<meta name="og:site_name" content={cfg.pageTitle}></meta>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image:type" content={`image/${extension}`} />
|
||||
<meta property="og:image:alt" content={description} />
|
||||
{/* Dont set width and height if unknown (when using custom frontmatter image) */}
|
||||
{!frontmatterImgUrl && (
|
||||
<>
|
||||
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
||||
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
||||
</>
|
||||
)}
|
||||
<meta property="og:image:url" content={ogImagePath} />
|
||||
{cfg.baseUrl && (
|
||||
<>
|
||||
<meta name="twitter:image" content={ogImagePath} />
|
||||
<meta property="og:image" content={ogImagePath} />
|
||||
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
|
||||
<meta property="og:url" content={socialUrl}></meta>
|
||||
<meta property="twitter:url" content={socialUrl}></meta>
|
||||
</>
|
||||
)}
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
|
||||
{js
|
||||
.filter((resource) => resource.loadTime === "beforeDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
</head>
|
||||
)
|
||||
}
|
||||
|
||||
return Head
|
||||
}) satisfies QuartzComponentConstructor
|
||||
22
quartz/components/Header.tsx
Executable file
22
quartz/components/Header.tsx
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Header: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return children.length > 0 ? <header>{children}</header> : null
|
||||
}
|
||||
|
||||
Header.css = `
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 2rem 0;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
flex: auto;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => Header) satisfies QuartzComponentConstructor
|
||||
18
quartz/components/MobileOnly.tsx
Executable file
18
quartz/components/MobileOnly.tsx
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component?: QuartzComponent) => {
|
||||
if (component) {
|
||||
const Component = component
|
||||
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="mobile-only" {...props} />
|
||||
}
|
||||
|
||||
MobileOnly.displayName = component.displayName
|
||||
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
MobileOnly.css = component?.css
|
||||
return MobileOnly
|
||||
} else {
|
||||
return () => <></>
|
||||
}
|
||||
}) satisfies QuartzComponentConstructor
|
||||
87
quartz/components/PageList.tsx
Executable file
87
quartz/components/PageList.tsx
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
import { FullSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
|
||||
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
|
||||
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
limit?: number
|
||||
sort?: SortFn
|
||||
} & QuartzComponentProps
|
||||
|
||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||
const sorter = sort ?? byDateAndAlphabetical(cfg)
|
||||
let list = allFiles.sort(sorter)
|
||||
if (limit) {
|
||||
list = list.slice(0, limit)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul class="section-ul">
|
||||
{list.map((page) => {
|
||||
const title = page.frontmatter?.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="section-li">
|
||||
<div class="section">
|
||||
<p class="meta">
|
||||
{/* {page.dates && <Date date={getDate(cfg, page)!} locale={cfg.locale} />} */}
|
||||
</p>
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
{/* <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>
|
||||
)
|
||||
}
|
||||
|
||||
PageList.css = `
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section > .tags {
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
23
quartz/components/PageTitle.tsx
Executable file
23
quartz/components/PageTitle.tsx
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
import { pathToRoot } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
|
||||
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
return (
|
||||
<h2 class={classNames(displayClass, "page-title")}>
|
||||
<a href={baseDir}>{title}</a>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
PageTitle.css = `
|
||||
.page-title {
|
||||
font-size: 1.50rem;
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
||||
93
quartz/components/RecentNotes.tsx
Executable file
93
quartz/components/RecentNotes.tsx
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
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
|
||||
53
quartz/components/Search.tsx
Executable file
53
quartz/components/Search.tsx
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
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" id="search-button">
|
||||
{/* <p>{i18n(cfg.locale).components.search.title}</p> */}
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.0">
|
||||
<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>
|
||||
</button>
|
||||
<div id="search-container">
|
||||
<div id="search-space">
|
||||
<input
|
||||
autocomplete="off"
|
||||
id="search-bar"
|
||||
name="search"
|
||||
type="text"
|
||||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div id="search-layout" data-preview={opts.enablePreview}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Search.afterDOMLoaded = script
|
||||
Search.css = style
|
||||
|
||||
return Search
|
||||
}) satisfies QuartzComponentConstructor
|
||||
22
quartz/components/Separator.tsx
Executable file
22
quartz/components/Separator.tsx
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
const Separator: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<hr></hr>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
Separator.css = style
|
||||
return Separator
|
||||
}) satisfies QuartzComponentConstructor
|
||||
8
quartz/components/Spacer.tsx
Executable file
8
quartz/components/Spacer.tsx
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||
return <div class={classNames(displayClass, "spacer")}></div>
|
||||
}
|
||||
|
||||
export default (() => Spacer) satisfies QuartzComponentConstructor
|
||||
95
quartz/components/TableOfContents.tsx
Executable file
95
quartz/components/TableOfContents.tsx
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
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"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
layout: "modern",
|
||||
}
|
||||
|
||||
const TableOfContents: QuartzComponent = ({
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={classNames(displayClass, "toc")}>
|
||||
<button
|
||||
type="button"
|
||||
id="toc"
|
||||
class={fileData.collapseToc ? "collapsed" : ""}
|
||||
aria-controls="toc-content"
|
||||
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>
|
||||
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||
<ul class="overflow">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = script
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<details id="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
|
||||
|
||||
export default ((opts?: Partial<Options>) => {
|
||||
const layout = opts?.layout ?? defaultOptions.layout
|
||||
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
||||
}) satisfies QuartzComponentConstructor
|
||||
57
quartz/components/TagList.tsx
Executable file
57
quartz/components/TagList.tsx
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
import { pathToRoot, slugTag } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||
return (
|
||||
<li>
|
||||
<a href={linkDest} class="internal tag-link">
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
TagList.css = `
|
||||
.tags {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding-left: 0;
|
||||
gap: 0.4rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-li > .section > .tags {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tags > li {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
a.internal.tag-link {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => TagList) satisfies QuartzComponentConstructor
|
||||
51
quartz/components/index.ts
Executable file
51
quartz/components/index.ts
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
import Content from "./pages/Content"
|
||||
import TagContent from "./pages/TagContent"
|
||||
import FolderContent from "./pages/FolderContent"
|
||||
import NotFound from "./pages/404"
|
||||
import ArticleTitle from "./ArticleTitle"
|
||||
import Darkmode from "./Darkmode"
|
||||
import Head from "./Head"
|
||||
import PageTitle from "./PageTitle"
|
||||
import ContentMeta from "./ContentMeta"
|
||||
import Spacer from "./Spacer"
|
||||
import TableOfContents from "./TableOfContents"
|
||||
import Explorer from "./Explorer"
|
||||
import TagList from "./TagList"
|
||||
import Graph from "./Graph"
|
||||
import GraphMobile from "./GraphMobile"
|
||||
import Backlinks from "./Backlinks"
|
||||
import Search from "./Search"
|
||||
import Footer from "./Footer"
|
||||
import DesktopOnly from "./DesktopOnly"
|
||||
import MobileOnly from "./MobileOnly"
|
||||
import RecentNotes from "./RecentNotes"
|
||||
import Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Separator from "./Separator"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
Content,
|
||||
TagContent,
|
||||
FolderContent,
|
||||
Darkmode,
|
||||
Head,
|
||||
PageTitle,
|
||||
ContentMeta,
|
||||
Spacer,
|
||||
TableOfContents,
|
||||
Explorer,
|
||||
TagList,
|
||||
Graph,
|
||||
GraphMobile,
|
||||
Backlinks,
|
||||
Search,
|
||||
Footer,
|
||||
DesktopOnly,
|
||||
MobileOnly,
|
||||
RecentNotes,
|
||||
NotFound,
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Separator,
|
||||
}
|
||||
18
quartz/components/pages/404.tsx
Executable file
18
quartz/components/pages/404.tsx
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
import { i18n } from "../../i18n"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const NotFound: QuartzComponent = ({ cfg }: QuartzComponentProps) => {
|
||||
// If baseUrl contains a pathname after the domain, use this as the home link
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const baseDir = url.pathname
|
||||
|
||||
return (
|
||||
<article class="popover-hint">
|
||||
<h1>404</h1>
|
||||
<p>{i18n(cfg.locale).pages.error.notFound}</p>
|
||||
<a href={baseDir}>{i18n(cfg.locale).pages.error.home}</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => NotFound) satisfies QuartzComponentConstructor
|
||||
11
quartz/components/pages/Content.tsx
Executable file
11
quartz/components/pages/Content.tsx
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
|
||||
const content = htmlToJsx(fileData.filePath!, tree)
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
}
|
||||
|
||||
export default (() => Content) satisfies QuartzComponentConstructor
|
||||
107
quartz/components/pages/FolderContent.tsx
Executable file
107
quartz/components/pages/FolderContent.tsx
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import path from "path"
|
||||
|
||||
import style from "../styles/listPage.scss"
|
||||
import { byDateAndAlphabetical, PageList, SortFn } from "../PageList"
|
||||
import { stripSlashes, simplifySlug, joinSegments, FullSlug } from "../../util/path"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
*/
|
||||
showFolderCount: boolean
|
||||
showSubfolders: boolean
|
||||
sort?: SortFn
|
||||
}
|
||||
|
||||
const defaultOptions: FolderContentOptions = {
|
||||
showFolderCount: true,
|
||||
showSubfolders: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
|
||||
const folderParts = folderSlug.split(path.posix.sep)
|
||||
|
||||
const allPagesInFolder: QuartzPluginData[] = []
|
||||
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
|
||||
|
||||
allFiles.forEach((file) => {
|
||||
const fileSlug = stripSlashes(simplifySlug(file.slug!))
|
||||
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||
const fileParts = fileSlug.split(path.posix.sep)
|
||||
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||
|
||||
if (!prefixed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isDirectChild) {
|
||||
allPagesInFolder.push(file)
|
||||
} else if (options.showSubfolders) {
|
||||
const subfolderSlug = joinSegments(
|
||||
...fileParts.slice(0, folderParts.length + 1),
|
||||
) as FullSlug
|
||||
const pagesInFolder = allPagesInSubfolders.get(subfolderSlug) || []
|
||||
allPagesInSubfolders.set(subfolderSlug, [...pagesInFolder, file])
|
||||
}
|
||||
})
|
||||
|
||||
allPagesInSubfolders.forEach((files, subfolderSlug) => {
|
||||
const hasIndex = allPagesInFolder.some(
|
||||
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
|
||||
)
|
||||
if (!hasIndex) {
|
||||
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
|
||||
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
|
||||
allPagesInFolder.push({
|
||||
slug: subfolderSlug,
|
||||
dates: subfolderDates,
|
||||
frontmatter: { title: subfolderTitle, tags: ["folder"] },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
sort: options.sort,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content =
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
|
||||
count: allPagesInFolder.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FolderContent.css = style + PageList.css
|
||||
return FolderContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
127
quartz/components/pages/TagContent.tsx
Executable file
127
quartz/components/pages/TagContent.tsx
Executable file
|
|
@ -0,0 +1,127 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
interface TagContentOptions {
|
||||
sort?: SortFn
|
||||
numPages: number
|
||||
}
|
||||
|
||||
const defaultOptions: TagContentOptions = {
|
||||
numPages: 10,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<TagContentOptions>) => {
|
||||
const options: TagContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const slug = fileData.slug
|
||||
|
||||
if (!(slug?.startsWith("tags/") || slug === "tags")) {
|
||||
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
|
||||
}
|
||||
|
||||
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug)
|
||||
const allPagesWithTag = (tag: string) =>
|
||||
allFiles.filter((file) =>
|
||||
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
|
||||
)
|
||||
|
||||
const content =
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b))
|
||||
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
const pages = tagItemMap.get(tag)!
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`).at(0)
|
||||
|
||||
const root = contentPage?.htmlAst
|
||||
const content =
|
||||
!root || root?.children.length === 0
|
||||
? contentPage?.description
|
||||
: htmlToJsx(contentPage.filePath!, root)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<a class="internal tag-link" href={`../tags/${tag}`}>
|
||||
{tag}
|
||||
</a>
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<div class="page-listing">
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
|
||||
{pages.length > options.numPages && (
|
||||
<>
|
||||
{" "}
|
||||
<span>
|
||||
{i18n(cfg.locale).pages.tagContent.showingFirst({
|
||||
count: options.numPages,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const pages = allPagesWithTag(tag)
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article class="popover-hint">{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TagContent.css = style + PageList.css
|
||||
return TagContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
280
quartz/components/renderPage.tsx
Executable file
280
quartz/components/renderPage.tsx
Executable file
|
|
@ -0,0 +1,280 @@
|
|||
import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import HeaderConstructor from "./Header"
|
||||
import BodyConstructor from "./Body"
|
||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
// @ts-ignore
|
||||
import mermaidScript from "./scripts/mermaid.inline"
|
||||
import mermaidStyle from "./styles/mermaid.inline.scss"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
export function pageResources(
|
||||
baseDir: FullSlug | RelativeURL,
|
||||
fileData: QuartzPluginData,
|
||||
staticResources: StaticResources,
|
||||
): StaticResources {
|
||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||
|
||||
const resources: StaticResources = {
|
||||
css: [
|
||||
{
|
||||
content: joinSegments(baseDir, "index.css"),
|
||||
},
|
||||
...staticResources.css,
|
||||
],
|
||||
js: [
|
||||
{
|
||||
src: joinSegments(baseDir, "prescript.js"),
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
{
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "inline",
|
||||
spaPreserve: true,
|
||||
script: contentIndexScript,
|
||||
},
|
||||
...staticResources.js,
|
||||
],
|
||||
}
|
||||
|
||||
if (fileData.hasMermaidDiagram) {
|
||||
resources.js.push({
|
||||
script: mermaidScript,
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "inline",
|
||||
})
|
||||
resources.css.push({ content: mermaidStyle, inline: true })
|
||||
}
|
||||
|
||||
// NOTE: we have to put this last to make sure spa.inline.ts is the last item.
|
||||
resources.js.push({
|
||||
src: joinSegments(baseDir, "postscript.js"),
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "external",
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
): string {
|
||||
// make a deep copy of the tree so we don't remove the transclusion references
|
||||
// for the file cached in contentMap in build.ts
|
||||
const root = clone(componentData.tree) as Root
|
||||
|
||||
// process transcludes in componentData
|
||||
visit(root, "element", (node, _index, _parent) => {
|
||||
if (node.tagName === "blockquote") {
|
||||
const classNames = (node.properties?.className ?? []) as string[]
|
||||
if (classNames.includes("transclude")) {
|
||||
const inner = node.children[0] as Element
|
||||
const transcludeTarget = inner.properties["data-slug"] as FullSlug
|
||||
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
let blockRef = node.properties.dataBlock as string | undefined
|
||||
if (blockRef?.startsWith("#^")) {
|
||||
// block transclude
|
||||
blockRef = blockRef.slice("#^".length)
|
||||
let blockNode = page.blocks?.[blockRef]
|
||||
if (blockNode) {
|
||||
if (blockNode.tagName === "li") {
|
||||
blockNode = {
|
||||
type: "element",
|
||||
tagName: "ul",
|
||||
properties: {},
|
||||
children: [blockNode],
|
||||
}
|
||||
}
|
||||
|
||||
node.children = [
|
||||
normalizeHastElement(blockNode, slug, transcludeTarget),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
} else if (blockRef?.startsWith("#") && page.htmlAst) {
|
||||
// header transclude
|
||||
blockRef = blockRef.slice(1)
|
||||
let startIdx = undefined
|
||||
let startDepth = undefined
|
||||
let endIdx = undefined
|
||||
for (const [i, el] of page.htmlAst.children.entries()) {
|
||||
// skip non-headers
|
||||
if (!(el.type === "element" && el.tagName.match(headerRegex))) continue
|
||||
const depth = Number(el.tagName.substring(1))
|
||||
|
||||
// lookin for our blockref
|
||||
if (startIdx === undefined || startDepth === undefined) {
|
||||
// skip until we find the blockref that matches
|
||||
if (el.properties?.id === blockRef) {
|
||||
startIdx = i
|
||||
startDepth = depth
|
||||
}
|
||||
} else if (depth <= startDepth) {
|
||||
// looking for new header that is same level or higher
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
node.children = [
|
||||
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
} else if (page.htmlAst) {
|
||||
// page transclude
|
||||
node.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h1",
|
||||
properties: {},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value:
|
||||
page.frontmatter?.title ??
|
||||
i18n(cfg.locale).components.transcludes.transcludeOf({
|
||||
targetSlug: page.slug!,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(page.htmlAst.children as ElementContent[]).map((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// set componentData.tree to the edited html that has transclusions rendered
|
||||
componentData.tree = root
|
||||
|
||||
const {
|
||||
head: Head,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
} = components
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
const LeftComponent = (
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const RightComponent = (
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
const doc = (
|
||||
<html lang={lang}>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={slug}>
|
||||
<div id="quartz-root" class="page">
|
||||
<Body {...componentData}>
|
||||
{LeftComponent}
|
||||
<div class="center">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
{/* <hr /> */}
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{RightComponent}
|
||||
<Footer {...componentData} />
|
||||
</Body>
|
||||
</div>
|
||||
</body>
|
||||
{pageResources.js
|
||||
.filter((resource) => resource.loadTime === "afterDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res))}
|
||||
</html>
|
||||
)
|
||||
|
||||
return "<!DOCTYPE html>\n" + render(doc)
|
||||
}
|
||||
44
quartz/components/scripts/callout.inline.ts
Executable file
44
quartz/components/scripts/callout.inline.ts
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
|
||||
outerBlock.style.maxHeight = height + "px"
|
||||
|
||||
// walk and adjust height of all parents
|
||||
let current = outerBlock
|
||||
let parent = outerBlock.parentElement
|
||||
while (parent) {
|
||||
if (!parent.classList.contains("callout")) {
|
||||
return
|
||||
}
|
||||
|
||||
const collapsed = parent.classList.contains("is-collapsed")
|
||||
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
|
||||
parent.style.maxHeight = height + "px"
|
||||
|
||||
current = parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
const collapsible = document.getElementsByClassName(
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
const title = div.firstElementChild
|
||||
|
||||
if (title) {
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
const height = collapsed ? title.scrollHeight : div.scrollHeight
|
||||
div.style.maxHeight = height + "px"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupCallout)
|
||||
window.addEventListener("resize", setupCallout)
|
||||
23
quartz/components/scripts/checkbox.inline.ts
Executable file
23
quartz/components/scripts/checkbox.inline.ts
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
import { getFullSlug } from "../../util/path"
|
||||
|
||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
"input.checkbox-toggle",
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
checkboxes.forEach((el, index) => {
|
||||
const elId = checkboxId(index)
|
||||
|
||||
const switchState = (e: Event) => {
|
||||
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
|
||||
localStorage.setItem(elId, newCheckboxState)
|
||||
}
|
||||
|
||||
el.addEventListener("change", switchState)
|
||||
window.addCleanup(() => el.removeEventListener("change", switchState))
|
||||
if (localStorage.getItem(elId) === "true") {
|
||||
el.checked = true
|
||||
}
|
||||
})
|
||||
})
|
||||
37
quartz/components/scripts/clipboard.inline.ts
Executable file
37
quartz/components/scripts/clipboard.inline.ts
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
const svgCopy =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||
const svgCheck =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const els = document.getElementsByTagName("pre")
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||
if (codeBlock) {
|
||||
const source = (
|
||||
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
|
||||
).replace(/\n\n/g, "\n")
|
||||
const button = document.createElement("button")
|
||||
button.className = "clipboard-button"
|
||||
button.type = "button"
|
||||
button.innerHTML = svgCopy
|
||||
button.ariaLabel = "Copy source"
|
||||
function onClick() {
|
||||
navigator.clipboard.writeText(source).then(
|
||||
() => {
|
||||
button.blur()
|
||||
button.innerHTML = svgCheck
|
||||
setTimeout(() => {
|
||||
button.innerHTML = svgCopy
|
||||
button.style.borderColor = ""
|
||||
}, 2000)
|
||||
},
|
||||
(error) => console.error(error),
|
||||
)
|
||||
}
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
els[i].prepend(button)
|
||||
}
|
||||
}
|
||||
})
|
||||
91
quartz/components/scripts/comments.inline.ts
Executable file
91
quartz/components/scripts/comments.inline.ts
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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))
|
||||
})
|
||||
38
quartz/components/scripts/darkmode.inline.ts
Executable file
38
quartz/components/scripts/darkmode.inline.ts
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
|
||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||
|
||||
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||
detail: { theme },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchTheme = (e: Event) => {
|
||||
const newTheme =
|
||||
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
const themeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
// Darkmode toggle
|
||||
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
|
||||
if (themeButton) {
|
||||
themeButton.addEventListener("click", switchTheme)
|
||||
window.addCleanup(() => themeButton.removeEventListener("click", switchTheme))
|
||||
}
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||
})
|
||||
135
quartz/components/scripts/explorer.inline.ts
Executable file
135
quartz/components/scripts/explorer.inline.ts
Executable file
|
|
@ -0,0 +1,135 @@
|
|||
import { FolderState } from "../ExplorerNode"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
let currentExplorerState: FolderState[]
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||
const explorerUl = document.getElementById("explorer-ul")
|
||||
if (!explorerUl) return
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
explorerUl.classList.add("no-background")
|
||||
} else {
|
||||
explorerUl.classList.remove("no-background")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
this.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as MaybeHTMLElement
|
||||
if (!content) return
|
||||
|
||||
content.classList.toggle("collapsed")
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
const isSvg = target.nodeName === "svg"
|
||||
const childFolderContainer = (
|
||||
isSvg
|
||||
? target.parentElement?.nextSibling
|
||||
: target.parentElement?.parentElement?.nextElementSibling
|
||||
) as MaybeHTMLElement
|
||||
const currentFolderParent = (
|
||||
isSvg ? target.nextElementSibling : target.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!(childFolderContainer && currentFolderParent)) return
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, !isCollapsed)
|
||||
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function setupExplorer() {
|
||||
const explorer = document.getElementById("explorer")
|
||||
if (!explorer) return
|
||||
|
||||
if (explorer.dataset.behavior === "collapse") {
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||
const oldExplorerState: FolderState[] =
|
||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||
? JSON.parse(explorer.dataset.tree)
|
||||
: []
|
||||
currentExplorerState = []
|
||||
for (const { path, collapsed } of newExplorerState) {
|
||||
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
|
||||
}
|
||||
|
||||
currentExplorerState.map((folderState) => {
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='${folderState.path}']`,
|
||||
) as MaybeHTMLElement
|
||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||
if (folderUl) {
|
||||
setFolderState(folderUl, folderState.collapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("resize", setupExplorer)
|
||||
document.addEventListener("nav", () => {
|
||||
setupExplorer()
|
||||
observer.disconnect()
|
||||
|
||||
// select pseudo element at end of list
|
||||
const lastItem = document.getElementById("explorer-end")
|
||||
if (lastItem) {
|
||||
observer.observe(lastItem)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggles the state of a given folder
|
||||
* @param folderElement <div class="folder-outer"> Element of folder (parent)
|
||||
* @param collapsed if folder should be set to collapsed or not
|
||||
*/
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles visibility of a folder
|
||||
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
|
||||
* @param path path to folder (e.g. 'advanced/more/more2')
|
||||
*/
|
||||
function toggleCollapsedByPath(array: FolderState[], path: string) {
|
||||
const entry = array.find((item) => item.path === path)
|
||||
if (entry) {
|
||||
entry.collapsed = !entry.collapsed
|
||||
}
|
||||
}
|
||||
601
quartz/components/scripts/graph.inline.ts
Executable file
601
quartz/components/scripts/graph.inline.ts
Executable file
|
|
@ -0,0 +1,601 @@
|
|||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import {
|
||||
SimulationNodeDatum,
|
||||
SimulationLinkDatum,
|
||||
Simulation,
|
||||
forceSimulation,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
zoom,
|
||||
} from "d3"
|
||||
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
|
||||
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { D3Config } from "../Graph"
|
||||
|
||||
type GraphicsInfo = {
|
||||
color: string
|
||||
gfx: Graphics
|
||||
alpha: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type NodeData = {
|
||||
id: SimpleSlug
|
||||
text: string
|
||||
tags: string[]
|
||||
} & SimulationNodeDatum
|
||||
|
||||
type SimpleLinkData = {
|
||||
source: SimpleSlug
|
||||
target: SimpleSlug
|
||||
}
|
||||
|
||||
type LinkData = {
|
||||
source: NodeData
|
||||
target: NodeData
|
||||
} & SimulationLinkDatum<NodeData>
|
||||
|
||||
type LinkRenderData = GraphicsInfo & {
|
||||
simulationData: LinkData
|
||||
}
|
||||
|
||||
type NodeRenderData = GraphicsInfo & {
|
||||
simulationData: NodeData
|
||||
label: Text
|
||||
}
|
||||
|
||||
const localStorageKey = "graph-visited"
|
||||
function getVisited(): Set<SimpleSlug> {
|
||||
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
|
||||
}
|
||||
|
||||
function addToVisited(slug: SimpleSlug) {
|
||||
const visited = getVisited()
|
||||
visited.add(slug)
|
||||
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
||||
}
|
||||
|
||||
type TweenNode = {
|
||||
update: (time: number) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
const slug = simplifySlug(fullSlug)
|
||||
const visited = getVisited()
|
||||
const graph = document.getElementById(container)
|
||||
if (!graph) return
|
||||
removeAllChildren(graph)
|
||||
|
||||
let {
|
||||
drag: enableDrag,
|
||||
zoom: enableZoom,
|
||||
depth,
|
||||
scale,
|
||||
repelForce,
|
||||
centerForce,
|
||||
linkDistance,
|
||||
fontSize,
|
||||
opacityScale,
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
|
||||
simplifySlug(k as FullSlug),
|
||||
v,
|
||||
]),
|
||||
)
|
||||
const links: SimpleLinkData[] = []
|
||||
const tags: SimpleSlug[] = []
|
||||
const validLinks = new Set(data.keys())
|
||||
|
||||
const tweens = new Map<string, TweenNode>()
|
||||
for (const [source, details] of data.entries()) {
|
||||
const outgoing = details.links ?? []
|
||||
|
||||
for (const dest of outgoing) {
|
||||
if (validLinks.has(dest)) {
|
||||
links.push({ source: source, target: dest })
|
||||
}
|
||||
}
|
||||
|
||||
if (showTags) {
|
||||
const localTags = details.tags
|
||||
.filter((tag) => !removeTags.includes(tag))
|
||||
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
||||
|
||||
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
||||
|
||||
for (const tag of localTags) {
|
||||
links.push({ source: source, target: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbourhood = new Set<SimpleSlug>()
|
||||
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
||||
if (depth >= 0) {
|
||||
while (depth >= 0 && wl.length > 0) {
|
||||
// compute neighbours
|
||||
const cur = wl.shift()!
|
||||
if (cur === "__SENTINEL") {
|
||||
depth--
|
||||
wl.push("__SENTINEL")
|
||||
} else {
|
||||
neighbourhood.add(cur)
|
||||
const outgoing = links.filter((l) => l.source === cur)
|
||||
const incoming = links.filter((l) => l.target === cur)
|
||||
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validLinks.forEach((id) => neighbourhood.add(id))
|
||||
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
||||
}
|
||||
|
||||
const nodes = [...neighbourhood].map((url) => {
|
||||
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
|
||||
return {
|
||||
id: url,
|
||||
text,
|
||||
tags: data.get(url)?.tags ?? [],
|
||||
}
|
||||
})
|
||||
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||
nodes,
|
||||
links: links
|
||||
.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
|
||||
.map((l) => ({
|
||||
source: nodes.find((n) => n.id === l.source)!,
|
||||
target: nodes.find((n) => n.id === l.target)!,
|
||||
})),
|
||||
}
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--bodyFont",
|
||||
] as const
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
// calculate color
|
||||
const color = (d: NodeData) => {
|
||||
const isCurrent = d.id === slug
|
||||
if (isCurrent) {
|
||||
return computedStyleMap["--secondary"]
|
||||
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||
return computedStyleMap["--tertiary"]
|
||||
} else {
|
||||
return computedStyleMap["--gray"]
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(d: NodeData) {
|
||||
const numLinks = graphData.links.filter(
|
||||
(l) => l.source.id === d.id || l.target.id === d.id,
|
||||
).length
|
||||
return 2 + Math.sqrt(numLinks)
|
||||
}
|
||||
|
||||
let hoveredNodeId: string | null = null
|
||||
let hoveredNeighbours: Set<string> = new Set()
|
||||
const linkRenderData: LinkRenderData[] = []
|
||||
const nodeRenderData: NodeRenderData[] = []
|
||||
function updateHoverInfo(newHoveredId: string | null) {
|
||||
hoveredNodeId = newHoveredId
|
||||
|
||||
if (newHoveredId === null) {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = false
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
l.active = false
|
||||
}
|
||||
} else {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
|
||||
hoveredNeighbours.add(linkData.source.id)
|
||||
hoveredNeighbours.add(linkData.target.id)
|
||||
}
|
||||
|
||||
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
||||
}
|
||||
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = hoveredNeighbours.has(n.simulationData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dragStartTime = 0
|
||||
let dragging = false
|
||||
|
||||
function renderLinks() {
|
||||
tweens.get("link")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
// with full alpha and the rest with default alpha
|
||||
if (hoveredNodeId) {
|
||||
alpha = l.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
|
||||
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("link", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderLabels() {
|
||||
tweens.get("label")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
const defaultScale = 1 / scale
|
||||
const activeScale = defaultScale * 1.1
|
||||
for (const n of nodeRenderData) {
|
||||
const nodeId = n.simulationData.id
|
||||
|
||||
if (hoveredNodeId === nodeId) {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: 1,
|
||||
scale: { x: activeScale, y: activeScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: n.label.alpha,
|
||||
scale: { x: defaultScale, y: defaultScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("label", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
tweens.get("hover")?.stop()
|
||||
|
||||
const tweenGroup = new TweenGroup()
|
||||
for (const n of nodeRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
if (hoveredNodeId !== null && focusOnHover) {
|
||||
alpha = n.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("hover", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderPixiFromD3() {
|
||||
renderNodes()
|
||||
renderLinks()
|
||||
renderLabels()
|
||||
}
|
||||
|
||||
tweens.forEach((tween) => tween.stop())
|
||||
tweens.clear()
|
||||
|
||||
const app = new Application()
|
||||
await app.init({
|
||||
width,
|
||||
height,
|
||||
antialias: true,
|
||||
autoStart: false,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
preference: "webgpu",
|
||||
resolution: window.devicePixelRatio,
|
||||
eventMode: "static",
|
||||
})
|
||||
graph.appendChild(app.canvas)
|
||||
|
||||
const stage = app.stage
|
||||
stage.interactive = false
|
||||
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3 })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2 })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1 })
|
||||
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
||||
|
||||
for (const n of graphData.nodes) {
|
||||
const nodeId = n.id
|
||||
|
||||
const label = new Text({
|
||||
interactive: false,
|
||||
eventMode: "none",
|
||||
text: n.text,
|
||||
alpha: 0,
|
||||
anchor: { x: 0.5, y: 1.2 },
|
||||
style: {
|
||||
fontSize: fontSize * 15,
|
||||
fill: computedStyleMap["--dark"],
|
||||
fontFamily: computedStyleMap["--bodyFont"],
|
||||
},
|
||||
resolution: window.devicePixelRatio * 4,
|
||||
})
|
||||
label.scale.set(1 / scale)
|
||||
|
||||
let oldLabelOpacity = 0
|
||||
const isTagNode = nodeId.startsWith("tags/")
|
||||
const gfx = new Graphics({
|
||||
interactive: true,
|
||||
label: nodeId,
|
||||
eventMode: "static",
|
||||
hitArea: new Circle(0, 0, nodeRadius(n)),
|
||||
cursor: "pointer",
|
||||
})
|
||||
.circle(0, 0, nodeRadius(n))
|
||||
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
||||
.stroke({ width: isTagNode ? 2 : 0, color: color(n) })
|
||||
.on("pointerover", (e) => {
|
||||
updateHoverInfo(e.target.label)
|
||||
oldLabelOpacity = label.alpha
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
.on("pointerleave", () => {
|
||||
updateHoverInfo(null)
|
||||
label.alpha = oldLabelOpacity
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
|
||||
nodesContainer.addChild(gfx)
|
||||
labelsContainer.addChild(label)
|
||||
|
||||
const nodeRenderDatum: NodeRenderData = {
|
||||
simulationData: n,
|
||||
gfx,
|
||||
label,
|
||||
color: color(n),
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
nodeRenderData.push(nodeRenderDatum)
|
||||
}
|
||||
|
||||
for (const l of graphData.links) {
|
||||
const gfx = new Graphics({ interactive: false, eventMode: "none" })
|
||||
linkContainer.addChild(gfx)
|
||||
|
||||
const linkRenderDatum: LinkRenderData = {
|
||||
simulationData: l,
|
||||
gfx,
|
||||
color: computedStyleMap["--lightgray"],
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
linkRenderData.push(linkRenderDatum)
|
||||
}
|
||||
|
||||
let currentTransform = zoomIdentity
|
||||
if (enableDrag) {
|
||||
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
|
||||
drag<HTMLCanvasElement, NodeData | undefined>()
|
||||
.container(() => app.canvas)
|
||||
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
|
||||
.on("start", function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(1).restart()
|
||||
event.subject.fx = event.subject.x
|
||||
event.subject.fy = event.subject.y
|
||||
event.subject.__initialDragPos = {
|
||||
x: event.subject.x,
|
||||
y: event.subject.y,
|
||||
fx: event.subject.fx,
|
||||
fy: event.subject.fy,
|
||||
}
|
||||
dragStartTime = Date.now()
|
||||
dragging = true
|
||||
})
|
||||
.on("drag", function dragged(event) {
|
||||
const initPos = event.subject.__initialDragPos
|
||||
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
|
||||
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
|
||||
})
|
||||
.on("end", function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
event.subject.fx = null
|
||||
event.subject.fy = null
|
||||
dragging = false
|
||||
|
||||
// if the time between mousedown and mouseup is short, we consider it a click
|
||||
if (Date.now() - dragStartTime < 500) {
|
||||
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
|
||||
const targ = resolveRelative(fullSlug, node.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
for (const node of nodeRenderData) {
|
||||
node.gfx.on("click", () => {
|
||||
const targ = resolveRelative(fullSlug, node.simulationData.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (enableZoom) {
|
||||
select<HTMLCanvasElement, NodeData>(app.canvas).call(
|
||||
zoom<HTMLCanvasElement, NodeData>()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[width, height],
|
||||
])
|
||||
.scaleExtent([0.25, 4])
|
||||
.on("zoom", ({ transform }) => {
|
||||
currentTransform = transform
|
||||
stage.scale.set(transform.k, transform.k)
|
||||
stage.position.set(transform.x, transform.y)
|
||||
|
||||
// zoom adjusts opacity of labels too
|
||||
const scale = transform.k * opacityScale
|
||||
let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
|
||||
const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
|
||||
|
||||
for (const label of labelsContainer.children) {
|
||||
if (!activeNodes.includes(label)) {
|
||||
label.alpha = scaleOpacity
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function animate(time: number) {
|
||||
for (const n of nodeRenderData) {
|
||||
const { x, y } = n.simulationData
|
||||
if (!x || !y) continue
|
||||
n.gfx.position.set(x + width / 2, y + height / 2)
|
||||
if (n.label) {
|
||||
n.label.position.set(x + width / 2, y + height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
l.gfx.clear()
|
||||
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
|
||||
l.gfx
|
||||
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
|
||||
.stroke({ alpha: l.alpha, width: 1, color: l.color })
|
||||
}
|
||||
|
||||
tweens.forEach((t) => t.update(time))
|
||||
app.renderer.render(stage)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const graphAnimationFrameHandle = requestAnimationFrame(animate)
|
||||
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
addToVisited(simplifySlug(slug))
|
||||
await renderGraph("graph-container", slug)
|
||||
|
||||
// Function to re-render the graph when the theme changes
|
||||
const handleThemeChange = () => {
|
||||
renderGraph("graph-container", slug)
|
||||
}
|
||||
|
||||
// event listener for theme change
|
||||
document.addEventListener("themechange", handleThemeChange)
|
||||
|
||||
// cleanup for the event listener
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("themechange", handleThemeChange)
|
||||
})
|
||||
|
||||
const container = document.getElementById("global-graph-outer")
|
||||
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||
|
||||
function renderGlobalGraph() {
|
||||
const slug = getFullSlug(window)
|
||||
container?.classList.add("active")
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = "1"
|
||||
}
|
||||
|
||||
renderGraph("global-graph-container", slug)
|
||||
registerEscapeHandler(container, hideGlobalGraph)
|
||||
}
|
||||
|
||||
function hideGlobalGraph() {
|
||||
container?.classList.remove("active")
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const globalGraphOpen = container?.classList.contains("active")
|
||||
globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
|
||||
}
|
||||
}
|
||||
|
||||
const containerIcon = document.getElementById("global-graph-icon")
|
||||
containerIcon?.addEventListener("click", renderGlobalGraph)
|
||||
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||
})
|
||||
248
quartz/components/scripts/mermaid.inline.ts
Executable file
248
quartz/components/scripts/mermaid.inline.ts
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
import { removeAllChildren } from "./util"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
class DiagramPanZoom {
|
||||
private isDragging = false
|
||||
private startPan: Position = { x: 0, y: 0 }
|
||||
private currentPan: Position = { x: 0, y: 0 }
|
||||
private scale = 1
|
||||
private readonly MIN_SCALE = 0.5
|
||||
private readonly MAX_SCALE = 3
|
||||
private readonly ZOOM_SENSITIVITY = 0.001
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private content: HTMLElement,
|
||||
) {
|
||||
this.setupEventListeners()
|
||||
this.setupNavigationControls()
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
// Mouse drag events
|
||||
this.container.addEventListener("mousedown", this.onMouseDown.bind(this))
|
||||
document.addEventListener("mousemove", this.onMouseMove.bind(this))
|
||||
document.addEventListener("mouseup", this.onMouseUp.bind(this))
|
||||
|
||||
// Wheel zoom events
|
||||
this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false })
|
||||
|
||||
// Reset on window resize
|
||||
window.addEventListener("resize", this.resetTransform.bind(this))
|
||||
}
|
||||
|
||||
private setupNavigationControls() {
|
||||
const controls = document.createElement("div")
|
||||
controls.className = "mermaid-controls"
|
||||
|
||||
// Zoom controls
|
||||
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
||||
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
||||
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||
|
||||
controls.appendChild(zoomOut)
|
||||
controls.appendChild(resetBtn)
|
||||
controls.appendChild(zoomIn)
|
||||
|
||||
this.container.appendChild(controls)
|
||||
}
|
||||
|
||||
private createButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button")
|
||||
button.textContent = text
|
||||
button.className = "mermaid-control-button"
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
return button
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return // Only handle left click
|
||||
this.isDragging = true
|
||||
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
|
||||
this.container.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent) {
|
||||
if (!this.isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
this.currentPan = {
|
||||
x: e.clientX - this.startPan.x,
|
||||
y: e.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onMouseUp() {
|
||||
this.isDragging = false
|
||||
this.container.style.cursor = "grab"
|
||||
}
|
||||
|
||||
private onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const delta = -e.deltaY * this.ZOOM_SENSITIVITY
|
||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||
|
||||
// Calculate mouse position relative to content
|
||||
const rect = this.content.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left
|
||||
const mouseY = e.clientY - rect.top
|
||||
|
||||
// Adjust pan to zoom around mouse position
|
||||
const scaleDiff = newScale - this.scale
|
||||
this.currentPan.x -= mouseX * scaleDiff
|
||||
this.currentPan.y -= mouseY * scaleDiff
|
||||
|
||||
this.scale = newScale
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||
|
||||
// Zoom around center
|
||||
const rect = this.content.getBoundingClientRect()
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
|
||||
const scaleDiff = newScale - this.scale
|
||||
this.currentPan.x -= centerX * scaleDiff
|
||||
this.currentPan.y -= centerY * scaleDiff
|
||||
|
||||
this.scale = newScale
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private updateTransform() {
|
||||
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
|
||||
}
|
||||
|
||||
private resetTransform() {
|
||||
this.scale = 1
|
||||
this.currentPan = { x: 0, y: 0 }
|
||||
this.updateTransform()
|
||||
}
|
||||
}
|
||||
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--highlight",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--codeFont",
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
mermaidImport ||= await import(
|
||||
//@ts-ignore
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||
)
|
||||
const mermaid = mermaidImport.default
|
||||
|
||||
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "loose",
|
||||
theme: darkMode ? "dark" : "base",
|
||||
themeVariables: {
|
||||
fontFamily: computedStyleMap["--codeFont"],
|
||||
primaryColor: computedStyleMap["--light"],
|
||||
primaryTextColor: computedStyleMap["--darkgray"],
|
||||
primaryBorderColor: computedStyleMap["--tertiary"],
|
||||
lineColor: computedStyleMap["--darkgray"],
|
||||
secondaryColor: computedStyleMap["--secondary"],
|
||||
tertiaryColor: computedStyleMap["--tertiary"],
|
||||
clusterBkg: computedStyleMap["--light"],
|
||||
edgeLabelBackground: computedStyleMap["--highlight"],
|
||||
},
|
||||
})
|
||||
await mermaid.run({ nodes })
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const codeBlock = nodes[i] as HTMLElement
|
||||
const pre = codeBlock.parentElement as HTMLPreElement
|
||||
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
|
||||
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
|
||||
|
||||
const clipboardStyle = window.getComputedStyle(clipboardBtn)
|
||||
const clipboardWidth =
|
||||
clipboardBtn.offsetWidth +
|
||||
parseFloat(clipboardStyle.marginLeft || "0") +
|
||||
parseFloat(clipboardStyle.marginRight || "0")
|
||||
|
||||
// Set expand button position
|
||||
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
|
||||
pre.prepend(expandBtn)
|
||||
|
||||
// query popup container
|
||||
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
|
||||
if (!popupContainer) return
|
||||
|
||||
let panZoom: DiagramPanZoom | null = null
|
||||
|
||||
function showMermaid() {
|
||||
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
|
||||
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
|
||||
if (!content) return
|
||||
removeAllChildren(content)
|
||||
|
||||
// Clone the mermaid content
|
||||
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
|
||||
content.appendChild(mermaidContent)
|
||||
|
||||
// Show container
|
||||
popupContainer.classList.add("active")
|
||||
container.style.cursor = "grab"
|
||||
|
||||
// Initialize pan-zoom after showing the popup
|
||||
panZoom = new DiagramPanZoom(container, content)
|
||||
}
|
||||
|
||||
function hideMermaid() {
|
||||
popupContainer.classList.remove("active")
|
||||
panZoom = null
|
||||
}
|
||||
|
||||
function handleEscape(e: any) {
|
||||
if (e.key === "Escape") {
|
||||
hideMermaid()
|
||||
}
|
||||
}
|
||||
|
||||
const closeBtn = popupContainer.querySelector(".close-button") as HTMLButtonElement
|
||||
|
||||
closeBtn.addEventListener("click", hideMermaid)
|
||||
expandBtn.addEventListener("click", showMermaid)
|
||||
document.addEventListener("keydown", handleEscape)
|
||||
|
||||
window.addCleanup(() => {
|
||||
closeBtn.removeEventListener("click", hideMermaid)
|
||||
expandBtn.removeEventListener("click", showMermaid)
|
||||
document.removeEventListener("keydown", handleEscape)
|
||||
})
|
||||
}
|
||||
})
|
||||
109
quartz/components/scripts/popover.inline.ts
Executable file
109
quartz/components/scripts/popover.inline.ts
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
async function mouseEnterHandler(
|
||||
this: HTMLAnchorElement,
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = this
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
})
|
||||
}
|
||||
|
||||
const hasAlreadyBeenFetched = () =>
|
||||
[...link.children].some((child) => child.classList.contains("popover"))
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (hasAlreadyBeenFetched()) {
|
||||
return setPosition(link.lastChild as HTMLElement)
|
||||
}
|
||||
|
||||
const thisUrl = new URL(document.location.href)
|
||||
thisUrl.hash = ""
|
||||
thisUrl.search = ""
|
||||
const targetUrl = new URL(link.href)
|
||||
const hash = decodeURIComponent(targetUrl.hash)
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
// bailout if another popover exists
|
||||
if (hasAlreadyBeenFetched()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!response) return
|
||||
const [contentType] = response.headers.get("Content-Type")!.split(";")
|
||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.classList.add("popover")
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
popoverInner.dataset.contentType = contentType ?? undefined
|
||||
|
||||
switch (contentTypeCategory) {
|
||||
case "image":
|
||||
const img = document.createElement("img")
|
||||
img.src = targetUrl.toString()
|
||||
img.alt = targetUrl.pathname
|
||||
|
||||
popoverInner.appendChild(img)
|
||||
break
|
||||
case "application":
|
||||
switch (typeInfo) {
|
||||
case "pdf":
|
||||
const pdf = document.createElement("iframe")
|
||||
pdf.src = targetUrl.toString()
|
||||
popoverInner.appendChild(pdf)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
break
|
||||
default:
|
||||
const contents = await response.text()
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||
}
|
||||
|
||||
setPosition(popoverElement)
|
||||
link.appendChild(popoverElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const heading = popoverInner.querySelector(hash) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
|
||||
}
|
||||
})
|
||||
493
quartz/components/scripts/search.inline.ts
Executable file
493
quartz/components/scripts/search.inline.ts
Executable file
|
|
@ -0,0 +1,493 @@
|
|||
import FlexSearch 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[]
|
||||
}
|
||||
|
||||
// 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) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||
let index = new FlexSearch.Document<Item>({
|
||||
charset: "latin:extra",
|
||||
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
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const data = await fetchData
|
||||
const container = document.getElementById("search-container")
|
||||
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||
const searchButton = document.getElementById("search-button")
|
||||
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
||||
const searchLayout = document.getElementById("search-layout")
|
||||
const idDataMap = Object.keys(data) as FullSlug[]
|
||||
|
||||
const appendLayout = (el: HTMLElement) => {
|
||||
if (searchLayout?.querySelector(`#${el.id}`) === null) {
|
||||
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.id = "results-container"
|
||||
appendLayout(results)
|
||||
|
||||
if (enablePreview) {
|
||||
preview = document.createElement("div")
|
||||
preview.id = "preview-container"
|
||||
appendLayout(preview)
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
container?.classList.remove("active")
|
||||
if (searchBar) {
|
||||
searchBar.value = "" // clear the input when we dismiss the search
|
||||
}
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = ""
|
||||
}
|
||||
if (results) {
|
||||
removeAllChildren(results)
|
||||
}
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
}
|
||||
if (searchLayout) {
|
||||
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
|
||||
if (searchBar) 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") {
|
||||
// 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>${title}</h3>${htmlTags}${
|
||||
enablePreview && window.innerWidth > 600 ? "" : `<p>${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[]) {
|
||||
if (!results) return
|
||||
|
||||
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.querySelectorAll(".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: FlexSearch.SimpleDocumentSearchResultSetUnit[]
|
||||
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: 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
|
||||
*/
|
||||
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return await Promise.all(promises)
|
||||
}
|
||||
203
quartz/components/scripts/spa.inline.ts
Executable file
203
quartz/components/scripts/spa.inline.ts
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
const NODE_TYPE_ELEMENT = 1
|
||||
let announcer = document.createElement("route-announcer")
|
||||
const isElement = (target: EventTarget | null): target is Element =>
|
||||
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
|
||||
const isLocalUrl = (href: string) => {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (window.location.origin === url.origin) {
|
||||
return true
|
||||
}
|
||||
} catch (e) {}
|
||||
return false
|
||||
}
|
||||
|
||||
const isSamePage = (url: URL): boolean => {
|
||||
const sameOrigin = url.origin === window.location.origin
|
||||
const samePath = url.pathname === window.location.pathname
|
||||
return sameOrigin && samePath
|
||||
}
|
||||
|
||||
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
|
||||
if (!isElement(target)) return
|
||||
if (target.attributes.getNamedItem("target")?.value === "_blank") return
|
||||
const a = target.closest("a")
|
||||
if (!a) return
|
||||
if ("routerIgnore" in a.dataset) return
|
||||
const { href } = a
|
||||
if (!isLocalUrl(href)) return
|
||||
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
|
||||
}
|
||||
|
||||
function notifyNav(url: FullSlug) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
function startLoading() {
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
if (!document.body.contains(loadingBar)) {
|
||||
document.body.appendChild(loadingBar)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
let p: DOMParser
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
startLoading()
|
||||
p = p || new DOMParser()
|
||||
const contents = await fetchCanonical(url)
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get("content-type")
|
||||
if (contentType?.startsWith("text/html")) {
|
||||
return res.text()
|
||||
} else {
|
||||
window.location.assign(url)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.assign(url)
|
||||
})
|
||||
|
||||
if (!contents) return
|
||||
|
||||
// cleanup old
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns.clear()
|
||||
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, url)
|
||||
|
||||
let title = html.querySelector("title")?.textContent
|
||||
if (title) {
|
||||
document.title = title
|
||||
} else {
|
||||
const h1 = document.querySelector("h1")
|
||||
title = h1?.innerText ?? h1?.textContent ?? url.pathname
|
||||
}
|
||||
if (announcer.textContent !== title) {
|
||||
announcer.textContent = title
|
||||
}
|
||||
announcer.dataset.persist = ""
|
||||
html.body.appendChild(announcer)
|
||||
|
||||
// morph body
|
||||
micromorph(document.body, html.body)
|
||||
|
||||
// scroll into place and add history
|
||||
if (!isBack) {
|
||||
if (url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// now, patch head
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
// at this point everything is loaded so changing the url should resolve to the correct addresses
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
window.spaNavigate = navigate
|
||||
|
||||
function createRouter() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", async (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
// dont hijack behaviour, just let browser act normally
|
||||
if (!url || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
|
||||
if (isSamePage(url) && url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
history.pushState({}, "", url)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
navigate(url, false)
|
||||
} catch (e) {
|
||||
window.location.assign(url)
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("popstate", (event) => {
|
||||
const { url } = getOpts(event) ?? {}
|
||||
if (window.location.hash && window.location.pathname === url?.pathname) return
|
||||
try {
|
||||
navigate(new URL(window.location.toString()), true)
|
||||
} catch (e) {
|
||||
window.location.reload()
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
return new (class Router {
|
||||
go(pathname: RelativeURL) {
|
||||
const url = new URL(pathname, window.location.toString())
|
||||
return navigate(url, false)
|
||||
}
|
||||
|
||||
back() {
|
||||
return window.history.back()
|
||||
}
|
||||
|
||||
forward() {
|
||||
return window.history.forward()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
createRouter()
|
||||
notifyNav(getFullSlug(window))
|
||||
|
||||
if (!customElements.get("route-announcer")) {
|
||||
const attrs = {
|
||||
"aria-live": "assertive",
|
||||
"aria-atomic": "true",
|
||||
style:
|
||||
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"route-announcer",
|
||||
class RouteAnnouncer extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
connectedCallback() {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
this.setAttribute(key, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
47
quartz/components/scripts/toc.inline.ts
Executable file
47
quartz/components/scripts/toc.inline.ts
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
const bufferPx = 150
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
|
||||
const windowHeight = entry.rootBounds?.height
|
||||
if (windowHeight && tocEntryElement) {
|
||||
if (entry.boundingClientRect.y < windowHeight) {
|
||||
tocEntryElement.classList.add("in-view")
|
||||
} else {
|
||||
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() {
|
||||
const toc = document.getElementById("toc")
|
||||
if (toc) {
|
||||
const collapsed = toc.classList.contains("collapsed")
|
||||
const content = toc.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
toc.addEventListener("click", toggleToc)
|
||||
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", setupToc)
|
||||
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))
|
||||
})
|
||||
45
quartz/components/scripts/util.ts
Executable file
45
quartz/components/scripts/util.ts
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
|
||||
if (!outsideContainer) return
|
||||
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
|
||||
if (e.target !== this) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
|
||||
function esc(e: HTMLElementEventMap["keydown"]) {
|
||||
if (!e.key.startsWith("Esc")) return
|
||||
e.preventDefault()
|
||||
cb()
|
||||
}
|
||||
|
||||
outsideContainer?.addEventListener("click", click)
|
||||
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||
document.addEventListener("keydown", esc)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||
}
|
||||
|
||||
export function removeAllChildren(node: HTMLElement) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||
// containing the URL it's redirecting to.
|
||||
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||
// way less robust - we only care about our own generated redirects after all.
|
||||
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||
|
||||
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const res = await fetch(`${url}`)
|
||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||
return res
|
||||
}
|
||||
// reading the body can only be done once, so we need to clone the response
|
||||
// to allow the caller to read it if it's was not a redirect
|
||||
const text = await res.clone().text()
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
44
quartz/components/styles/backlinks.scss
Executable file
44
quartz/components/styles/backlinks.scss
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.backlinks {
|
||||
flex-direction: column;
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(transparent 0px, var(--light));
|
||||
}*/
|
||||
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
& > li {
|
||||
& > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .overflow {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
height: auto;
|
||||
@media all and not ($desktop) {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
quartz/components/styles/breadcrumbs.scss
Executable file
22
quartz/components/styles/breadcrumbs.scss
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
.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;
|
||||
}
|
||||
36
quartz/components/styles/clipboard.scss
Executable file
36
quartz/components/styles/clipboard.scss
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
.clipboard-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .clipboard-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
14
quartz/components/styles/contentMeta.scss
Executable file
14
quartz/components/styles/contentMeta.scss
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
.content-meta {
|
||||
margin-top: 0;
|
||||
color: var(--gray);
|
||||
|
||||
&[show-comma="true"] {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
quartz/components/styles/darkmode.scss
Executable file
46
quartz/components/styles/darkmode.scss
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
.darkmode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0 10px;
|
||||
text-align: inherit;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 35px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[saved-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] .darkmode {
|
||||
& > #dayIcon {
|
||||
display: none;
|
||||
}
|
||||
& > #nightIcon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
:root .darkmode {
|
||||
& > #dayIcon {
|
||||
display: inline;
|
||||
}
|
||||
& > #nightIcon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
181
quartz/components/styles/explorer.scss
Executable file
181
quartz/components/styles/explorer.scss
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(transparent 0px, var(--light));
|
||||
}*/
|
||||
}
|
||||
|
||||
button#explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h2 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#explorer-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0s;
|
||||
margin-top: 0.5rem;
|
||||
visibility: visible;
|
||||
|
||||
&.collapsed {
|
||||
max-height: 0;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0.35s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.08rem 0;
|
||||
padding: 0;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
transform 0.35s ease,
|
||||
opacity 0.2s ease;
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
> #explorer-ul {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
pointer-events: all;
|
||||
|
||||
& > polyline {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-container {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
& div > a {
|
||||
color: var(--secondary);
|
||||
font-family: var(--headerFont);
|
||||
font-size: 0.95rem;
|
||||
font-weight: $semiBoldWeight;
|
||||
line-height: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& div > a:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
& div > button {
|
||||
color: var(--dark);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--headerFont);
|
||||
|
||||
& span {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
font-weight: $semiBoldWeight;
|
||||
margin: 0;
|
||||
line-height: 1.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 5px;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.folder-icon:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.no-background::after {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
#explorer-end {
|
||||
// needs height so IntersectionObserver gets triggered
|
||||
height: 4px;
|
||||
// remove default margin from li
|
||||
margin: 0;
|
||||
}
|
||||
15
quartz/components/styles/footer.scss
Executable file
15
quartz/components/styles/footer.scss
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
footer {
|
||||
text-align: left;
|
||||
margin-bottom: 4rem;
|
||||
opacity: 0.7;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
}
|
||||
73
quartz/components/styles/graph.scss
Executable file
73
quartz/components/styles/graph.scss
Executable file
|
|
@ -0,0 +1,73 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.graph {
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > .graph-outer {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--lightgray);
|
||||
box-sizing: border-box;
|
||||
height: 250px;
|
||||
margin: 0.5em 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > #global-graph-icon {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dark);
|
||||
opacity: 0.5;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
padding: 0.2rem;
|
||||
margin: 0.3rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.5s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--lightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > #global-graph-outer {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > #global-graph-container {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
quartz/components/styles/legacyToc.scss
Executable file
27
quartz/components/styles/legacyToc.scss
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
40
quartz/components/styles/listPage.scss
Executable file
40
quartz/components/styles/listPage.scss
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
ul.section-ul {
|
||||
list-style: none;
|
||||
margin-top: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li.section-li {
|
||||
margin-bottom: 1em;
|
||||
|
||||
& > .section {
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(8em) 3fr 1fr;
|
||||
|
||||
@media all and ($mobile) {
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& .meta {
|
||||
margin: 0 1em 0 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modifications in popover context
|
||||
.popover .section {
|
||||
grid-template-columns: fit-content(8em) 1fr !important;
|
||||
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
163
quartz/components/styles/mermaid.inline.scss
Executable file
163
quartz/components/styles/mermaid.inline.scss
Executable file
|
|
@ -0,0 +1,163 @@
|
|||
.expand-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
right: 0; // NOTE: right will be set in mermaid.inline.ts
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .expand-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
#mermaid-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > #mermaid-space {
|
||||
display: grid;
|
||||
width: 90%;
|
||||
height: 90vh;
|
||||
margin: 5vh auto;
|
||||
background: var(--light);
|
||||
box-shadow:
|
||||
0 14px 50px rgba(27, 33, 48, 0.12),
|
||||
0 10px 30px rgba(27, 33, 48, 0.16);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
& > .mermaid-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
z-index: 2;
|
||||
max-height: fit-content;
|
||||
|
||||
& > .close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--darkgray);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--lightgray);
|
||||
color: var(--dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .mermaid-content {
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 0.1s ease;
|
||||
overflow: visible;
|
||||
min-height: 200px;
|
||||
min-width: 200px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > .mermaid-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--light);
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2;
|
||||
|
||||
.mermaid-control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-family: var(--bodyFont);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
// Style the reset button differently
|
||||
&:nth-child(2) {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
quartz/components/styles/popover.scss
Executable file
83
quartz/components/styles/popover.scss
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@keyframes dropin {
|
||||
0% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
1% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
z-index: 999;
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
|
||||
& > .popover-inner {
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
max-height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
font-style: initial;
|
||||
line-height: normal;
|
||||
font-size: initial;
|
||||
font-family: var(--bodyFont);
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
||||
overflow: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
& > .popover-inner[data-content-type] {
|
||||
&[data-content-type*="pdf"],
|
||||
&[data-content-type*="image"] {
|
||||
padding: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&[data-content-type*="image"] {
|
||||
img {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-content-type*="pdf"] {
|
||||
iframe {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
visibility 0.3s ease;
|
||||
|
||||
@media all and ($mobile) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover .popover,
|
||||
.popover:hover {
|
||||
animation: dropin 0.3s ease;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
24
quartz/components/styles/recentNotes.scss
Executable file
24
quartz/components/styles/recentNotes.scss
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
234
quartz/components/styles/search.scss
Executable file
234
quartz/components/styles/search.scss
Executable file
|
|
@ -0,0 +1,234 @@
|
|||
@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: var(--light);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
|
||||
& > p {
|
||||
display: inline;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
& svg {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0 0.5rem;
|
||||
|
||||
.search-path {
|
||||
stroke: var(--darkgray);
|
||||
stroke-width: 2px;
|
||||
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) {
|
||||
& > #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;
|
||||
}
|
||||
|
||||
& > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
quartz/components/styles/toc.scss
Executable file
93
quartz/components/styles/toc.scss
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.desktop-only {
|
||||
max-height: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
.toc {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
button#toc {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#toc-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0s;
|
||||
position: relative;
|
||||
visibility: visible;
|
||||
|
||||
&.collapsed {
|
||||
max-height: 0;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0.35s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.collapsed > .overflow::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
> ul.overflow {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
29
quartz/components/types.ts
Executable file
29
quartz/components/types.ts
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
import { ComponentType, JSX } from "preact"
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { Node } from "hast"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export type QuartzComponentProps = {
|
||||
ctx: BuildCtx
|
||||
externalResources: StaticResources
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
children: (QuartzComponent | JSX.Element)[]
|
||||
tree: Node
|
||||
allFiles: QuartzPluginData[]
|
||||
displayClass?: "mobile-only" | "desktop-only"
|
||||
} & JSX.IntrinsicAttributes & {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
|
||||
css?: string
|
||||
beforeDOMLoaded?: string
|
||||
afterDOMLoaded?: string
|
||||
}
|
||||
|
||||
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
|
||||
opts: Options,
|
||||
) => QuartzComponent
|
||||
Loading…
Add table
Add a link
Reference in a new issue