Merge branch 'v4' into v4

This commit is contained in:
Jude Wang 2024-10-10 11:31:50 +08:00 committed by GitHub
commit 5a5b3a4511
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
148 changed files with 4910 additions and 1654 deletions

View File

@ -1,11 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
production-dependencies:
applies-to: "version-updates"
patterns:
- "*"

View File

@ -7,6 +7,7 @@ on:
push:
branches:
- v4
workflow_dispatch:
jobs:
build-and-test:
@ -18,17 +19,17 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Cache dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
@ -47,22 +48,22 @@ jobs:
run: npx quartz build --bundleInfo
publish-tag:
if: ${{ github.repository == 'jackyzha0/quartz' }}
if: ${{ github.repository == 'jackyzha0/quartz' && github.ref == 'refs/heads/v4' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Get package version
run: node -p -e '`PACKAGE_VERSION=${require("./package.json").version}`' >> $GITHUB_ENV
- name: Create release tag
uses: pkgdeps/git-tag-action@v2
uses: pkgdeps/git-tag-action@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_repo: ${{ github.repository }}

View File

@ -0,0 +1,88 @@
name: Docker build & push image
on:
push:
branches: [v4]
tags: ["v*"]
pull_request:
branches: [v4]
paths:
- .github/workflows/docker-build-push.yaml
- quartz/**
workflow_dispatch:
jobs:
build:
if: ${{ github.repository == 'jackyzha0/quartz' }} # Comment this out if you want to publish your own images on a fork!
runs-on: ubuntu-latest
steps:
- name: Set lowercase repository owner environment variable
run: |
echo "OWNER_LOWERCASE=${OWNER,,}" >> ${GITHUB_ENV}
env:
OWNER: "${{ github.repository_owner }}"
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@v4.4.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
driver-opts: |
image=moby/buildkit:master
network=host
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.7.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata tags and labels on PRs
if: github.event_name == 'pull_request'
id: meta-pr
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ env.OWNER_LOWERCASE }}/quartz
tags: |
type=raw,value=sha-${{ env.GITHUB_SHA_SHORT }}
labels: |
org.opencontainers.image.source="https://github.com/${{ github.repository_owner }}/quartz"
- name: Extract metadata tags and labels for main, release or tag
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@v5
with:
flavor: |
latest=auto
images: ghcr.io/${{ env.OWNER_LOWERCASE }}/quartz
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=raw,value=sha-${{ env.GITHUB_SHA_SHORT }}
labels: |
maintainer=${{ github.repository_owner }}
org.opencontainers.image.source="https://github.com/${{ github.repository_owner }}/quartz"
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
build-args: |
GIT_SHA=${{ env.GITHUB_SHA }}
DOCKER_LABEL=sha-${{ env.GITHUB_SHA_SHORT }}
tags: ${{ steps.meta.outputs.tags || steps.meta-pr.outputs.tags }}
labels: ${{ steps.meta.outputs.labels || steps.meta-pr.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

1
.node-version Normal file
View File

@ -0,0 +1 @@
v20.9.0

View File

@ -1,4 +1,4 @@
FROM node:20-slim as builder
FROM node:20-slim AS builder
WORKDIR /usr/src/app
COPY package.json .
COPY package-lock.json* .

View File

@ -129,11 +129,11 @@ export default (() => {
return <button id="btn">Click me</button>
}
YourComponent.beforeDOM = `
YourComponent.beforeDOMLoaded = `
console.log("hello from before the page loads!")
`
YourComponent.afterDOM = `
YourComponent.afterDOMLoaded = `
document.getElementById('btn').onclick = () => {
alert('button clicked!')
}
@ -180,7 +180,7 @@ export default (() => {
return <button id="btn">Click me</button>
}
YourComponent.afterDOM = script
YourComponent.afterDOMLoaded = script
return YourComponent
}) satisfies QuartzComponentConstructor
```

View File

@ -260,11 +260,11 @@ export const ContentPage: QuartzEmitterPlugin = () => {
...defaultContentPageLayout,
pageBody: Content(),
}
const { head, header, beforeBody, pageBody, left, right, footer } = layout
const { head, header, beforeBody, pageBody, afterBody, left, right, footer } = layout
return {
name: "ContentPage",
getQuartzComponents() {
return [head, ...header, ...beforeBody, pageBody, ...left, ...right, footer]
return [head, ...header, ...beforeBody, pageBody, ...afterBody, ...left, ...right, footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration

View File

@ -48,4 +48,4 @@ Here are the main types of slugs with a rough description of each type of path:
- `SimpleSlug`: cannot be relative and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path.
- `RelativeURL`: must start with `.` or `..` to indicate it's a relative URL. Shouldn't have `/index` as an ending or a file extension but can contain a trailing slash.
To get a clearer picture of how these relate to each other, take a look at the path tests in `quartz/path.test.ts`.
To get a clearer picture of how these relate to each other, take a look at the path tests in `quartz/util/path.test.ts`.

View File

@ -29,6 +29,7 @@ Some common frontmatter fields that are natively supported by Quartz:
- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title.
- `description`: Description of the page used for link previews.
- `permalink`: A custom URL for the page that will remain constant even if the path to the file changes.
- `aliases`: Other names for this note. This is a list of strings.
- `tags`: Tags for this note.
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.

View File

@ -21,3 +21,7 @@ This will start a local web server to run your Quartz on your computer. Open a w
> - `--serve`: run a local hot-reloading server to preview your Quartz
> - `--port`: what port to run the local preview server on
> - `--concurrency`: how many threads to use to parse notes
> [!warning] Not to be used for production
> Serve mode is intended for local previews only.
> For production workloads, see the page on [[hosting]].

View File

@ -21,6 +21,7 @@ const config: QuartzConfig = {
This part of the configuration concerns anything that can affect the whole site. The following is a list breaking down all the things you can configure:
- `pageTitle`: title of the site. This is also used when generating the [[RSS Feed]] for your site.
- `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page.
- `enableSPA`: whether to enable [[SPA Routing]] on your site.
- `enablePopovers`: whether to enable [[popover previews]] on your site.
- `analytics`: what to use for analytics on your site. Values can be
@ -28,6 +29,11 @@ This part of the configuration concerns anything that can affect the whole site.
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;
- `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: '<your-plausible-host>' }` (self-hosted): use [Plausible](https://plausible.io/);
- `{ provider: 'umami', host: '<your-umami-host>', websiteId: '<your-umami-website-id>' }`: use [Umami](https://umami.is/);
- `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id' }` (managed) or `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id', host: 'my-goatcounter-domain.com', scriptSrc: 'https://my-url.to/counter.js' }` (self-hosted) use [GoatCounter](https://goatcounter.com);
- `{ provider: 'posthog', apiKey: '<your-posthog-project-apiKey>', host: '<your-posthog-host>' }`: use [Posthog](https://posthog.com/);
- `{ provider: 'tinylytics', siteId: '<your-site-id>' }`: use [Tinylytics](https://tinylytics.app/);
- `{ provider: 'cabin' }` or `{ provider: 'cabin', host: 'https://cabin.example.com' }` (custom domain): use [Cabin](https://withcabin.com);
- `{provider: 'clarity', projectId: '<your-clarity-id-code' }`: use [Microsoft clarity](https://clarity.microsoft.com/). The project id can be found on top of the overview page.
- `locale`: used for [[i18n]] and date formatting
- `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes.
- This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`.
@ -49,6 +55,7 @@ This part of the configuration concerns anything that can affect the whole site.
- `secondary`: link colour, current [[graph view|graph]] node
- `tertiary`: hover states and visited [[graph view|graph]] nodes
- `highlight`: internal link background, highlighted text, [[syntax highlighting|highlighted lines of code]]
- `textHighlight`: markdown highlighted text background
## Plugins
@ -73,10 +80,10 @@ You can customize the behaviour of Quartz by adding, removing and reordering plu
> [!note]
> Each node is modified by every transformer _in order_. Some transformers are position sensitive, so you may need to pay particular attention to whether they need to come before or after certain other plugins.
You should take care to add the plugin to the right entry corresponding to its plugin type. For example, to add the [[ExplicitPublish]] plugin (a [[tags/plugin/transformer|Transformer]], you would add the following line:
You should take care to add the plugin to the right entry corresponding to its plugin type. For example, to add the [[ExplicitPublish]] plugin (a [[tags/plugin/filter|Filter]]), you would add the following line:
```ts title="quartz.config.ts"
transformers: [
filters: [
...
Plugin.ExplicitPublish(),
...
@ -96,7 +103,7 @@ transformers: [
]
```
Some plugins are included by default in the[ `quartz.config.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz.config.ts), but there are more available.
Some plugins are included by default in the [`quartz.config.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz.config.ts), but there are more available.
You can see a list of all plugins and their configuration options [[tags/plugin|here]].

View File

@ -39,6 +39,17 @@ a & b & c
\end{bmatrix}
$$
$$
\begin{array}{rll}
E \psi &= H\psi & \text{Expanding the Hamiltonian Operator} \\
&= -\frac{\hbar^2}{2m}\frac{\partial^2}{\partial x^2} \psi + \frac{1}{2}m\omega x^2 \psi & \text{Using the ansatz $\psi(x) = e^{-kx^2}f(x)$, hoping to cancel the $x^2$ term} \\
&= -\frac{\hbar^2}{2m} [4k^2x^2f(x)+2(-2kx)f'(x) + f''(x)]e^{-kx^2} + \frac{1}{2}m\omega x^2 f(x)e^{-kx^2} &\text{Removing the $e^{-kx^2}$ term from both sides} \\
& \Downarrow \\
Ef(x) &= -\frac{\hbar^2}{2m} [4k^2x^2f(x)-4kxf'(x) + f''(x)] + \frac{1}{2}m\omega x^2 f(x) & \text{Choosing $k=\frac{im}{2}\sqrt{\frac{\omega}{\hbar}}$ to cancel the $x^2$ term, via $-\frac{\hbar^2}{2m}4k^2=\frac{1}{2}m \omega$} \\
&= -\frac{\hbar^2}{2m} [-4kxf'(x) + f''(x)] \\
\end{array}
$$
> [!warn]
> Due to limitations in the [underlying parsing library](https://github.com/remarkjs/remark-math), block math in Quartz requires the `$$` delimiters to be on newlines like above.

View File

@ -0,0 +1,28 @@
---
title: "Roam Research Compatibility"
tags:
- feature/transformer
---
[Roam Research](https://roamresearch.com) is a note-taking tool that organizes your knowledge graph in a unique and interconnected way.
Quartz supports transforming the special Markdown syntax from Roam Research (like `{{[[components]]}}` and other formatting) into
regular Markdown via the [[RoamFlavoredMarkdown]] plugin.
```typescript title="quartz.config.ts"
plugins: {
transformers: [
// ...
Plugin.RoamFlavoredMarkdown(),
Plugin.ObsidianFlavoredMarkdown(),
// ...
],
},
```
> [!warning]
> As seen above placement of `Plugin.RoamFlavoredMarkdown()` within `quartz.config.ts` is very important. It must come before `Plugin.ObsidianFlavoredMarkdown()`.
## Customization
This functionality is provided by the [[RoamFlavoredMarkdown]] plugin. See the plugin page for customization options.

83
docs/features/comments.md Normal file
View File

@ -0,0 +1,83 @@
---
title: Comments
tags:
- component
---
Quartz also has the ability to hook into various providers to enable readers to leave comments on your site.
![[giscus-example.png]]
As of today, only [Giscus](https://giscus.app/) is supported out of the box but PRs to support other providers are welcome!
## Providers
### Giscus
First, make sure that the [[setting up your GitHub repository|GitHub]] repository you are using for your Quartz meets the following requirements:
1. The **repository is [public](https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/setting-repository-visibility#making-a-repository-public)**, otherwise visitors will not be able to view the discussion.
2. The **[giscus](https://github.com/apps/giscus) app is installed**, otherwise visitors will not be able to comment and react.
3. The **Discussions feature is turned on** by [enabling it for your repository](https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/enabling-or-disabling-github-discussions-for-a-repository).
Then, use the [Giscus site](https://giscus.app/#repository) to figure out what your `repoId` and `categoryId` should be. Make sure you select `Announcements` for the Discussion category.
![[giscus-repo.png]]
![[giscus-discussion.png]]
After entering both your repository and selecting the discussion category, Giscus will compute some IDs that you'll need to provide back to Quartz. You won't need to manually add the script yourself as Quartz will handle that part for you but will need these values in the next step!
![[giscus-results.png]]
Finally, in `quartz.layout.ts`, edit the `afterBody` field of `sharedPageComponents` to include the following options but with the values you got from above:
```ts title="quartz.layout.ts"
afterBody: [
Component.Comments({
provider: 'giscus',
options: {
// from data-repo
repo: 'jackyzha0/quartz',
// from data-repo-id
repoId: 'MDEwOlJlcG9zaXRvcnkzODcyMTMyMDg',
// from data-category
category: 'Announcements',
// from data-category-id
categoryId: 'DIC_kwDOFxRnmM4B-Xg6',
}
}),
],
```
### Customization
Quartz also exposes a few of the other Giscus options as well and you can provide them the same way `repo`, `repoId`, `category`, and `categoryId` are provided.
```ts
type Options = {
provider: "giscus"
options: {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: string
// how to map pages -> discussions
// defaults to 'url'
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
// use strict title matching
// defaults to true
strict?: boolean
// whether to enable reactions for the main post
// defaults to true
reactionsEnabled?: boolean
// where to put the comment input box relative to the comments
// defaults to 'bottom'
inputPosition?: "top" | "bottom"
}
}
```

View File

@ -30,4 +30,4 @@ As with folder listings, you can also provide a description and title for a tag
## Customization
The folder listings are a functionality of the [[FolderPage]] plugin, the tag listings of the [[TagPage]] plugin. See the plugin pages for customization options.
Quartz allows you to define a custom sort ordering for content on both page types. The folder listings are a functionality of the [[FolderPage]] plugin, the tag listings of the [[TagPage]] plugin. See the plugin pages for customization options.

View File

@ -9,6 +9,7 @@ Quartz can generate a list of recent notes based on some filtering and sorting c
- Changing the title from "Recent notes": pass in an additional parameter to `Component.RecentNotes({ title: "Recent writing" })`
- Changing the number of recent notes: pass in an additional parameter to `Component.RecentNotes({ limit: 5 })`
- Display the note's tags (defaults to true): `Component.RecentNotes({ showTags: false })`
- Show a 'see more' link: pass in an additional parameter to `Component.RecentNotes({ linkToMore: "tags/components" })`. This field should be a full slug to a page that exists.
- Customize filtering: pass in an additional parameter to `Component.RecentNotes({ filter: someFilterFunction })`. The filter function should be a function that has the signature `(f: QuartzPluginData) => boolean`.
- Customize sorting: pass in an additional parameter to `Component.RecentNotes({ sort: someSortFunction })`. By default, Quartz will sort by date and then tie break lexographically. The sort function should be a function that has the signature `(f1: QuartzPluginData, f2: QuartzPluginData) => number`. See `byDateAndAlphabetical` in `quartz/components/PageList.tsx` for an example.

View File

@ -95,6 +95,16 @@ const [age, setAge] = useState(50)
const [name, setName] = useState("Taylor")
```
### Inline Highlighting
Append {:lang} to the end of inline code to highlight it like a regular code block.
```
This is an array `[1, 2, 3]{:js}` of numbers 1 through 3.
```
This is an array `[1, 2, 3]{:js}` of numbers 1 through 3.
### Line numbers
Syntax highlighting has line numbers configured automatically. If you want to start line numbers at a specific number, use `showLineNumbers{number}`:

View File

@ -2,22 +2,11 @@
draft: true
---
## high priority backlog
- static dead link detection
- block links: https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note
- note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files
- docker support
## misc backlog
- breadcrumbs component
- static dead link detection
- cursor chat extension
- https://giscus.app/ extension
- sidenotes? https://github.com/capnfabs/paperesque
- direct match in search using double quotes
- https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI
- audio/video embed styling
- Canvas
- parse all images in page: use this for page lists if applicable?
- CV mode? with print stylesheet

View File

@ -57,18 +57,18 @@ jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for git info
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18.14
node-version: 22
- name: Install Dependencies
run: npm ci
- name: Build Quartz
run: npx quartz build
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
uses: actions/upload-pages-artifact@v3
with:
path: public
@ -81,7 +81,7 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
uses: actions/deploy-pages@v4
```
Then:
@ -182,37 +182,33 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
## GitLab Pages
In your local Quartz, create a new file `.gitlab-ci.yaml`.
In your local Quartz, create a new file `.gitlab-ci.yml`.
```yaml title=".gitlab-ci.yaml"
```yaml title=".gitlab-ci.yml"
stages:
- build
- deploy
variables:
NODE_VERSION: "18.14"
image: node:20
cache: # Cache modules in between jobs
key: $CI_COMMIT_REF_SLUG
paths:
- .npm/
build:
stage: build
rules:
- if: '$CI_COMMIT_REF_NAME == "v4"'
before_script:
- apt-get update -q && apt-get install -y nodejs npm
- npm install -g n
- n $NODE_VERSION
- hash -r
- npm ci
- npm ci --cache .npm --prefer-offline
script:
- npx quartz build
artifacts:
paths:
- public
cache:
paths:
- ~/.npm/
key: "${CI_COMMIT_REF_SLUG}-node-${CI_COMMIT_REF_NAME}"
tags:
- docker
- gitlab-org-docker
pages:
stage: deploy
@ -250,3 +246,21 @@ server {
}
}
```
### Using Caddy
Here's and example of how to do this with Caddy:
```caddy title="Caddyfile"
example.com {
root * /path/to/quartz/public
try_files {path} {path}.html {path}/ =404
file_server
encode gzip
handle_errors {
rewrite * /{err.status_code}.html
file_server
}
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

BIN
docs/images/giscus-repo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -6,7 +6,7 @@ Quartz is a fast, batteries-included static-site generator that transforms Markd
## 🪴 Get Started
Quartz requires **at least [Node](https://nodejs.org/) v18.14** and `npm` v9.3.1 to function correctly. Ensure you have this installed on your machine before continuing.
Quartz requires **at least [Node](https://nodejs.org/) v20** and `npm` v9.3.1 to function correctly. Ensure you have this installed on your machine before continuing.
Then, in your terminal of choice, enter the following commands line by line:
@ -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]] and [many more](./features) right out of the box
- [[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
- Hot-reload for both configuration and content
- Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes

View File

@ -12,15 +12,20 @@ export interface FullPageLayout {
header: QuartzComponent[] // laid out horizontally
beforeBody: QuartzComponent[] // laid out vertically
pageBody: QuartzComponent // single component
left: QuartzComponent[] // vertical on desktop, horizontal on mobile
right: QuartzComponent[] // vertical on desktop, horizontal on mobile
afterBody: QuartzComponent[] // laid out vertically
left: QuartzComponent[] // vertical on desktop and tablet, horizontal on mobile
right: QuartzComponent[] // vertical on desktop, horizontal on tablet and mobile
footer: QuartzComponent // single component
}
```
These correspond to following parts of the page:
![[quartz layout.png|800]]
| Layout | Preview |
| ------------------------------- | ----------------------------------- |
| Desktop (width > 1200px) | ![[quartz-layout-desktop.png\|800]] |
| Tablet (800px < width < 1200px) | ![[quartz-layout-tablet.png\|800]] |
| Mobile (width < 800px) | ![[quartz-layout-mobile.png\|800]] |
> [!note]
> There are two additional layout fields that are _not_ shown in the above diagram.
@ -32,6 +37,23 @@ Quartz **components**, like plugins, can take in additional properties as config
See [a list of all the components](component.md) for all available components along with their configuration options. You can also checkout the guide on [[creating components]] if you're interested in further customizing the behaviour of Quartz.
### Layout breakpoints
Quartz has different layouts depending on the width the screen viewing the website.
The breakpoints for layouts can be configured in `variables.scss`.
- `mobile`: screen width below this size will use mobile layout.
- `desktop`: screen width above this size will use desktop layout.
- Screen width between `mobile` and `desktop` width will use the tablet layout.
```scss
$breakpoints: (
mobile: 800px,
desktop: 1200px,
);
```
### Style
Most meaningful style changes like colour scheme and font can be done simply through the [[configuration#General Configuration|general configuration]] options. However, if you'd like to make more involved style changes, you can do this by writing your own styles. Quartz 4, like Quartz 3, uses [Sass](https://sass-lang.com/guide/) for styling.

View File

@ -26,7 +26,7 @@ The emitter supports the following aliases:
- `alias`
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -9,7 +9,7 @@ This plugin emits all non-Markdown static assets in your content folder (like im
Note that all static assets will then be accessible through its path on your generated site, i.e: `host.me/path/to/static.pdf`
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -8,10 +8,10 @@ This plugin emits a `CNAME` record that points your subdomain to the default dom
If you want to use a custom domain name like `quartz.example.com` for the site, then this is needed.
See [[Hosting]] for more information.
See [[hosting|Hosting]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -7,7 +7,7 @@ tags:
This plugin manages and emits the static resources required for the Quartz framework. This includes CSS stylesheets and JavaScript scripts that enhance the functionality and aesthetics of the generated site. See also the `cdnCaching` option in the `theme` section of the [[configuration]].
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -9,7 +9,7 @@ This plugin emits both RSS and an XML sitemap for your site. The [[RSS Feed]] al
This plugin emits a comprehensive index of the site's content, generating additional resources such as a sitemap, an RSS feed, and a
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,7 +7,7 @@ tags:
This plugin is a core component of the Quartz framework. It generates the HTML pages for each piece of Markdown content. It emits the full-page [[layout]], including headers, footers, and body content, among others.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -7,7 +7,7 @@ tags:
This plugin parses links and processes them to point to the right places. It is also needed for embedded links (like images). See [[Obsidian compatibility]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,11 +7,11 @@ tags:
This plugin determines the created, modified, and published dates for a document using three potential data sources: frontmatter metadata, Git history, and the filesystem. See [[authoring content#Syntax]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `"frontmatter", "git", "filesystem"]`.
- `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `["frontmatter", "git", "filesystem"]`.
> [!warning]
> If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`.

View File

@ -9,11 +9,12 @@ This plugin generates descriptions that are used as metadata for the HTML `head`
If the frontmatter contains a `description` property, it is used (see [[authoring content#Syntax]]). Otherwise, the plugin will do its best to use the first few sentences of the content to reach the target description length.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `descriptionLength`: the maximum length of the generated description. Default is 150 characters. The cut off happens after the first _sentence_ that ends after the given length.
- `replaceExternalLinks`: If `true` (default), replace external links with their domain and path in the description (e.g. `https://domain.tld/some_page/another_page?query=hello&target=world` is replaced with `domain.tld/some_page/another_page`).
## API

View File

@ -7,12 +7,12 @@ tags:
This plugin filters content based on an explicit `publish` flag in the frontmatter, allowing only content that is explicitly marked for publication to pass through. It's the opt-in version of [[RemoveDrafts]]. See [[private pages]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.
## API
- Category: Emitter
- Category: Filter
- Function name: `Plugin.ExplicitPublish()`.
- Source: [`quartz/plugins/filters/explicit.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/filters/explicit.ts).

View File

@ -9,12 +9,14 @@ This plugin generates index pages for folders, creating a listing page for each
Example: [[advanced/|Advanced]]
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
This plugin has no configuration options.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
The pages are displayed using the `defaultListPageLayout` in `quartz.layouts.ts`. For the content, the `FolderContent` component is used. If you want to modify the layout, you must edit it directly (`quartz/components/pages/FolderContent.tsx`).
This plugin accepts the following configuration options:
- `sort`: A function of type `(f1: QuartzPluginData, f2: QuartzPluginData) => number{:ts}` used to sort entries. Defaults to sorting by date and tie-breaking on lexographical order.
## API
- Category: Emitter

View File

@ -7,7 +7,7 @@ tags:
This plugin parses the frontmatter of the page using the [gray-matter](https://github.com/jonschlinkert/gray-matter) library. See [[authoring content#Syntax]], [[Obsidian compatibility]] and [[OxHugo compatibility]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -9,7 +9,7 @@ This plugin enhances Markdown processing to support GitHub Flavored Markdown (GF
In addition, this plugin adds optional features for typographic refinement (such as converting straight quotes to curly quotes, dashes to en-dashes/em-dashes, and ellipses) and automatic heading links as a symbol that appears next to the heading on hover.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,7 +7,7 @@ tags:
This plugin automatically converts single line breaks in Markdown text into hard line breaks in the HTML output. This plugin is not enabled by default as this doesn't follow the semantics of actual Markdown but you may enable it if you'd like parity with [[Obsidian compatibility|Obsidian]].
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -7,11 +7,12 @@ tags:
This plugin adds LaTeX support to Quartz. See [[features/Latex|Latex]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/) or `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html). Defaults to KaTeX.
- `customMacros`: custom macros for all LaTeX blocks. It takes the form of a key-value pair where the key is a new command name and the value is the expansion of the macro. For example: `{"\\R": "\\mathbb{R}"}`
## API

View File

@ -7,7 +7,7 @@ tags:
This plugin emits a 404 (Not Found) page for broken or non-existent URLs.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -7,7 +7,7 @@ tags:
This plugin provides support for [[Obsidian compatibility]].
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
@ -20,7 +20,7 @@ This plugin accepts the following configuration options:
- `parseArrows`: If `true` (default), transforms arrow symbols into their HTML character equivalents.
- `parseBlockReferences`: If `true` (default), handles block references, linking to specific content blocks.
- `enableInHtmlEmbed`: If `true`, allows embedding of content directly within HTML. Defaults to `false`.
- `enableYouTubeEmbed`: If `true` (default), enables the embedding of YouTube videos using external image Markdown syntax.
- `enableYouTubeEmbed`: If `true` (default), enables the embedding of YouTube videos and playlists using external image Markdown syntax.
- `enableVideoEmbed`: If `true` (default), enables the embedding of video files.
- `enableCheckbox`: If `true`, adds support for interactive checkboxes in content. Defaults to `false`.

View File

@ -7,7 +7,7 @@ tags:
This plugin provides support for [ox-hugo](https://github.com/kaushalmodi/ox-hugo) compatibility. See [[OxHugo compatibility]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,7 +7,7 @@ tags:
This plugin filters out content from your vault, so that only finalized content is made available. This prevents [[private pages]] from being published. By default, it filters out all pages with `draft: true` in the frontmatter and leaves all other pages intact.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -0,0 +1,26 @@
---
title: RoamFlavoredMarkdown
tags:
- plugin/transformer
---
This plugin provides support for [Roam Research](https://roamresearch.com) compatibility. See [[Roam Research Compatibility]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `orComponent`: If `true` (default), converts Roam `{{ or:ONE|TWO|THREE }}` shortcodes into HTML Dropdown options.
- `TODOComponent`: If `true` (default), converts Roam `{{[[TODO]]}}` shortcodes into HTML check boxes.
- `DONEComponent`: If `true` (default), converts Roam `{{[[DONE]]}}` shortcodes into checked HTML check boxes.
- `videoComponent`: If `true` (default), converts Roam `{{[[video]]:URL}}` shortcodes into embeded HTML video.
- `audioComponent`: If `true` (default), converts Roam `{{[[audio]]:URL}}` shortcodes into embeded HTML audio.
- `pdfComponent`: If `true` (default), converts Roam `{{[[pdf]]:URL}}` shortcodes into embeded HTML PDF viewer.
- `blockquoteComponent`: If `true` (default), converts Roam `{{[[>]]}}` shortcodes into Quartz blockquotes.
## API
- Category: Transformer
- Function name: `Plugin.RoamFlavoredMarkdown()`.
- Source: [`quartz/plugins/transformers/roam.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/roam.ts).

View File

@ -10,7 +10,7 @@ This plugin emits all static resources needed by Quartz. This is used, for examp
> This is different from [[Assets]]. The resources from the [[Static]] plugin are located under `quartz/static`, whereas [[Assets]] renders all static resources under `content` and is used for images, videos, audio, etc. that are directly referenced by your markdown content.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.

View File

@ -7,7 +7,7 @@ tags:
This plugin is used to add syntax highlighting to code blocks in Quartz. See [[syntax highlighting]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,7 +7,7 @@ tags:
This plugin generates a table of contents (TOC) for Markdown documents. See [[table of contents]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:

View File

@ -7,12 +7,14 @@ tags:
This plugin emits dedicated pages for each tag used in the content. See [[folder and tag listings]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
This plugin has no configuration options.
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
The pages are displayed using the `defaultListPageLayout` in `quartz.layouts.ts`. For the content, the `TagContent` component is used. If you want to modify the layout, you must edit it directly (`quartz/components/pages/TagContent.tsx`).
This plugin accepts the following configuration options:
- `sort`: A function of type `(f1: QuartzPluginData, f2: QuartzPluginData) => number{:ts}` used to sort entries. Defaults to sorting by date and tie-breaking on lexographical order.
## API
- Category: Emitter

View File

@ -7,24 +7,26 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/)
- [Socratica Toolbox](https://toolbox.socratica.info/)
- [oldwinter の数字花园](https://garden.oldwinter.top/)
- [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
- [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Quantum Garden](https://quantumgardener.blog/)
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/)
- [Matt Dunn's Second Brain](https://mattdunn.info/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
- [Vince Imbat's Talahardin](https://vinceimbat.com/)
- [Pelayo Arbues' Notes](https://pelayoarbues.com/)
- [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
- [oldwinter の数字花园](https://garden.oldwinter.top/)
- [Eilleen's Everything Notebook](https://quartz.eilleeenz.com/)
- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/)
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
- [🌊 Collapsed Wave](https://collapsedwave.com/)
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [🪴Aster's notebook](https://notes.asterhu.com)
- [Gatekeeper Wiki](https://www.gatekeeper.wiki)
- [Ellie's Notes](https://ellie.wtf)
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
- [Eledah's Crystalline](https://blog.eledah.ir/)
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!

4
globals.d.ts vendored
View File

@ -4,6 +4,10 @@ export declare global {
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
removeEventListener<K extends keyof CustomEventMap>(
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
}
interface Window {

2564
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.2.3",
"version": "4.4.0",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
@ -12,6 +12,7 @@
"url": "https://github.com/jackyzha0/quartz.git"
},
"scripts": {
"quartz": "./quartz/bootstrap-cli.mjs",
"docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
@ -20,7 +21,7 @@
},
"engines": {
"npm": ">=9.3.1",
"node": ">=18.14"
"node": "20 || >=22"
},
"keywords": [
"site generator",
@ -35,38 +36,40 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.3",
"@jackyzha0/quartz": "file:",
"@napi-rs/simple-git": "0.1.16",
"async-mutex": "^0.4.1",
"@floating-ui/dom": "^1.6.11",
"@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0",
"async-mutex": "^0.5.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"chokidar": "^4.0.1",
"cli-spinner": "^0.2.10",
"d3": "^7.8.5",
"esbuild-sass-plugin": "^2.16.1",
"d3": "^7.9.0",
"esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.1",
"globby": "^14.0.2",
"gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.0",
"hast-util-to-html": "^9.0.3",
"hast-util-to-jsx-runtime": "^2.3.0",
"hast-util-to-string": "^3.0.0",
"hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.23.0",
"lightningcss": "^1.27.0",
"mdast-util-find-and-replace": "^3.0.1",
"mdast-util-to-hast": "^13.1.0",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"preact": "^10.19.6",
"preact-render-to-string": "^6.3.1",
"pixi.js": "^8.4.1",
"preact": "^10.24.2",
"preact-render-to-string": "^6.5.11",
"pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0",
"rehype-citation": "^2.1.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.13.0",
"rehype-pretty-code": "^0.14.0",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@ -75,20 +78,20 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"remark-smartypants": "^2.0.0",
"rfdc": "^1.3.1",
"rimraf": "^5.0.5",
"remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"serve-handler": "^6.1.5",
"shiki": "^1.1.6",
"shiki": "^1.22.0",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",
"unified": "^11.0.4",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.1",
"workerpool": "^9.1.0",
"ws": "^8.15.1",
"vfile": "^6.0.3",
"workerpool": "^9.1.3",
"ws": "^8.18.0",
"yargs": "^17.7.2"
},
"devDependencies": {
@ -96,14 +99,14 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.19",
"@types/node": "^22.7.5",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"esbuild": "^0.19.9",
"prettier": "^3.2.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
"@types/ws": "^8.5.12",
"@types/yargs": "^17.0.33",
"esbuild": "^0.24.0",
"prettier": "^3.3.3",
"tsx": "^4.19.1",
"typescript": "^5.6.3"
}
}

View File

@ -9,6 +9,7 @@ import * as Plugin from "./quartz/plugins"
const config: QuartzConfig = {
configuration: {
pageTitle: "🎣 JudeW's Knowledge Brain",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
analytics: {
@ -19,6 +20,7 @@ const config: QuartzConfig = {
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
@ -35,6 +37,7 @@ const config: QuartzConfig = {
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
@ -45,6 +48,7 @@ const config: QuartzConfig = {
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#b3aa0288",
},
},
},
@ -55,7 +59,6 @@ const config: QuartzConfig = {
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "filesystem"],
}),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.SyntaxHighlighting({
theme: {
light: "github-light",
@ -68,11 +71,12 @@ const config: QuartzConfig = {
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
],
filters: [Plugin.RemoveDrafts()],
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources({ fontOrigin: "googleFonts" }),
Plugin.ComponentResources(),
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),

View File

@ -5,6 +5,7 @@ import * as Component from "./quartz/components"
export const sharedPageComponents: SharedLayout = {
head: Component.Head(),
header: [],
afterBody: [],
footer: Component.Footer({
links: {
GitHub: "https://github.com/PinkR1ver",

View File

@ -38,8 +38,13 @@ type BuildData = {
type FileEvent = "add" | "change" | "delete"
function newBuildId() {
return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
buildId: newBuildId(),
argv,
cfg,
allSlugs: [],
@ -157,10 +162,13 @@ async function partialRebuildFromEntrypoint(
return
}
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const buildId = newBuildId()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire()
if (buildData.lastBuildMs > buildStart) {
// if there's another build after us, release and let them do it
if (ctx.buildId !== buildId) {
release()
return
}
@ -309,6 +317,8 @@ async function partialRebuildFromEntrypoint(
}
await rimraf([...destinationsToDelete])
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
toRemove.clear()
release()
clientRefresh()
@ -349,26 +359,22 @@ async function rebuildFromEntrypoint(
toRemove.add(filePath)
}
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const buildId = newBuildId()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire()
// there's another build after us, release and let them do it
if (buildData.lastBuildMs > buildStart) {
if (ctx.buildId !== buildId) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
@ -382,6 +388,13 @@ async function rebuildFromEntrypoint(
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// re-update slugs
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(path.join(argv.output, ".*"), { glob: true })
@ -394,10 +407,10 @@ async function rebuildFromEntrypoint(
}
}
release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
release()
}
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {

View File

@ -19,9 +19,33 @@ export type Analytics =
websiteId: string
host?: string
}
| {
provider: "goatcounter"
websiteId: string
host?: string
scriptSrc?: string
}
| {
provider: "posthog"
apiKey: string
host?: string
}
| {
provider: "tinylytics"
siteId: string
}
| {
provider: "cabin"
host?: string
}
| {
provider: "clarity"
projectId?: string
}
export interface GlobalConfiguration {
pageTitle: string
pageTitleSuffix?: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */
@ -58,10 +82,11 @@ export interface FullPageLayout {
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
afterBody: QuartzComponent[]
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
}
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer">
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer" | "afterBody">

View File

@ -0,0 +1,44 @@
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
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, cfg }: QuartzComponentProps) => {
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"}
></div>
)
}
Comments.afterDOMLoaded = script
return Comments
}) satisfies QuartzComponentConstructor<Options>

