Merge branch 'v4' into v4

This commit is contained in:
enneaa 2025-03-24 16:39:38 +08:00 committed by GitHub
commit 73829a2506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 443 additions and 225 deletions

View File

@ -31,7 +31,7 @@ If you prefer instructions in a video format you can try following Nicole van de
## 🔧 Features
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks|wikilinks, transclusions]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
- Hot-reload on configuration edits and incremental rebuilds for content edits
- Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes

View File

@ -60,3 +60,25 @@ The `DesktopOnly` component is the counterpart to `MobileOnly`. It makes its chi
```typescript
Component.DesktopOnly(Component.TableOfContents())
```
## `ConditionalRender` Component
The `ConditionalRender` component is a wrapper that conditionally renders its child component based on a provided condition function. This is useful for creating dynamic layouts where components should only appear under certain conditions.
```typescript
type ConditionalRenderConfig = {
component: QuartzComponent
condition: (props: QuartzComponentProps) => boolean
}
```
### Example Usage
```typescript
Component.ConditionalRender({
component: Component.Search(),
condition: (props) => props.displayClass !== "fullpage",
})
```
This example would only render the Search component when the page is not in fullpage mode.

View File

@ -62,7 +62,7 @@ The following properties can be used to customize your link previews:
| `socialDescription` | `description` | Description to be used for preview. |
| `socialImage` | `image`, `cover` | Link to preview image. |
The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`.
The `socialImage` property should contain a link to an image either relative to `quartz/static`, or a full URL. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. Alternatively, you can use a fully qualified URL like `"https://example.com/cover.png"`.
> [!info] Info
>

115
package-lock.json generated
View File

@ -30,13 +30,13 @@
"hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.29.2",
"lightningcss": "^1.29.3",
"mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"minimatch": "^10.0.1",
"pixi.js": "^8.8.1",
"pixi.js": "^8.9.0",
"preact": "^10.26.4",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1",
@ -46,7 +46,7 @@
"rehype-citation": "^2.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-pretty-code": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@ -80,7 +80,7 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/node": "^22.13.11",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.0",
@ -1924,9 +1924,9 @@
}
},
"node_modules/@types/node": {
"version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"version": "22.13.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.11.tgz",
"integrity": "sha512-iEUCUJoU0i3VnrCmgoWCXttklWcvoCIx4jzcP22fioIVSdTmjgoEvmAO/QPw6TcS9k5FrNgn4w7q5lGOd1CT5g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3994,9 +3994,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.3.tgz",
"integrity": "sha512-GlOJwTIP6TMIlrTFsxTerwC0W6OpQpCGuX1ECRLBUVRh6fpJH3xTqjCjRgQHTb4ZXexH9rtHou1Lf03GKzmhhQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -4009,22 +4009,22 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
"lightningcss-darwin-arm64": "1.29.3",
"lightningcss-darwin-x64": "1.29.3",
"lightningcss-freebsd-x64": "1.29.3",
"lightningcss-linux-arm-gnueabihf": "1.29.3",
"lightningcss-linux-arm64-gnu": "1.29.3",
"lightningcss-linux-arm64-musl": "1.29.3",
"lightningcss-linux-x64-gnu": "1.29.3",
"lightningcss-linux-x64-musl": "1.29.3",
"lightningcss-win32-arm64-msvc": "1.29.3",
"lightningcss-win32-x64-msvc": "1.29.3"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.3.tgz",
"integrity": "sha512-fb7raKO3pXtlNbQbiMeEu8RbBVHnpyqAoxTyTRMEWFQWmscGC2wZxoHzZ+YKAepUuKT9uIW5vL2QbFivTgprZg==",
"cpu": [
"arm64"
],
@ -4042,9 +4042,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.3.tgz",
"integrity": "sha512-KF2XZ4ZdmDGGtEYmx5wpzn6u8vg7AdBHaEOvDKu8GOs7xDL/vcU2vMKtTeNe1d4dogkDdi3B9zC77jkatWBwEQ==",
"cpu": [
"x64"
],
@ -4062,9 +4062,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.3.tgz",
"integrity": "sha512-VUWeVf+V1UM54jv9M4wen9vMlIAyT69Krl9XjI8SsRxz4tdNV/7QEPlW6JASev/pYdiynUCW0pwaFquDRYdxMw==",
"cpu": [
"x64"
],
@ -4082,9 +4082,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.3.tgz",
"integrity": "sha512-UhgZ/XVNfXQVEJrMIWeK1Laj8KbhjbIz7F4znUk7G4zeGw7TRoJxhb66uWrEsonn1+O45w//0i0Fu0wIovYdYg==",
"cpu": [
"arm"
],
@ -4102,9 +4102,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.3.tgz",
"integrity": "sha512-Pqau7jtgJNmQ/esugfmAT1aCFy/Gxc92FOxI+3n+LbMHBheBnk41xHDhc0HeYlx9G0xP5tK4t0Koy3QGGNqypw==",
"cpu": [
"arm64"
],
@ -4122,9 +4122,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.3.tgz",
"integrity": "sha512-dxakOk66pf7KLS7VRYFO7B8WOJLecE5OPL2YOk52eriFd/yeyxt2Km5H0BjLfElokIaR+qWi33gB8MQLrdAY3A==",
"cpu": [
"arm64"
],
@ -4142,9 +4142,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.3.tgz",
"integrity": "sha512-ySZTNCpbfbK8rqpKJeJR2S0g/8UqqV3QnzcuWvpI60LWxnFN91nxpSSwCbzfOXkzKfar9j5eOuOplf+klKtINg==",
"cpu": [
"x64"
],
@ -4162,9 +4162,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.3.tgz",
"integrity": "sha512-3pVZhIzW09nzi10usAXfIGTTSTYQ141dk88vGFNCgawIzayiIzZQxEcxVtIkdvlEq2YuFsL9Wcj/h61JHHzuFQ==",
"cpu": [
"x64"
],
@ -4182,9 +4182,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.3.tgz",
"integrity": "sha512-VRnkAvtIkeWuoBJeGOTrZxsNp4HogXtcaaLm8agmbYtLDOhQdpgxW6NjZZjDXbvGF+eOehGulXZ3C1TiwHY4QQ==",
"cpu": [
"arm64"
],
@ -4202,9 +4202,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"version": "1.29.3",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.3.tgz",
"integrity": "sha512-IszwRPu2cPnDQsZpd7/EAr0x2W7jkaWqQ1SwCVIZ/tSbZVXPLt6k8s6FkcyBjViCzvB5CW0We0QbbP7zp2aBjQ==",
"cpu": [
"x64"
],
@ -5502,9 +5502,9 @@
}
},
"node_modules/pixi.js": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.8.1.tgz",
"integrity": "sha512-Zmox3Vy52Kl6X/uxknKlxJWPVEFiP63nsX8soqB4butTkIOK3y7c9C204wcDfAgkwO1OlwYxscWtHv+ef4gqgA==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.9.0.tgz",
"integrity": "sha512-uhXZKbus1C4nHu2ZHDCHE7m9BSsGOAuR+rj31VPyN6O8L8TEFs/q5+/43sMBC89EjPahIhvYOSNtY9nnrrY7BA==",
"license": "MIT",
"dependencies": {
"@pixi/colord": "^2.9.6",
@ -5762,9 +5762,10 @@
}
},
"node_modules/rehype-pretty-code": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/rehype-pretty-code/-/rehype-pretty-code-0.14.0.tgz",
"integrity": "sha512-hBeKF/Wkkf3zyUS8lal9RCUuhypDWLQc+h9UrP9Pav25FUm/AQAVh4m5gdvJxh4Oz+U+xKvdsV01p1LdvsZTiQ==",
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/rehype-pretty-code/-/rehype-pretty-code-0.14.1.tgz",
"integrity": "sha512-IpG4OL0iYlbx78muVldsK86hdfNoht0z63AP7sekQNW2QOTmjxB7RbTO+rhIYNGRljgHxgVZoPwUl6bIC9SbjA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.4",
"hast-util-to-string": "^3.0.0",
@ -5777,7 +5778,7 @@
"node": ">=18"
},
"peerDependencies": {
"shiki": "^1.3.0"
"shiki": "^1.0.0 || ^2.0.0 || ^3.0.0"
}
},
"node_modules/rehype-raw": {

View File

@ -56,13 +56,13 @@
"hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.29.2",
"lightningcss": "^1.29.3",
"mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"minimatch": "^10.0.1",
"pixi.js": "^8.8.1",
"pixi.js": "^8.9.0",
"preact": "^10.26.4",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1",
@ -72,7 +72,7 @@
"rehype-citation": "^2.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-pretty-code": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@ -103,7 +103,7 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/node": "^22.13.11",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.0",

View File

@ -18,7 +18,10 @@ export const sharedPageComponents: SharedLayout = {  
// components for pages that display a single page (e.g. a single note)    
export const defaultContentPageLayout: PageLayout = {   
beforeBody: [
// Component.Breadcrumbs(), 
Component.ConditionalRender({
component: Component.Breadcrumbs(),
condition: (page) => page.fileData.slug !== "index",
}),
Component.ArticleTitle(),
Component.ContentMeta(),
],

View File

@ -1,8 +1,8 @@
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 { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
import { classNames } from "../util/lang"
import { trieFromAllFiles } from "../util/ctx"
type CrumbData = {
displayName: string
@ -22,10 +22,6 @@ interface BreadcrumbOptions {
* 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.
*/
@ -36,7 +32,6 @@ const defaultOptions: BreadcrumbOptions = {
spacerSymbol: "",
rootName: "Home",
resolveFrontmatterTitle: true,
hideOnRoot: true,
showCurrentPage: true,
}
@ -48,78 +43,37 @@ function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: Simpl
}
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,
ctx,
}: QuartzComponentProps) => {
// Hide crumbs on root if enabled
if (options.hideOnRoot && fileData.slug === "index") {
return <></>
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
const slugParts = fileData.slug!.split("/")
const pathNodes = trie.ancestryChain(slugParts)
if (!pathNodes) {
return null
}
// 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 < slugParts.length - 1
// Format and add current crumb
const crumb = formatCrumb(
curPathSegment,
fileData.slug!,
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
)
crumbs.push(crumb)
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
if (idx === 0) {
crumb.displayName = options.rootName
}
// Add current file to crumb (can directly use frontmatter title)
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
// For last node (current page), set empty path
if (idx === pathNodes.length - 1) {
crumb.path = ""
}
return crumb
})
if (!options.showCurrentPage) {
crumbs.pop()
}
return (

View File

@ -0,0 +1,22 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
type ConditionalRenderConfig = {
component: QuartzComponent
condition: (props: QuartzComponentProps) => boolean
}
export default ((config: ConditionalRenderConfig) => {
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
if (config.condition(props)) {
return <config.component {...props} />
}
return null
}
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
ConditionalRender.css = config.component.css
return ConditionalRender
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>

View File

@ -1,15 +1,14 @@
import { pathToRoot, slugTag } from "../util/path"
import { FullSlug, resolveRelative } 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)}`
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
return (
<li>
<a href={linkDest} class="internal tag-link">

View File

@ -21,6 +21,7 @@ import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
export {
ArticleTitle,
@ -46,4 +47,5 @@ export {
Breadcrumbs,
Comments,
Flex,
ConditionalRender,
}

View File

@ -8,7 +8,8 @@ import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
import { FileTrieNode } from "../../util/fileTrie"
import { trieFromAllFiles } from "../../util/ctx"
interface FolderContentOptions {
/**
* Whether to display number of folders
@ -25,31 +26,11 @@ const defaultOptions: FolderContentOptions = {
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
let trie: FileTrieNode<
QuartzPluginData & {
slug: string
title: string
filePath: string
}
>
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props
if (!trie) {
trie = new FileTrieNode([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
}
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
const folder = trie.findNode(fileData.slug!.split("/"))
if (!folder) {
return null

View File

@ -1,7 +1,7 @@
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 { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx"
@ -74,10 +74,13 @@ export default ((opts?: Partial<TagContentOptions>) => {
? contentPage?.description
: htmlToJsx(contentPage.filePath!, root)
const tagListingPage = `/tags/${tag}` as FullSlug
const href = resolveRelative(fileData.slug!, tagListingPage)
return (
<div>
<h2>
<a class="internal tag-link" href={`../tags/${tag}`}>
<a class="internal tag-link" href={href}>
{tag}
</a>
</h2>

View File

@ -36,6 +36,8 @@
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: auto;
white-space: normal;
user-select: none;
cursor: default;
}
& > .popover-inner[data-content-type] {

View File

@ -1,14 +1,21 @@
import { resolveRelative, simplifySlug } from "../../util/path"
import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
import { VFile } from "vfile"
import path from "path"
async function* processFile(ctx: BuildCtx, file: VFile) {
const ogSlug = simplifySlug(file.data.slug!)
for (const slug of file.data.aliases ?? []) {
const redirUrl = resolveRelative(slug, file.data.slug!)
for (const aliasTarget of file.data.aliases ?? []) {
const aliasTargetSlug = (
isRelativeURL(aliasTarget)
? path.normalize(path.join(ogSlug, "..", aliasTarget))
: aliasTarget
) as FullSlug
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug)
yield write({
ctx,
content: `
@ -23,7 +30,7 @@ async function* processFile(ctx: BuildCtx, file: VFile) {
</head>
</html>
`,
slug,
slug: aliasTargetSlug,
ext: ".html",
})
}

View File

@ -88,89 +88,108 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId
componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement("script")
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}"
gtagScript.defer = true
document.head.appendChild(gtagScript)
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "${tagId}", { send_page_view: false });
document.addEventListener("nav", () => {
gtag("event", "page_view", {
page_title: document.title,
page_location: location.href,
const gtagScript = document.createElement('script');
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
gtagScript.defer = true;
gtagScript.onload = () => {
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', '${tagId}', { send_page_view: false });
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
document.addEventListener('nav', () => {
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
});
});`)
};
document.head.appendChild(gtagScript);
`)
} else if (cfg.analytics?.provider === "plausible") {
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
componentResources.afterDOMLoaded.push(`
const plausibleScript = document.createElement("script")
plausibleScript.src = "${plausibleHost}/js/script.manual.js"
plausibleScript.setAttribute("data-domain", location.hostname)
plausibleScript.defer = true
document.head.appendChild(plausibleScript)
const plausibleScript = document.createElement('script');
plausibleScript.src = '${plausibleHost}/js/script.manual.js';
plausibleScript.setAttribute('data-domain', location.hostname);
plausibleScript.defer = true;
plausibleScript.onload = () => {
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
plausible('pageview');
document.addEventListener('nav', () => {
plausible('pageview');
});
};
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
document.addEventListener("nav", () => {
plausible("pageview")
})
document.head.appendChild(plausibleScript);
`)
} else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script")
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.setAttribute("data-auto-track", "false")
umamiScript.defer = true
document.head.appendChild(umamiScript)
document.addEventListener("nav", () => {
const umamiScript = document.createElement("script");
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js";
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
umamiScript.setAttribute("data-auto-track", "false");
umamiScript.defer = true;
umamiScript.onload = () => {
umami.track();
})
document.addEventListener("nav", () => {
umami.track();
});
};
document.head.appendChild(umamiScript);
`)
} else if (cfg.analytics?.provider === "goatcounter") {
componentResources.afterDOMLoaded.push(`
const goatcounterScript = document.createElement("script")
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
goatcounterScript.defer = true
goatcounterScript.setAttribute("data-goatcounter",
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
document.head.appendChild(goatcounterScript)
const goatcounterScript = document.createElement('script');
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
goatcounterScript.defer = true;
goatcounterScript.setAttribute(
'data-goatcounter',
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count"
);
goatcounterScript.onload = () => {
window.goatcounter = { no_onload: true };
goatcounter.count({ path: location.pathname });
document.addEventListener('nav', () => {
goatcounter.count({ path: location.pathname });
});
};
window.goatcounter = { no_onload: true }
document.addEventListener("nav", () => {
goatcounter.count({ path: location.pathname })
})
document.head.appendChild(goatcounterScript);
`)
} else if (cfg.analytics?.provider === "posthog") {
componentResources.afterDOMLoaded.push(`
const posthogScript = document.createElement("script")
const posthogScript = document.createElement("script");
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${cfg.analytics.apiKey}', {
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
capture_pageview: false,
})\`
document.head.appendChild(posthogScript)
posthogScript.onload = () => {
posthog.capture('$pageview', { path: location.pathname });
document.addEventListener('nav', () => {
posthog.capture('$pageview', { path: location.pathname });
});
};
document.addEventListener("nav", () => {
posthog.capture('$pageview', { path: location.pathname })
})
document.head.appendChild(posthogScript);
`)
} else if (cfg.analytics?.provider === "tinylytics") {
const siteId = cfg.analytics.siteId
componentResources.afterDOMLoaded.push(`
const tinylyticsScript = document.createElement("script")
tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js?spa"
tinylyticsScript.defer = true
document.head.appendChild(tinylyticsScript)
document.addEventListener("nav", () => {
window.tinylytics.triggerUpdate()
})
const tinylyticsScript = document.createElement('script');
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
tinylyticsScript.defer = true;
tinylyticsScript.onload = () => {
window.tinylytics.triggerUpdate();
document.addEventListener('nav', () => {
window.tinylytics.triggerUpdate();
});
};
document.head.appendChild(tinylyticsScript);
`)
} else if (cfg.analytics?.provider === "cabin") {
componentResources.afterDOMLoaded.push(`

View File

@ -1,7 +1,7 @@
import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension, joinSegments, QUARTZ } from "../../util/path"
import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp"
import satori, { SatoriOptions } from "satori"
@ -144,13 +144,19 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
additionalHead: [
(pageData) => {
const isRealFile = pageData.filePath !== undefined
const userDefinedOgImagePath = pageData.frontmatter?.socialImage
let userDefinedOgImagePath = pageData.frontmatter?.socialImage
if (userDefinedOgImagePath) {
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
? userDefinedOgImagePath
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
}
const generatedOgImagePath = isRealFile
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
: undefined
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
return (
<>

View File

@ -114,6 +114,10 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
if (socialImage) data.socialImage = socialImage
// Remove duplicate slugs
const uniqueSlugs = [...new Set(allSlugs)]
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
// fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
}

View File

@ -1,4 +1,6 @@
import { QuartzConfig } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
import { FileTrieNode } from "./fileTrie"
import { FilePath, FullSlug } from "./path"
export interface Argv {
@ -13,13 +15,36 @@ export interface Argv {
concurrency?: number
}
export type BuildTimeTrieData = QuartzPluginData & {
slug: string
title: string
filePath: string
}
export interface BuildCtx {
buildId: string
argv: Argv
cfg: QuartzConfig
allSlugs: FullSlug[]
allFiles: FilePath[]
trie?: FileTrieNode<BuildTimeTrieData>
incremental: boolean
}
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg">
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
const trie = new FileTrieNode<BuildTimeTrieData>([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
return trie
}
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">

View File

@ -330,4 +330,86 @@ describe("FileTrie", () => {
)
})
})
describe("pathToNode", () => {
test("should return root node for empty path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain([])
assert.deepStrictEqual(path, [trie])
})
test("should return root node for index path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain(["index"])
assert.deepStrictEqual(path, [trie])
})
test("should return path to first level node", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["test"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
test("should return path to nested node", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder", "subfolder", "test"])
assert.deepStrictEqual(path, [
trie,
trie.children[0],
trie.children[0].children[0],
trie.children[0].children[0].children[0],
])
})
test("should return undefined for non-existent path", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["nonexistent"])
assert.strictEqual(path, undefined)
})
test("should return file data for intermediate folders", () => {
const data1 = {
title: "Root",
slug: "index",
filePath: "index.md",
}
const data2 = {
title: "Test",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
const data3 = {
title: "Folder Index",
slug: "folder/index",
filePath: "folder/index.md",
}
trie.add(data1)
trie.add(data2)
trie.add(data3)
const path = trie.ancestryChain(["folder", "subfolder"])
assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
assert.strictEqual(path[1].data, data3)
})
test("should return path for partial path", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
})
})

View File

@ -97,6 +97,24 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
}
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return [this]
}
const child = this.children.find((c) => c.slugSegment === path[0])
if (!child) {
return undefined
}
const childPath = child.ancestryChain(path.slice(1))
if (!childPath) {
return undefined
}
return [this, ...childPath]
}
/**
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
*/

View File

@ -1,7 +1,7 @@
import test, { describe } from "node:test"
import * as path from "./path"
import assert from "node:assert"
import { FullSlug, TransformOptions } from "./path"
import { FullSlug, TransformOptions, SimpleSlug } from "./path"
describe("typeguards", () => {
test("isSimpleSlug", () => {
@ -38,6 +38,17 @@ describe("typeguards", () => {
assert(!path.isRelativeURL("./abc/def.md"))
})
test("isAbsoluteURL", () => {
assert(path.isAbsoluteURL("https://example.com"))
assert(path.isAbsoluteURL("http://example.com"))
assert(path.isAbsoluteURL("ftp://example.com/a/b/c"))
assert(path.isAbsoluteURL("http://host/%25"))
assert(path.isAbsoluteURL("file://host/twoslashes?more//slashes"))
assert(!path.isAbsoluteURL("example.com/abc/def"))
assert(!path.isAbsoluteURL("abc"))
})
test("isFullSlug", () => {
assert(path.isFullSlug("index"))
assert(path.isFullSlug("abc/def"))
@ -303,3 +314,50 @@ describe("link strategies", () => {
})
})
})
describe("resolveRelative", () => {
test("from index", () => {
assert.strictEqual(path.resolveRelative("index" as FullSlug, "index" as FullSlug), "./")
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc" as FullSlug), "./abc")
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def" as FullSlug),
"./abc/def",
)
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def/ghi" as FullSlug),
"./abc/def/ghi",
)
})
test("from nested page", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "abc" as FullSlug), "../abc")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/def" as FullSlug),
"../abc/def",
)
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "ghi/jkl" as FullSlug),
"../ghi/jkl",
)
})
test("with index paths", () => {
assert.strictEqual(path.resolveRelative("abc/index" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(
path.resolveRelative("abc/def/index" as FullSlug, "index" as FullSlug),
"../../",
)
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc/index" as FullSlug), "./abc/")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/index" as FullSlug),
"../abc/",
)
})
test("with simple slugs", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "" as SimpleSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi" as SimpleSlug), "../ghi")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi/" as SimpleSlug), "../ghi/")
})
})

View File

@ -1,6 +1,7 @@
import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
import { clone } from "./clone"
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
@ -39,6 +40,15 @@ export function isRelativeURL(s: string): s is RelativeURL {
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
}
export function isAbsoluteURL(s: string): boolean {
try {
new URL(s)
} catch {
return false
}
return true
}
export function getFullSlug(window: Window): FullSlug {
const res = window.document.body.dataset.slug! as FullSlug
return res