An article header
+Some content
+Some content
+My favourite number is {opts.favouriteNumber}
+ } + + return YourComponent +}) satisfies QuartzComponentConstructor +``` + +### Props + +The Quartz component itself (lines 11-17 highlighted above) looks like a React component. It takes in properties (sometimes called [props](https://react.dev/learn/passing-props-to-a-component)) and returns JSX. + +All Quartz components accept the same set of props: + +```tsx title="quartz/components/types.ts" +// simplified for sake of demonstration +export type QuartzComponentProps = { + fileData: QuartzPluginData + cfg: GlobalConfiguration + tree: NodeExample Component
+ } + + YourComponent.css = ` + p.red-text { + color: red; + } + ` + + return YourComponent +}) satisfies QuartzComponentConstructor +``` + +Imported styles, however, can be from SCSS files: + +```tsx {1-2,9} title="quartz/components/YourComponent.tsx" +// assuming your stylesheet is in quartz/components/styles/YourComponent.scss +import styles from "./styles/YourComponent.scss" + +export default (() => { + function YourComponent() { + returnExample Component
+ } + + YourComponent.css = styles + return YourComponent +}) satisfies QuartzComponentConstructor +``` + +> [!warning] +> Quartz does not use CSS modules so any styles you declare here apply _globally_. If you only want it to apply to your component, make sure you use specific class names and selectors. + +### Scripts and Interactivity + +What about interactivity? Suppose you want to add an-click handler for example. Like the `.css` property on the component, you can also declare `.beforeDOMLoaded` and `.afterDOMLoaded` properties that are strings that contain the script. + +```tsx title="quartz/components/YourComponent.tsx" +export default (() => { + function YourComponent() { + return + } + + YourComponent.beforeDOM = ` + console.log("hello from before the page loads!") + ` + + YourComponent.afterDOM = ` + document.getElementById('btn').onclick = () => { + alert('button clicked!') + } + ` + return YourComponent +}) satisfies QuartzComponentConstructor +``` + +> [!hint] +> For those coming from React, Quartz components are different from React components in that it only uses JSX for templating and layout. Hooks like `useEffect`, `useState`, etc. are not rendered and other properties that accept functions like `onClick` handlers will not work. Instead, do it using a regular JS script that modifies the DOM element directly. + +As the names suggest, the `.beforeDOMLoaded` scripts are executed _before_ the page is done loading so it doesn't have access to any elements on the page. This is mostly used to prefetch any critical data. + +The `.afterDOMLoaded` script executes once the page has been completely loaded. This is a good place to setup anything that should last for the duration of a site visit (e.g. getting something saved from local storage). + +If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you can listen for the `"nav"` event that gets fired whenever a page loads (which may happen on navigation if [[SPA Routing]] is enabled). + +```ts +document.addEventListener("nav", () => { + // do page specific logic here + // e.g. attach event listeners + const toggleSwitch = document.querySelector("#switch") as HTMLInputElement + toggleSwitch.addEventListener("change", switchTheme) + window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) +}) +``` + +It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. +This will get called on page navigation. + +#### Importing Code + +Of course, it isn't always practical (nor desired!) to write your code as a string literal in the component. + +Quartz supports importing component code through `.inline.ts` files. + +```tsx title="quartz/components/YourComponent.tsx" +// @ts-ignore: typescript doesn't know about our inline bundling system +// so we need to silence the error +import script from "./scripts/graph.inline" + +export default (() => { + function YourComponent() { + return + } + + YourComponent.afterDOM = script + return YourComponent +}) satisfies QuartzComponentConstructor +``` + +```ts title="quartz/components/scripts/graph.inline.ts" +// any imports here are bundled for the browser +import * as d3 from "d3" + +document.getElementById("btn").onclick = () => { + alert("button clicked!") +} +``` + +Additionally, like what is shown in the example above, you can import packages in `.inline.ts` files. This will be bundled by Quartz and included in the actual script. + +### Using a Component + +After creating your custom component, re-export it in `quartz/components/index.ts`: + +```ts title="quartz/components/index.ts" {4,10} +import ArticleTitle from "./ArticleTitle" +import Content from "./pages/Content" +import Darkmode from "./Darkmode" +import YourComponent from "./YourComponent" + +export { ArticleTitle, Content, Darkmode, YourComponent } +``` + +Then, you can use it like any other component in `quartz.layout.ts` via `Component.YourComponent()`. See the [[configuration#Layout|layout]] section for more details. + +As Quartz components are just functions that return React components, you can compositionally use them in other Quartz components. + +```tsx title="quartz/components/AnotherComponent.tsx" +import YourComponent from "./YourComponent" + +export default (() => { + function AnotherComponent(props: QuartzComponentProps) { + return ( +It's nested!
++ {segmentsElements} +
+ ) + } else { + return null + } + } + + ContentMetadata.css = style + + return ContentMetadata +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Darkmode.tsx b/quartz/components/Darkmode.tsx new file mode 100644 index 0000000..8ed7c99 --- /dev/null +++ b/quartz/components/Darkmode.tsx @@ -0,0 +1,53 @@ +// @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 ( ++ + {i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })} + +
+ )} +{i18n(cfg.locale).components.search.title}
+ + +{i18n(cfg.locale).pages.error.notFound}
++ {i18n(cfg.locale).pages.folderContent.itemsUnderFolder({ + count: allPagesInFolder.length, + })} +
+ )} +{content}
+{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}
+{content}
} ++ {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })} + {pages.length > numPages && ( + <> + {" "} + + {i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })} + + > + )} +
+{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
+#${tag}
#${tag}
${content}
` + }` + itemTile.addEventListener("click", (event) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + hideSearch() + }) + + const handler = (event: MouseEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + hideSearch() + } + + async function onMouseEnter(ev: MouseEvent) { + if (!ev.target) return + const target = ev.target as HTMLInputElement + await displayPreview(target) + } + + itemTile.addEventListener("mouseenter", onMouseEnter) + window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter)) + itemTile.addEventListener("click", handler) + window.addCleanup(() => itemTile.removeEventListener("click", handler)) + + return itemTile + } + + async function displayResults(finalResults: Item[]) { + if (!results) return + + removeAllChildren(results) + if (finalResults.length === 0) { + results.innerHTML = ` +Try another search term?
+ ` + } else { + results.append(...finalResults.map(resultToHTML)) + } + + if (finalResults.length === 0 && preview) { + // no results, clear previous preview + removeAllChildren(preview) + } else { + // focus on first result, then also dispatch preview immediately + const firstChild = results.firstElementChild as HTMLElement + firstChild.classList.add("focus") + currentHover = firstChild as HTMLInputElement + await displayPreview(firstChild) + } + } + + async function fetchContent(slug: FullSlug): PromiseTransclude of ${url}${block}`, + } + } + + // otherwise, fall through to regular link + } + + // internal link + const url = fp + anchor + return { + type: "link", + url, + children: [ + { + type: "text", + value: alias ?? fp, + }, + ], + } + }, + ]) + } + + if (opts.highlight) { + replacements.push([ + highlightRegex, + (_value: string, ...capture: string[]) => { + const [inner] = capture + return { + type: "html", + value: `${inner}`, + } + }, + ]) + } + + if (opts.parseArrows) { + replacements.push([ + arrowRegex, + (value: string, ..._capture: string[]) => { + const maybeArrow = arrowMapping[value] + if (maybeArrow === undefined) return SKIP + return { + type: "html", + value: `${maybeArrow}`, + } + }, + ]) + } + + if (opts.parseTags) { + replacements.push([ + tagRegex, + (_value: string, tag: string) => { + // Check if the tag only includes numbers + if (/^\d+$/.test(tag)) { + return false + } + + tag = slugTag(tag) + if (file.data.frontmatter) { + const noteTags = file.data.frontmatter.tags ?? [] + file.data.frontmatter.tags = [...new Set([...noteTags, tag])] + } + + return { + type: "link", + url: base + `/tags/${tag}`, + data: { + hProperties: { + className: ["tag-link"], + }, + }, + children: [ + { + type: "text", + value: tag, + }, + ], + } + }, + ]) + } + + if (opts.enableInHtmlEmbed) { + visit(tree, "html", (node: Html) => { + for (const [regex, replace] of replacements) { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace) + } else { + node.value = node.value.replace(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args) + if (typeof replaceValue === "string") { + return replaceValue + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join("") + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue) + } else { + return substring + } + }) + } + } + }) + } + mdastFindReplace(tree, replacements) + } + }) + + if (opts.enableVideoEmbed) { + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "image", (node, index, parent) => { + if (parent && index != undefined && videoExtensionRegex.test(node.url)) { + const newNode: Html = { + type: "html", + value: ``, + } + + parent.children.splice(index, 1, newNode) + return SKIP + } + }) + } + }) + } + + if (opts.callouts) { + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "blockquote", (node) => { + if (node.children.length === 0) { + return + } + + // find first line + const firstChild = node.children[0] + if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { + return + } + + const text = firstChild.children[0].value + const restOfTitle = firstChild.children.slice(1) + const [firstLine, ...remainingLines] = text.split("\n") + const remainingText = remainingLines.join("\n") + + const match = firstLine.match(calloutRegex) + if (match && match.input) { + const [calloutDirective, typeString, collapseChar] = match + const calloutType = canonicalizeCallout(typeString.toLowerCase()) + const collapse = collapseChar === "+" || collapseChar === "-" + const defaultState = collapseChar === "-" ? "collapsed" : "expanded" + const titleContent = match.input.slice(calloutDirective.length).trim() + const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 + const titleNode: Paragraph = { + type: "paragraph", + children: [ + { + type: "text", + value: useDefaultTitle ? capitalize(typeString) : titleContent + " ", + }, + ...restOfTitle, + ], + } + const title = mdastToHtml(titleNode) + + const toggleIcon = `` + + const titleHtml: Html = { + type: "html", + value: `