View File

@ -3,16 +3,20 @@ 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>) => {
@ -23,7 +27,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const text = fileData.text
if (text) {
const segments: string[] = []
const segments: (string | JSX.Element)[] = []
if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
@ -38,17 +42,19 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
segments.push(displayedTime)
}
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
const segmentsElements = segments.map((segment) => <span>{segment}</span>)
return (
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
{segmentsElements}
</p>
)
} else {
return null
}
}
ContentMetadata.css = `
.content-meta {
margin-top: 0;
color: var(--gray);
}
`
ContentMetadata.css = style
return ContentMetadata
}) satisfies QuartzComponentConstructor

View File

@ -9,41 +9,38 @@ import { classNames } from "../util/lang"
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
return (
<div class={classNames(displayClass, "darkmode")}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
<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"
>
<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>
</label>
<label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}>
<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"
>
<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>
</label>
</div>
<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>
)
}

View File

@ -44,12 +44,9 @@ export default ((userOpts?: Partial<Options>) => {
// memoized
let fileTree: FileNode
let jsonTree: string
let lastBuildId: string = ""
function constructFileTree(allFiles: QuartzPluginData[]) {
if (fileTree) {
return
}
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
@ -76,12 +73,17 @@ export default ((userOpts?: Partial<Options>) => {
}
const Explorer: QuartzComponent = ({
ctx,
cfg,
allFiles,
displayClass,
fileData,
}: QuartzComponentProps) => {
constructFileTree(allFiles)
if (ctx.buildId !== lastBuildId) {
lastBuildId = ctx.buildId
constructFileTree(allFiles)
}
return (
<div class={classNames(displayClass, "explorer")}>
<button
@ -91,8 +93,10 @@ export default ((userOpts?: Partial<Options>) => {
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
aria-controls="explorer-content"
aria-expanded={opts.folderDefaultState === "open"}
>
<h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"

View File

@ -168,10 +168,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath
let folderPath = ""
if (node.name !== "") {
folderPath = joinSegments(fullPath ?? "", node.name)
}
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
return (
<>
@ -205,11 +203,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
{/* 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={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
data-for={node.name}
class="folder-title"
>
<a href={href} data-for={node.name} class="folder-title">
{node.displayName}
</a>
) : (

View File

@ -13,7 +13,6 @@ export default ((opts?: Options) => {
const links = opts?.links ?? []
return (
<footer class={`${displayClass ?? ""}`}>
<hr />
<p>
{i18n(cfg.locale).components.footer.createdWith}{" "}
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}

View File

@ -17,6 +17,7 @@ export interface D3Config {
opacityScale: number
removeTags: string[]
showTags: boolean
focusOnHover?: boolean
}
interface GraphOptions {
@ -37,6 +38,7 @@ const defaultOptions: GraphOptions = {
opacityScale: 1,
showTags: true,
removeTags: [],
focusOnHover: false,
},
globalGraph: {
drag: true,
@ -50,6 +52,7 @@ const defaultOptions: GraphOptions = {
opacityScale: 1,
showTags: true,
removeTags: [],
focusOnHover: true,
},
}
@ -62,31 +65,32 @@ export default ((opts?: GraphOptions) => {
<h3>{i18n(cfg.locale).components.graph.title}</h3>
<div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<svg
version="1.1"
id="global-graph-icon"
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 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>

View File

@ -1,11 +1,14 @@
import { i18n } from "../i18n"
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources"
import { googleFontHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default (() => {
const Head: QuartzComponent = ({ cfg, fileData, externalResources }: QuartzComponentProps) => {
const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
const { css, js } = externalResources
@ -21,10 +24,11 @@ export default (() => {
<head>
<title>{title}</title>
<meta charSet="utf-8" />
{cfg.theme.cdnCaching && (
{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)} />
</>
)}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@ -4,9 +4,9 @@ import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types"
import { GlobalConfiguration } from "../cfg"
export function byDateAndAlphabetical(
cfg: GlobalConfiguration,
): (f1: QuartzPluginData, f2: QuartzPluginData) => number {
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
return (f1, f2) => {
if (f1.dates && f2.dates) {
// sort descending
@ -27,10 +27,12 @@ export function byDateAndAlphabetical(
type Props = {
limit?: number
sort?: SortFn
} & QuartzComponentProps
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit }: Props) => {
let list = allFiles.sort(byDateAndAlphabetical(cfg))
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)
}
@ -44,11 +46,13 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit }: Pr
return (
<li class="section-li">
<div class="section">
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p>
)}
<div>
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p>
)}
</div>
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
@ -63,7 +67,7 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit }: Pr
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
>
#{tag}
{tag}
</a>
</li>
))}

View File

@ -7,14 +7,15 @@ const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzCompo
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
const baseDir = pathToRoot(fileData.slug!)
return (
<h1 class={classNames(displayClass, "page-title")}>
<h2 class={classNames(displayClass, "page-title")}>
<a href={baseDir}>{title}</a>
</h1>
</h2>
)
}
PageTitle.css = `
.page-title {
font-size: 1.75rem;
margin: 0;
}
`

View File

@ -12,6 +12,7 @@ interface Options {
title?: string
limit: number
linkToMore: SimpleSlug | false
showTags: boolean
filter: (f: QuartzPluginData) => boolean
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
}
@ -19,6 +20,7 @@ interface Options {
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
limit: 3,
linkToMore: false,
showTags: true,
filter: () => true,
sort: byDateAndAlphabetical(cfg),
})
@ -56,18 +58,20 @@ export default ((userOpts?: Partial<Options>) => {
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p>
)}
<ul class="tags">
{tags.map((tag) => (
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
>
#{tag}
</a>
</li>
))}
</ul>
{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>
)

View File

@ -19,24 +19,16 @@ export default ((userOpts?: Partial<SearchOptions>) => {
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
return (
<div class={classNames(displayClass, "search")}>
<div id="search-icon">
<button class="search-button" id="search-button">
<p>{i18n(cfg.locale).components.search.title}</p>
<div></div>
<svg
tabIndex={0}
aria-labelledby="title desc"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 19.9 19.7"
>
<title id="title">Search</title>
<desc id="desc">Search</desc>
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
<title>Search</title>
<g class="search-path" fill="none">
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
<circle cx="8" cy="8" r="7" />
</g>
</svg>
</div>
</button>
<div id="search-container">
<div id="search-space">
<input

View File

@ -26,7 +26,13 @@ const TableOfContents: QuartzComponent = ({
return (
<div class={classNames(displayClass, "toc")}>
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<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"
@ -43,7 +49,7 @@ const TableOfContents: QuartzComponent = ({
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="toc-content">
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
<ul class="overflow">
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>

View File

@ -9,12 +9,11 @@ const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentPro
return (
<ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => {
const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugTag(tag)}`
return (
<li>
<a href={linkDest} class="internal tag-link">
{display}
{tag}
</a>
</li>
)

View File

@ -19,6 +19,7 @@ import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
export {
ArticleTitle,
@ -42,4 +43,5 @@ export {
RecentNotes,
NotFound,
Breadcrumbs,
Comments,
}

View File

@ -2,10 +2,15 @@ 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>
)
}

View File

@ -2,7 +2,7 @@ import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } fro
import path from "path"
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { PageList, SortFn } from "../PageList"
import { stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx"
@ -13,6 +13,7 @@ interface FolderContentOptions {
* Whether to display number of folders
*/
showFolderCount: boolean
sort?: SortFn
}
const defaultOptions: FolderContentOptions = {
@ -37,6 +38,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const classes = ["popover-hint", ...cssClasses].join(" ")
const listProps = {
...props,
sort: options.sort,
allFiles: allPagesInFolder,
}
@ -47,9 +49,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
return (
<div class={classes}>
<article>
<p>{content}</p>
</article>
<article>{content}</article>
<div class="page-listing">
{options.showFolderCount && (
<p>

View File

@ -1,107 +1,127 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
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"
const numPages = 10
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 = ["popover-hint", ...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={classes}>
<article>
<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}`)[0]
const content = contentPage?.description
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 > numPages && (
<>
{" "}
<span>
{i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
</span>
</>
)}
</p>
<PageList limit={numPages} {...listProps} />
</div>
</div>
)
})}
</div>
</div>
)
} else {
const pages = allPagesWithTag(tag)
const listProps = {
...props,
allFiles: pages,
}
return (
<div class={classes}>
<article>{content}</article>
<div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
interface TagContentOptions {
sort?: SortFn
numPages: number
}
TagContent.css = style + PageList.css
export default (() => TagContent) satisfies QuartzComponentConstructor
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 = ["popover-hint", ...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={classes}>
<article>
<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={opts?.sort} />
</div>
</div>
)
})}
</div>
</div>
)
} else {
const pages = allPagesWithTag(tag)
const listProps = {
...props,
allFiles: pages,
}
return (
<div class={classes}>
<article>{content}</article>
<div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
}
TagContent.css = style + PageList.css
return TagContent
}) satisfies QuartzComponentConstructor

View File

@ -14,11 +14,13 @@ interface RenderComponents {
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,
staticResources: StaticResources,
@ -105,18 +107,24 @@ export function renderPage(
// header transclude
blockRef = blockRef.slice(1)
let startIdx = undefined
let startDepth = undefined
let endIdx = undefined
for (const [i, el] of page.htmlAst.children.entries()) {
if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
if (endIdx) {
break
}
// skip non-headers
if (!(el.type === "element" && el.tagName.match(headerRegex))) continue
const depth = Number(el.tagName.substring(1))
if (startIdx !== undefined) {
endIdx = i
} else if (el.properties?.id === blockRef) {
// 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
}
}
@ -180,6 +188,7 @@ export function renderPage(
header,
beforeBody,
pageBody: Content,
afterBody,
left,
right,
footer: Footer,
@ -203,7 +212,7 @@ export function renderPage(
</div>
)
const lang = componentData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const doc = (
<html lang={lang}>
<Head {...componentData} />
@ -225,10 +234,16 @@ export function renderPage(
</div>
</div>
<Content {...componentData} />
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
{RightComponent}
<Footer {...componentData} />
</Body>
<Footer {...componentData} />
</div>
</body>
{pageResources.js

View File

@ -0,0 +1,67 @@
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: theme,
},
},
},
"https://giscus.app",
)
}
type GiscusElement = Omit<HTMLElement, "dataset"> & {
dataset: DOMStringMap & {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: 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", theme)
}
giscusContainer.appendChild(giscusScript)
document.addEventListener("themechange", changeTheme)
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
})

View File

@ -11,7 +11,8 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => {
document.addEventListener("nav", () => {
const switchTheme = (e: Event) => {
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
const newTheme =
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
emitThemeChangeEvent(newTheme)
@ -21,17 +22,13 @@ document.addEventListener("nav", () => {
const newTheme = e.matches ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
emitThemeChangeEvent(newTheme)
}
// Darkmode toggle
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
if (currentTheme === "dark") {
toggleSwitch.checked = true
}
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
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)")

View File

@ -17,11 +17,14 @@ const observer = new IntersectionObserver((entries) => {
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")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
function toggleFolder(evt: MouseEvent) {

View File

@ -1,19 +1,56 @@
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3"
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[]
} & d3.SimulationNodeDatum
} & SimulationNodeDatum
type LinkData = {
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) ?? "[]"))
@ -25,6 +62,11 @@ function addToVisited(slug: SimpleSlug) {
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()
@ -44,7 +86,8 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
opacityScale,
removeTags,
showTags,
} = JSON.parse(graph.dataset["cfg"]!)
focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
@ -52,10 +95,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
v,
]),
)
const links: LinkData[] = []
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 ?? []
@ -99,232 +143,459 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
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: [...neighbourhood].map((url) => {
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
return {
id: url,
text: text,
tags: data.get(url)?.tags ?? [],
}
}),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
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)!,
})),
}
const simulation: d3.Simulation<NodeData, LinkData> = d3
.forceSimulation(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
.force(
"link",
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force("center", d3.forceCenter().strength(centerForce))
// 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 height = Math.max(graph.offsetHeight, 250)
const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
const svg = d3
.select<HTMLElement, NodeData>("#" + container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
// draw links between nodes
const link = svg
.append("g")
.selectAll("line")
.data(graphData.links)
.join("line")
.attr("class", "link")
.attr("stroke", "var(--lightgray)")
.attr("stroke-width", 1)
// svg groups
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
// 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 "var(--secondary)"
return computedStyleMap["--secondary"]
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)"
return computedStyleMap["--tertiary"]
} else {
return "var(--gray)"
return computedStyleMap["--gray"]
}
}
const drag = (simulation: d3.Simulation<NodeData, LinkData>) => {
function dragstarted(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(1).restart()
d.fx = d.x
d.fy = d.y
}
function dragged(event: any, d: NodeData) {
d.fx = event.x
d.fy = event.y
}
function dragended(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
const noop = () => {}
return d3
.drag<Element, NodeData>()
.on("start", enableDrag ? dragstarted : noop)
.on("drag", enableDrag ? dragged : noop)
.on("end", enableDrag ? dragended : noop)
}
function nodeRadius(d: NodeData) {
const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length
const numLinks = graphData.links.filter(
(l) => l.source.id === d.id || l.target.id === d.id,
).length
return 2 + Math.sqrt(numLinks)
}
// draw individual nodes
const node = graphNode
.append("circle")
.attr("class", "node")
.attr("id", (d) => d.id)
.attr("r", nodeRadius)
.attr("fill", color)
.style("cursor", "pointer")
.on("click", (_, d) => {
const targ = resolveRelative(fullSlug, d.id)
window.spaNavigate(new URL(targ, window.location.toString()))
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())
},
})
.on("mouseover", function (_, d) {
const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id))
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
}
// highlight neighbour nodes
neighbourNodes.transition().duration(200).attr("fill", color)
function renderLabels() {
tweens.get("label")?.stop()
const tweenGroup = new TweenGroup()
// highlight links
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
const defaultScale = 1 / scale
const activeScale = defaultScale * 1.1
for (const n of nodeRenderData) {
const nodeId = n.simulationData.id
const bigFont = fontSize * 1.5
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,
),
)
}
}
// show text for self
const parent = this.parentNode as HTMLElement
d3.select<HTMLElement, NodeData>(parent)
.raise()
.select("text")
.transition()
.duration(200)
.attr("opacityOld", d3.select(parent).select("text").style("opacity"))
.style("opacity", 1)
.style("font-size", bigFont + "em")
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("label", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
.on("mouseleave", function (_, d) {
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
}
linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)")
function renderNodes() {
tweens.get("hover")?.stop()
const parent = this.parentNode as HTMLElement
d3.select<HTMLElement, NodeData>(parent)
.select("text")
.transition()
.duration(200)
.style("opacity", d3.select(parent).select("text").attr("opacityOld"))
.style("font-size", fontSize + "em")
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())
},
})
// @ts-ignore
.call(drag(simulation))
}
// draw labels
const labels = graphNode
.append("text")
.attr("dx", 0)
.attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle")
.text((d) => d.text)
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style("font-size", fontSize + "em")
.raise()
// @ts-ignore
.call(drag(simulation))
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()))
})
}
}
// set panning
if (enableZoom) {
svg.call(
d3
.zoom<SVGSVGElement, NodeData>()
select<HTMLCanvasElement, NodeData>(app.canvas).call(
zoom<HTMLCanvasElement, NodeData>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => {
link.attr("transform", transform)
node.attr("transform", 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
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity)
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
}
}
}),
)
}
// progress the simulation
simulation.on("tick", () => {
link
.attr("x1", (d: any) => d.source.x)
.attr("y1", (d: any) => d.source.y)
.attr("x2", (d: any) => d.target.x)
.attr("y2", (d: any) => d.target.y)
node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
})
}
function renderGlobalGraph() {
const slug = getFullSlug(window)
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
container?.classList.add("active")
if (sidebar) {
sidebar.style.zIndex = "1"
}
renderGraph("global-graph-container", slug)
function hideGlobalGraph() {
container?.classList.remove("active")
const graph = document.getElementById("global-graph-container")
if (sidebar) {
sidebar.style.zIndex = "unset"
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)
}
}
if (!graph) return
removeAllChildren(graph)
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)
}
registerEscapeHandler(container, hideGlobalGraph)
const graphAnimationFrameHandle = requestAnimationFrame(animate)
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url
addToVisited(slug)
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 = "unset"
}
}
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))
})

View File

@ -3,7 +3,7 @@ import { normalizeRelativeURLs } from "../../util/path"
const p = new DOMParser()
async function mouseEnterHandler(
this: HTMLLinkElement,
this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number },
) {
const link = this
@ -33,7 +33,7 @@ async function mouseEnterHandler(
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href)
const hash = targetUrl.hash
const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = ""
targetUrl.search = ""
@ -100,7 +100,7 @@ async function mouseEnterHandler(
}
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler)
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))

View File

@ -21,6 +21,7 @@ let index = new FlexSearch.Document<Item>({
encode: encoder,
document: {
id: "id",
tag: "tags",
index: [
{
field: "title",
@ -147,7 +148,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const data = await fetchData
const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
const searchIcon = document.getElementById("search-icon")
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[]
@ -190,6 +191,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
}
searchType = "basic" // reset search type after closing
searchButton?.focus()
}
function showSearch(searchTypeNew: SearchType) {
@ -405,11 +408,33 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
if (searchType === "tags") {
searchResults = await index.searchAsync({
query: currentSearchTerm.substring(1),
limit: numSearchResults,
index: ["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,
@ -435,8 +460,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.addEventListener("click", () => showSearch("basic"))
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
searchButton?.addEventListener("click", () => showSearch("basic"))
window.addCleanup(() => searchButton?.removeEventListener("click", () => showSearch("basic")))
searchBar?.addEventListener("input", onType)
window.addCleanup(() => searchBar?.removeEventListener("input", onType))

View File

@ -16,10 +16,13 @@ const observer = new IntersectionObserver((entries) => {
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")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
function setupToc() {
@ -28,7 +31,6 @@ function setupToc() {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.addEventListener("click", toggleToc)
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
}

View File

@ -3,6 +3,7 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
if (e.target !== this) return
e.preventDefault()
e.stopPropagation()
cb()
}

View File

@ -1,5 +1,19 @@
@use "../../styles/variables.scss" as *;
.backlinks {
position: relative;
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;
@ -17,4 +31,14 @@
}
}
}
& > .overflow {
&:after {
display: none;
}
height: auto;
@media all and ($desktop) {
height: 250px;
}
}
}

View File

@ -0,0 +1,14 @@
.content-meta {
margin-top: 0;
color: var(--gray);
&[show-comma="true"] {
> span:not(:last-child) {
margin-right: 8px;
&::after {
content: ",";
}
}
}
}

View File

@ -1,17 +1,15 @@
.darkmode {
cursor: pointer;
padding: 0;
position: relative;
background: none;
border: none;
width: 20px;
height: 20px;
margin: 0 10px;
& > .toggle {
display: none;
box-sizing: border-box;
}
text-align: inherit;
& svg {
cursor: pointer;
opacity: 0;
position: absolute;
width: 20px;
height: 20px;
@ -29,20 +27,20 @@
color-scheme: light;
}
:root[saved-theme="dark"] .toggle ~ label {
:root[saved-theme="dark"] .darkmode {
& > #dayIcon {
opacity: 0;
display: none;
}
& > #nightIcon {
opacity: 1;
display: inline;
}
}
:root .toggle ~ label {
:root .darkmode {
& > #dayIcon {
opacity: 1;
display: inline;
}
& > #nightIcon {
opacity: 0;
display: none;
}
}

View File

@ -1,7 +1,29 @@
@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 {
all: unset;
background-color: transparent;
border: none;
text-align: left;
@ -11,7 +33,7 @@ button#explorer {
display: flex;
align-items: center;
& h1 {
& h2 {
font-size: 1rem;
display: inline-block;
margin: 0;
@ -45,12 +67,20 @@ button#explorer {
#explorer-content {
list-style: none;
overflow: hidden;
max-height: none;
transition: max-height 0.35s ease;
overflow-y: auto;
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
margin-top: 0.5rem;
visibility: visible;
&.collapsed > .overflow::after {
opacity: 0;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
& ul {
@ -67,6 +97,9 @@ button#explorer {
pointer-events: all;
}
}
> #explorer-ul {
max-height: none;
}
}
svg {

View File

@ -16,10 +16,13 @@
overflow: hidden;
& > #global-graph-icon {
cursor: pointer;
background: none;
border: none;
color: var(--dark);
opacity: 0.5;
width: 18px;
height: 18px;
width: 24px;
height: 24px;
position: absolute;
padding: 0.2rem;
margin: 0.3rem;
@ -59,10 +62,10 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 60vh;
width: 50vw;
height: 80vh;
width: 80vw;
@media all and (max-width: $fullPageWidth) {
@media all and ($desktop) {
width: 90%;
}
}

View File

@ -11,9 +11,9 @@ li.section-li {
& > .section {
display: grid;
grid-template-columns: 6em 3fr 1fr;
grid-template-columns: fit-content(8em) 3fr 1fr;
@media all and (max-width: $mobileBreakpoint) {
@media all and ($mobile) {
& > .tags {
display: none;
}
@ -23,9 +23,8 @@ li.section-li {
background-color: transparent;
}
& > .meta {
margin: 0;
flex-basis: 6em;
& .meta {
margin: 0 1em 0 0;
opacity: 0.6;
}
}
@ -33,7 +32,8 @@ li.section-li {
// modifications in popover context
.popover .section {
grid-template-columns: 6em 1fr !important;
grid-template-columns: fit-content(8em) 1fr !important;
& > .tags {
display: none;
}

View File

@ -70,7 +70,7 @@
opacity 0.3s ease,
visibility 0.3s ease;
@media all and (max-width: $mobileBreakpoint) {
@media all and ($mobile) {
display: none !important;
}
}

View File

@ -3,20 +3,25 @@
.search {
min-width: fit-content;
max-width: 14rem;
flex-grow: 0.3;
@media all and ($mobile) {
flex-grow: 0.3;
}
& > #search-icon {
& > .search-button {
background-color: var(--lightgray);
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;
& > div {
flex-grow: 1;
}
width: 100%;
justify-content: space-between;
& > p {
display: inline;
@ -59,7 +64,7 @@
margin-left: auto;
margin-right: auto;
@media all and (max-width: $fullPageWidth) {
@media all and ($desktop) {
width: 90%;
}
@ -101,7 +106,7 @@
flex: 0 0 min(30%, 450px);
}
@media all and (min-width: $tabletBreakpoint) {
@media all and not ($tablet) {
&[data-preview] {
& .result-card > p.preview {
display: none;
@ -127,7 +132,7 @@
border-radius: 5px;
}
@media all and (max-width: $tabletBreakpoint) {
@media all and ($tablet) {
& > #preview-container {
display: none !important;
}

View File

@ -1,3 +1,12 @@
.toc {
display: flex;
flex-direction: column;
&.desktop-only {
display: flex;
max-height: 40%;
}
}
button#toc {
background-color: transparent;
border: none;
@ -28,9 +37,21 @@ button#toc {
#toc-content {
list-style: none;
overflow: hidden;
max-height: none;
transition: max-height 0.5s ease;
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;
@ -51,6 +72,10 @@ button#toc {
}
}
}
> ul.overflow {
max-height: none;
width: 100%;
}
@for $i from 0 through 6 {
& .depth-#{$i} {

View File

@ -1,20 +1,29 @@
import { Translation, CalloutTranslation } from "./locales/definition"
import en from "./locales/en-US"
import enUs from "./locales/en-US"
import enGb from "./locales/en-GB"
import fr from "./locales/fr-FR"
import it from "./locales/it-IT"
import ja from "./locales/ja-JP"
import de from "./locales/de-DE"
import nl from "./locales/nl-NL"
import ro from "./locales/ro-RO"
import ca from "./locales/ca-ES"
import es from "./locales/es-ES"
import ar from "./locales/ar-SA"
import uk from "./locales/uk-UA"
import ru from "./locales/ru-RU"
import ko from "./locales/ko-KR"
import zh from "./locales/zh-CN"
import vi from "./locales/vi-VN"
import pt from "./locales/pt-BR"
import hu from "./locales/hu-HU"
import fa from "./locales/fa-IR"
import pl from "./locales/pl-PL"
import cs from "./locales/cs-CZ"
export const TRANSLATIONS = {
"en-US": en,
"en-US": enUs,
"en-GB": enGb,
"fr-FR": fr,
"it-IT": it,
"ja-JP": ja,
@ -23,6 +32,7 @@ export const TRANSLATIONS = {
"nl-BE": nl,
"ro-RO": ro,
"ro-MD": ro,
"ca-ES": ca,
"es-ES": es,
"ar-SA": ar,
"ar-AE": ar,
@ -48,6 +58,12 @@ export const TRANSLATIONS = {
"ru-RU": ru,
"ko-KR": ko,
"zh-CN": zh,
"vi-VN": vi,
"pt-BR": pt,
"hu-HU": hu,
"fa-IR": fa,
"pl-PL": pl,
"cs-CZ": cs,
} as const
export const defaultTranslation = "en-US"

View File

@ -70,6 +70,7 @@ export default {
error: {
title: "غير موجود",
notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
home: "العوده للصفحة الرئيسية",
},
folderContent: {
folder: "مجلد",

View File

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sense títol",
description: "Sense descripció",
},
components: {
callout: {
note: "Nota",
abstract: "Resum",
info: "Informació",
todo: "Per fer",
tip: "Consell",
success: "Èxit",
question: "Pregunta",
warning: "Advertència",
failure: "Fall",
danger: "Perill",
bug: "Error",
example: "Exemple",
quote: "Cita",
},
backlinks: {
title: "Retroenllaç",
noBacklinksFound: "No s'han trobat retroenllaços",
},
themeToggle: {
lightMode: "Mode clar",
darkMode: "Mode fosc",
},
explorer: {
title: "Explorador",
},
footer: {
createdWith: "Creat amb",
},
graph: {
title: "Vista Gràfica",
},
recentNotes: {
title: "Notes Recents",
seeRemainingMore: ({ remaining }) => `Vegi ${remaining} més →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transcluit de ${targetSlug}`,
linkToOriginal: "Enllaç a l'original",
},
search: {
title: "Cercar",
searchBarPlaceholder: "Cerca alguna cosa",
},
tableOfContents: {
title: "Taula de Continguts",
},
contentMeta: {
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`,
},
},
pages: {
rss: {
recentNotes: "Notes recents",
lastFewNotes: ({ count }) => `Últimes ${count} notes`,
},
error: {
title: "No s'ha trobat.",
notFound: "Aquesta pàgina és privada o no existeix.",
home: "Torna a la pàgina principal",
},
folderContent: {
folder: "Carpeta",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 article en aquesta carpeta." : `${count} articles en esta carpeta.`,
},
tagContent: {
tag: "Etiqueta",
tagIndex: "índex d'Etiquetes",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 article amb aquesta etiqueta." : `${count} article amb aquesta etiqueta.`,
showingFirst: ({ count }) => `Mostrant les primeres ${count} etiquetes.`,
totalTags: ({ count }) => `S'han trobat ${count} etiquetes en total.`,
},
},
} as const satisfies Translation

Some files were not shown because too many files have changed in this diff Show More