Merge branch 'v4' of github.com:jackyzha0/quartz into jackyzha0-v4

This commit is contained in:
oldwinter 2024-11-22 15:20:20 +08:00
commit 44731aa5ec
120 changed files with 6907 additions and 1925 deletions

View File

@ -1,11 +1,20 @@
# 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 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
groups:
production-dependencies:
applies-to: "version-updates"
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
ci-dependencies:
applies-to: "version-updates"
patterns:
- "*"

View File

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

View File

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

View File

@ -27,7 +27,7 @@ The following sections will go into detail for what methods can be implemented f
- `cfg`: The full Quartz [[configuration]] - `cfg`: The full Quartz [[configuration]]
- `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is) - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is)
- `StaticResources` is defined in `quartz/resources.tsx`. It consists of - `StaticResources` is defined in `quartz/resources.tsx`. It consists of
- `css`: a list of URLs for stylesheets that should be loaded - `css`: a list of CSS style definitions that should be loaded. A CSS style is described with the `CSSResource` type which is also defined in `quartz/resources.tsx`. It accepts either a source URL or the inline content of the stylesheet.
- `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script. - `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script.
## Transformers ## Transformers
@ -85,8 +85,10 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
if (engine === "katex") { if (engine === "katex") {
return { return {
css: [ css: [
{
// base css // base css
"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css", content: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
},
], ],
js: [ js: [
{ {
@ -260,11 +262,11 @@ export const ContentPage: QuartzEmitterPlugin = () => {
...defaultContentPageLayout, ...defaultContentPageLayout,
pageBody: Content(), pageBody: Content(),
} }
const { head, header, beforeBody, pageBody, left, right, footer } = layout const { head, header, beforeBody, pageBody, afterBody, left, right, footer } = layout
return { return {
name: "ContentPage", name: "ContentPage",
getQuartzComponents() { 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[]> { async emit(ctx, content, resources, emit): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration 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. - `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. - `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. - `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. - `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. - `aliases`: Other names for this note. This is a list of strings.
- `tags`: Tags for this note. - `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. - `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 > - `--serve`: run a local hot-reloading server to preview your Quartz
> - `--port`: what port to run the local preview server on > - `--port`: what port to run the local preview server on
> - `--concurrency`: how many threads to use to parse notes > - `--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: 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. - `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. - `enableSPA`: whether to enable [[SPA Routing]] on your site.
- `enablePopovers`: whether to enable [[popover previews]] on your site. - `enablePopovers`: whether to enable [[popover previews]] on your site.
- `analytics`: what to use for analytics on your site. Values can be - `analytics`: what to use for analytics on your site. Values can be
@ -31,6 +32,8 @@ This part of the configuration concerns anything that can affect the whole site.
- `{ 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: '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: '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: '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 - `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. - `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`. - 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`.
@ -52,6 +55,7 @@ This part of the configuration concerns anything that can affect the whole site.
- `secondary`: link colour, current [[graph view|graph]] node - `secondary`: link colour, current [[graph view|graph]] node
- `tertiary`: hover states and visited [[graph view|graph]] nodes - `tertiary`: hover states and visited [[graph view|graph]] nodes
- `highlight`: internal link background, highlighted text, [[syntax highlighting|highlighted lines of code]] - `highlight`: internal link background, highlighted text, [[syntax highlighting|highlighted lines of code]]
- `textHighlight`: markdown highlighted text background
## Plugins ## Plugins

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.

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

@ -0,0 +1,127 @@
---
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
// Url to folder with custom themes
// defaults to 'https://${cfg.baseUrl}/static/giscus'
themeUrl?: string
// filename for light theme .css file
// defaults to 'light'
lightTheme?: string
// filename for dark theme .css file
// defaults to 'dark'
darkTheme?: 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"
}
}
```
#### Custom CSS theme
Quartz supports custom theme for Giscus. To use a custom CSS theme, place the `.css` file inside the `quartz/static` folder and set the configuration values.
For example, if you have a light theme `light-theme.css`, a dark theme `dark-theme.css`, and your Quartz site is hosted at `https://example.com/`:
```ts
afterBody: [
Component.Comments({
provider: 'giscus',
options: {
// Other options
themeUrl: "https://example.com/static/giscus", // corresponds to quartz/static/giscus/
lightTheme: "light-theme", // corresponds to light-theme.css in quartz/static/giscus/
darkTheme: "dark-theme", // corresponds to dark-theme.css quartz/static/giscus/
}
}),
],
```
#### Conditionally display comments
Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`.
```
---
title: Comments disabled here!
comments: false
---
```

View File

@ -30,4 +30,4 @@ As with folder listings, you can also provide a description and title for a tag
## Customization ## 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

@ -0,0 +1,401 @@
---
title: "Social Media Preview Cards"
---
A lot of social media platforms can display a rich preview for your website when sharing a link (most notably, a cover image, a title and a description). Quartz automatically handles most of this for you with reasonable defaults, but for more control, you can customize these by setting [[social images#Frontmatter Properties]].
Quartz can also dynamically generate and use new cover images for every page to be used in link previews on social media for you. To get started with this, set `generateSocialImages: true` in `quartz.config.ts`.
## Showcase
After enabling `generateSocialImages` in `quartz.config.ts`, the social media link preview for [[authoring content | Authoring Content]] looks like this:
| Light | Dark |
| ----------------------------------- | ---------------------------------- |
| ![[social-image-preview-light.png]] | ![[social-image-preview-dark.png]] |
For testing, it is recommended to use [opengraph.xyz](https://www.opengraph.xyz/) to see what the link to your page will look like on various platforms (more info under [[social images#local testing]]).
## Customization
You can customize how images will be generated in the quartz config.
For example, here's what the default configuration looks like if you set `generateSocialImages: true`:
```typescript title="quartz.config.ts"
generateSocialImages: {
colorScheme: "lightMode", // what colors to use for generating image, same as theme colors from config, valid values are "darkMode" and "lightMode"
width: 1200, // width to generate with (in pixels)
height: 630, // height to generate with (in pixels)
excludeRoot: false, // wether to exclude "/" index path to be excluded from auto generated images (false = use auto, true = use default og image)
}
```
---
### Frontmatter Properties
> [!tip] Hint
>
> Overriding social media preview properties via frontmatter still works even if `generateSocialImages` is disabled.
The following properties can be used to customize your link previews:
| Property | Alias | Summary |
| ------------------- | ---------------- | ----------------------------------- |
| `socialDescription` | `description` | Description to be used for preview. |
| `socialImage` | `image`, `cover` | Link to preview image. |
The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`.
> [!info] Info
>
> The priority for what image will be used for the cover image looks like the following: `frontmatter property > generated image (if enabled) > default image`.
>
> The default image (`quartz/static/og-image.png`) will only be used as a fallback if nothing else is set. If `generateSocialImages` is enabled, it will be treated as the new default per page, but can be overwritten by setting the `socialImage` frontmatter property for that page.
---
### Fully customized image generation
You can fully customize how the images being generated look by passing your own component to `generateSocialImages.imageStructure`. This component takes html/css + some page metadata/config options and converts it to an image using [satori](https://github.com/vercel/satori). Vercel provides an [online playground](https://og-playground.vercel.app/) that can be used to preview how your html/css looks like as a picture. This is ideal for prototyping your custom design.
It is recommended to write your own image components in `quartz/util/og.tsx` or any other `.tsx` file, as passing them to the config won't work otherwise. An example of the default image component can be found in `og.tsx` in `defaultImage()`.
> [!tip] Hint
>
> Satori only supports a subset of all valid CSS properties. All supported properties can be found in their [documentation](https://github.com/vercel/satori#css).
Your custom image component should have the `SocialImageOptions["imageStructure"]` type, to make development easier for you. Using a component of this type, you will be passed the following variables:
```ts
imageStructure: (
cfg: GlobalConfiguration, // global Quartz config (useful for getting theme colors and other info)
userOpts: UserOpts, // options passed to `generateSocialImage`
title: string, // title of current page
description: string, // description of current page
fonts: SatoriOptions["fonts"], // header + body font
) => JSXInternal.Element
```
Now, you can let your creativity flow and design your own image component! For reference and some cool tips, you can check how the markup for the default image looks.
> [!example] Examples
>
> Here are some examples for markup you may need to get started:
>
> - Get a theme color
>
> `cfg.theme.colors[colorScheme].<colorName>`, where `<colorName>` corresponds to a key in `ColorScheme` (defined at the top of `quartz/util/theme.ts`)
>
> - Use the page title/description
>
> `<p>{title}</p>`/`<p>{description}</p>`
>
> - Use a font family
>
> Detailed in the Fonts chapter below
---
### Fonts
You will also be passed an array containing a header and a body font (where the first entry is header and the second is body). The fonts matches the ones selected in `theme.typography.header` and `theme.typography.body` from `quartz.config.ts` and will be passed in the format required by [`satori`](https://github.com/vercel/satori). To use them in CSS, use the `.name` property (e.g. `fontFamily: fonts[1].name` to use the "body" font family).
An example of a component using the header font could look like this:
```tsx title="socialImage.tsx"
export const myImage: SocialImageOptions["imageStructure"] = (...) => {
return <p style={{ fontFamily: fonts[0].name }}>Cool Header!</p>
}
```
> [!example]- Local fonts
>
> For cases where you use a local fonts under `static` folder, make sure to set the correct `@font-face` in `custom.scss`
>
> ```scss title="custom.scss"
> @font-face {
> font-family: "Newsreader";
> font-style: normal;
> font-weight: normal;
> font-display: swap;
> src: url("/static/Newsreader.woff2") format("woff2");
> }
> ```
>
> Then in `quartz/util/og.tsx`, you can load the satori fonts like so:
>
> ```tsx title="quartz/util/og.tsx"
> const headerFont = joinSegments("static", "Newsreader.woff2")
> const bodyFont = joinSegments("static", "Newsreader.woff2")
>
> export async function getSatoriFont(cfg: GlobalConfiguration): Promise<SatoriOptions["fonts"]> {
> const headerWeight: FontWeight = 700
> const bodyWeight: FontWeight = 400
>
> const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
>
> const [header, body] = await Promise.all(
> [headerFont, bodyFont].map((font) =>
> fetch(`${url.toString()}/${font}`).then((res) => res.arrayBuffer()),
> ),
> )
>
> return [
> { name: cfg.theme.typography.header, data: header, weight: headerWeight, style: "normal" },
> { name: cfg.theme.typography.body, data: body, weight: bodyWeight, style: "normal" },
> ]
> }
> ```
>
> This font then can be used with your custom structure
### Local testing
To test how the full preview of your page is going to look even before deploying, you can forward the port you're serving quartz on. In VSCode, this can easily be achieved following [this guide](https://code.visualstudio.com/docs/editor/port-forwarding) (make sure to set `Visibility` to `public` if testing on external tools like [opengraph.xyz](https://www.opengraph.xyz/)).
If you have `generateSocialImages` enabled, you can check out all generated images under `public/static/social-images`.
## Technical info
Images will be generated as `.webp` files, which helps to keep images small (the average image takes ~`19kB`). They are also compressed further using [sharp](https://sharp.pixelplumbing.com/).
When using images, the appropriate [Open Graph](https://ogp.me/) and [Twitter](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started) meta tags will be set to ensure they work and look as expected.
## Examples
Besides the template for the default image generation (found under `quartz/util/og.tsx`), you can also add your own! To do this, you can either edit the source code of that file (not recommended) or create a new one (e.g. `customSocialImage.tsx`, source shown below).
After adding that file, you can update `quartz.config.ts` to use your image generation template as follows:
```ts
// Import component at start of file
import { customImage } from "./quartz/util/customSocialImage.tsx"
// In main config
const config: QuartzConfig = {
...
generateSocialImages: {
...
imageStructure: customImage, // tells quartz to use your component when generating images
},
}
```
The following example will generate images that look as follows:
| Light | Dark |
| ------------------------------------------ | ----------------------------------------- |
| ![[custom-social-image-preview-light.png]] | ![[custom-social-image-preview-dark.png]] |
This example (and the default template) use colors and fonts from your theme specified in the quartz config. Fonts get passed in as a prop, where `fonts[0]` will contain the header font and `fonts[1]` will contain the body font (more info in the [[#fonts]] section).
```tsx
import { SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg"
import { SocialImageOptions, UserOpts } from "./imageHelper"
import { QuartzPluginData } from "../plugins/vfile"
export const customImage: SocialImageOptions["imageStructure"] = (
cfg: GlobalConfiguration,
userOpts: UserOpts,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData,
) => {
// How many characters are allowed before switching to smaller font
const fontBreakPoint = 22
const useSmallerFont = title.length > fontBreakPoint
const { colorScheme } = userOpts
return (
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
height: "100%",
width: "100%",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
flexDirection: "column",
gap: "2.5rem",
paddingTop: "2rem",
paddingBottom: "2rem",
}}
>
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
marginLeft: "4rem",
textAlign: "center",
marginRight: "4rem",
fontFamily: fonts[0].name,
}}
>
{title}
</p>
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: 44,
marginLeft: "8rem",
marginRight: "8rem",
lineClamp: 3,
fontFamily: fonts[1].name,
}}
>
{description}
</p>
</div>
<div
style={{
height: "100%",
width: "2vw",
position: "absolute",
backgroundColor: cfg.theme.colors[colorScheme].tertiary,
opacity: 0.85,
}}
/>
</div>
)
}
```
> [!example]- Advanced example
>
> The following example includes a customized social image with a custom background and formatted date.
>
> ```typescript title="custom-og.tsx"
> export const og: SocialImageOptions["Component"] = (
> cfg: GlobalConfiguration,
> fileData: QuartzPluginData,
> { colorScheme }: Options,
> title: string,
> description: string,
> fonts: SatoriOptions["fonts"],
> ) => {
> let created: string | undefined
> let reading: string | undefined
> if (fileData.dates) {
> created = formatDate(getDate(cfg, fileData)!, cfg.locale)
> }
> const { minutes, text: _timeTaken, words: _words } = readingTime(fileData.text!)
> reading = i18n(cfg.locale).components.contentMeta.readingTime({
> minutes: Math.ceil(minutes),
> })
>
> const Li = [created, reading]
>
> return (
> <div
> style={{
> position: "relative",
> display: "flex",
> flexDirection: "row",
> alignItems: "flex-start",
> height: "100%",
> width: "100%",
> backgroundImage: `url("https://${cfg.baseUrl}/static/og-image.jpeg")`,
> backgroundSize: "100% 100%",
> }}
> >
> <div
> style={{
> position: "absolute",
> top: 0,
> left: 0,
> right: 0,
> bottom: 0,
> background: "radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.4) 70%)",
> }}
> />
> <div
> style={{
> display: "flex",
> height: "100%",
> width: "100%",
> flexDirection: "column",
> justifyContent: "flex-start",
> alignItems: "flex-start",
> gap: "1.5rem",
> paddingTop: "4rem",
> paddingBottom: "4rem",
> marginLeft: "4rem",
> }}
> >
> <img
> src={`"https://${cfg.baseUrl}/static/icon.jpeg"`}
> style={{
> position: "relative",
> backgroundClip: "border-box",
> borderRadius: "6rem",
> }}
> width={80}
> />
> <div
> style={{
> display: "flex",
> flexDirection: "column",
> textAlign: "left",
> fontFamily: fonts[0].name,
> }}
> >
> <h2
> style={{
> color: cfg.theme.colors[colorScheme].light,
> fontSize: "3rem",
> fontWeight: 700,
> marginRight: "4rem",
> fontFamily: fonts[0].name,
> }}
> >
> {title}
> </h2>
> <ul
> style={{
> color: cfg.theme.colors[colorScheme].gray,
> gap: "1rem",
> fontSize: "1.5rem",
> fontFamily: fonts[1].name,
> }}
> >
> {Li.map((item, index) => {
> if (item) {
> return <li key={index}>{item}</li>
> }
> })}
> </ul>
> </div>
> <p
> style={{
> color: cfg.theme.colors[colorScheme].light,
> fontSize: "1.5rem",
> overflow: "hidden",
> marginRight: "8rem",
> textOverflow: "ellipsis",
> display: "-webkit-box",
> WebkitLineClamp: 7,
> WebkitBoxOrient: "vertical",
> lineClamp: 7,
> fontFamily: fonts[1].name,
> }}
> >
> {description}
> </p>
> </div>
> </div>
> )
> }
> ```

View File

@ -95,6 +95,16 @@ const [age, setAge] = useState(50)
const [name, setName] = useState("Taylor") 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 ### Line numbers
Syntax highlighting has line numbers configured automatically. If you want to start line numbers at a specific number, use `showLineNumbers{number}`: 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 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 ## misc backlog
- breadcrumbs component - static dead link detection
- cursor chat extension - cursor chat extension
- https://giscus.app/ extension
- sidenotes? https://github.com/capnfabs/paperesque - sidenotes? https://github.com/capnfabs/paperesque
- direct match in search using double quotes - direct match in search using double quotes
- https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI - https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI
- audio/video embed styling
- Canvas - 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: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Fetch all history for git info fetch-depth: 0 # Fetch all history for git info
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: 18.14 node-version: 22
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Build Quartz - name: Build Quartz
run: npx quartz build run: npx quartz build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v2 uses: actions/upload-pages-artifact@v3
with: with:
path: public path: public
@ -81,7 +81,7 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v2 uses: actions/deploy-pages@v4
``` ```
Then: Then:
@ -182,37 +182,33 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
## GitLab Pages ## 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: stages:
- build - build
- deploy - deploy
variables: image: node:20
NODE_VERSION: "18.14" cache: # Cache modules in between jobs
key: $CI_COMMIT_REF_SLUG
paths:
- .npm/
build: build:
stage: build stage: build
rules: rules:
- if: '$CI_COMMIT_REF_NAME == "v4"' - if: '$CI_COMMIT_REF_NAME == "v4"'
before_script: before_script:
- apt-get update -q && apt-get install -y nodejs npm
- npm install -g n
- n $NODE_VERSION
- hash -r - hash -r
- npm ci - npm ci --cache .npm --prefer-offline
script: script:
- npx quartz build - npx quartz build
artifacts: artifacts:
paths: paths:
- public - public
cache:
paths:
- ~/.npm/
key: "${CI_COMMIT_REF_SLUG}-node-${CI_COMMIT_REF_NAME}"
tags: tags:
- docker - gitlab-org-docker
pages: pages:
stage: deploy stage: deploy
@ -251,6 +247,28 @@ server {
} }
``` ```
### Using Apache
Here's an example of how to do this with Apache:
```apache title=".htaccess"
RewriteEngine On
ErrorDocument 404 /404.html
# Rewrite rule for .html extension removal (with directory check)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_URI}.html -f
RewriteRule ^(.*)$ $1.html [L]
# Handle directory requests explicitly
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^(.*)/$ $1/index.html [L]
```
Don't forget to activate brotli / gzip compression.
### Using Caddy ### Using Caddy
Here's and example of how to do this with Caddy: Here's and example of how to do this with Caddy:

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -6,7 +6,7 @@ Quartz is a fast, batteries-included static-site generator that transforms Markd
## 🪴 Get Started ## 🪴 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: 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 ## 🔧 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 - Hot-reload for both configuration and content
- Simple JSX layouts and [[creating components|page components]] - Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes

View File

@ -12,15 +12,20 @@ export interface FullPageLayout {
header: QuartzComponent[] // laid out horizontally header: QuartzComponent[] // laid out horizontally
beforeBody: QuartzComponent[] // laid out vertically beforeBody: QuartzComponent[] // laid out vertically
pageBody: QuartzComponent // single component pageBody: QuartzComponent // single component
left: QuartzComponent[] // vertical on desktop, horizontal on mobile afterBody: QuartzComponent[] // laid out vertically
right: QuartzComponent[] // vertical on desktop, horizontal on mobile left: QuartzComponent[] // vertical on desktop and tablet, horizontal on mobile
right: QuartzComponent[] // vertical on desktop, horizontal on tablet and mobile
footer: QuartzComponent // single component footer: QuartzComponent // single component
} }
``` ```
These correspond to following parts of the page: 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] > [!note]
> There are two additional layout fields that are _not_ shown in the above diagram. > 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. 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 ### 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. 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

@ -11,7 +11,7 @@ This plugin determines the created, modified, and published dates for a document
This plugin accepts the following configuration options: 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] > [!warning]
> If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`. > If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`.

View File

@ -11,10 +11,12 @@ Example: [[advanced/|Advanced]]
> [!note] > [!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.
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`). 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 ## API
- Category: Emitter - Category: Emitter

View File

@ -11,7 +11,12 @@ This plugin adds LaTeX support to Quartz. See [[features/Latex|Latex]] for more
This plugin accepts the following configuration options: 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. - `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/), `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html), or `"typst"` for [Typst](https://typst.app/) (a new way to compose LaTeX equation). 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}"}`
> [!note] Typst support
>
> Currently, typst doesn't support inline-math
## API ## API

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

@ -9,10 +9,12 @@ This plugin emits dedicated pages for each tag used in the content. See [[folder
> [!note] > [!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.
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`). 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 ## API
- Category: Emitter - Category: Emitter

View File

@ -7,26 +7,28 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/) - [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/) - [Jacky Zhao's Garden](https://jzhao.xyz/)
- [Socratica Toolbox](https://toolbox.socratica.info/) - [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/) - [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Quantum Garden](https://quantumgardener.blog/) - [The Pond](https://turntrout.com/welcome)
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Pelayo Arbues' Notes](https://pelayoarbues.com/)
- [Matt Dunn's Second Brain](https://mattdunn.info/) - [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) - [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
- [Vince Imbat's Talahardin](https://vinceimbat.com/) - [oldwinter の数字花园](https://garden.oldwinter.top/)
- [Eilleen's Everything Notebook](https://quartz.eilleeenz.com/)
- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/) - [🧠🌳 Chad's Mind Garden](https://www.chadly.net/)
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/) - [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
- [Mau Camargo's Notkesto](https://notes.camargomau.com/) - [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/) - [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) - [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/) - [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Simon's Second Brain: Crafted, Curated, Connected, Compounded](https://brain.ssp.sh/)
- [Data Engineering Vault: A Second Brain Knowledge Network](https://vault.ssp.sh/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/) - [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [🪴Aster's notebook](https://notes.asterhu.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) - [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/) - [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)! 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, type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void, listener: (this: Document, ev: CustomEventMap[K]) => void,
): 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 dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
} }
interface Window { interface Window {

3857
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.2.3", "version": "4.4.0",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@ -21,7 +21,7 @@
}, },
"engines": { "engines": {
"npm": ">=9.3.1", "npm": ">=9.3.1",
"node": ">=18.14" "node": "20 || >=22"
}, },
"keywords": [ "keywords": [
"site generator", "site generator",
@ -35,39 +35,43 @@
"quartz": "./quartz/bootstrap-cli.mjs" "quartz": "./quartz/bootstrap-cli.mjs"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.8.1",
"@floating-ui/dom": "^1.6.3", "@floating-ui/dom": "^1.6.12",
"@napi-rs/simple-git": "0.1.16", "@myriaddreamin/rehype-typst": "^0.5.0-rc7",
"@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chokidar": "^3.6.0", "chokidar": "^4.0.1",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"d3": "^7.9.0", "d3": "^7.9.0",
"esbuild-sass-plugin": "^2.16.1", "esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43", "flexsearch": "0.7.43",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^14.0.1", "globby": "^14.0.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.1", "hast-util-to-html": "^9.0.3",
"hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-jsx-runtime": "^2.3.2",
"hast-util-to-string": "^3.0.0", "hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lightningcss": "^1.24.1", "lightningcss": "^1.28.1",
"mdast-util-find-and-replace": "^3.0.1", "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", "mdast-util-to-string": "^4.0.0",
"mermaid": "^11.4.0",
"micromorph": "^0.4.5", "micromorph": "^0.4.5",
"preact": "^10.20.1", "pixi.js": "^8.5.2",
"preact-render-to-string": "^6.4.2", "preact": "^10.24.3",
"preact-render-to-string": "^6.5.11",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.0.0", "rehype-citation": "^2.2.2",
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.1",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.13.0", "rehype-pretty-code": "^0.14.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
@ -76,20 +80,22 @@
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0", "remark-rehype": "^11.1.1",
"remark-smartypants": "^2.1.0", "remark-smartypants": "^3.0.2",
"rfdc": "^1.3.1", "rfdc": "^1.4.1",
"rimraf": "^5.0.7", "rimraf": "^6.0.1",
"serve-handler": "^6.1.5", "satori": "^0.11.3",
"shiki": "^1.6.0", "serve-handler": "^6.1.6",
"sharp": "^0.33.5",
"shiki": "^1.23.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^8.0.0", "to-vfile": "^8.0.0",
"toml": "^3.0.0", "toml": "^3.0.0",
"unified": "^11.0.4", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.1", "vfile": "^6.0.3",
"workerpool": "^9.1.1", "workerpool": "^9.2.0",
"ws": "^8.15.1", "ws": "^8.18.0",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
@ -97,14 +103,14 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^20.12.5", "@types/node": "^22.9.0",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.13",
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.33",
"esbuild": "^0.19.9", "esbuild": "^0.24.0",
"prettier": "^3.2.4", "prettier": "^3.3.3",
"tsx": "^4.9.3", "tsx": "^4.19.2",
"typescript": "^5.4.5" "typescript": "^5.6.3"
} }
} }

View File

@ -11,11 +11,14 @@ const config: QuartzConfig = {
pageTitle: "🌱 oldwinterの数字花园", pageTitle: "🌱 oldwinterの数字花园",
enableSPA: true, enableSPA: true,
enablePopovers: true, enablePopovers: true,
analytics: null, analytics: {
provider: "plausible",
},
locale: "zh-CN", locale: "zh-CN",
baseUrl: "garden.oldwinter.top", baseUrl: "garden.oldwinter.top",
ignorePatterns: ["private", "templates", ".obsidian","Atlas","Calendar","Cards", "Extras","Sources", "Spaces"], ignorePatterns: ["private", "templates", ".obsidian","Atlas","Calendar","Cards", "Extras","Sources", "Spaces"],
defaultDateType: "published", defaultDateType: "published",
generateSocialImages: false,
theme: { theme: {
fontOrigin: "googleFonts", fontOrigin: "googleFonts",
cdnCaching: false, cdnCaching: false,
@ -34,6 +37,7 @@ const config: QuartzConfig = {
secondary: "#284b63", secondary: "#284b63",
tertiary: "#84a59d", tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)", highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
}, },
darkMode: { darkMode: {
light: "#161618", light: "#161618",
@ -44,6 +48,7 @@ const config: QuartzConfig = {
secondary: "#7b97aa", secondary: "#7b97aa",
tertiary: "#84a59d", tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)", highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#b3aa0288",
}, },
}, },
}, },
@ -54,7 +59,6 @@ const config: QuartzConfig = {
Plugin.CreatedModifiedDate({ Plugin.CreatedModifiedDate({
priority: ["frontmatter"], priority: ["frontmatter"],
}), }),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.SyntaxHighlighting({ Plugin.SyntaxHighlighting({
theme: { theme: {
light: "github-light", light: "github-light",
@ -68,10 +72,7 @@ const config: QuartzConfig = {
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(), Plugin.Description(),
], ],
filters: [ filters: [Plugin.RemoveDrafts()],
Plugin.RemoveDrafts(),
// Plugin.ExplicitPublish()
],
emitters: [ emitters: [
Plugin.AliasRedirects(), Plugin.AliasRedirects(),
Plugin.ComponentResources(), Plugin.ComponentResources(),

View File

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

View File

@ -38,8 +38,13 @@ type BuildData = {
type FileEvent = "add" | "change" | "delete" type FileEvent = "add" | "change" | "delete"
function newBuildId() {
return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = { const ctx: BuildCtx = {
buildId: newBuildId(),
argv, argv,
cfg, cfg,
allSlugs: [], allSlugs: [],
@ -157,10 +162,13 @@ async function partialRebuildFromEntrypoint(
return return
} }
const buildStart = new Date().getTime() const buildId = newBuildId()
buildData.lastBuildMs = buildStart ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire() 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() release()
return return
} }
@ -351,26 +359,22 @@ async function rebuildFromEntrypoint(
toRemove.add(filePath) toRemove.add(filePath)
} }
const buildStart = new Date().getTime() const buildId = newBuildId()
buildData.lastBuildMs = buildStart ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire() const release = await mut.acquire()
// there's another build after us, release and let them do it // there's another build after us, release and let them do it
if (buildData.lastBuildMs > buildStart) { if (ctx.buildId !== buildId) {
release() release()
return return
} }
const perf = new PerfTimer() const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding...")) console.log(chalk.yellow("Detected change, rebuilding..."))
try { try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) 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) const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) { for (const content of parsedContent) {
const [_tree, vfile] = content const [_tree, vfile] = content
@ -384,6 +388,13 @@ async function rebuildFromEntrypoint(
const parsedFiles = [...contentMap.values()] const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles) 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 // TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything // instead of just deleting everything
await rimraf(path.join(argv.output, ".*"), { glob: true }) await rimraf(path.join(argv.output, ".*"), { glob: true })
@ -396,10 +407,10 @@ async function rebuildFromEntrypoint(
} }
} }
release()
clientRefresh() clientRefresh()
toRebuild.clear() toRebuild.clear()
toRemove.clear() toRemove.clear()
release()
} }
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {

View File

@ -2,6 +2,7 @@ import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types" import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n" import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types" import { PluginTypes } from "./plugins/types"
import { SocialImageOptions } from "./util/og"
import { Theme } from "./util/theme" import { Theme } from "./util/theme"
export type Analytics = export type Analytics =
@ -34,9 +35,18 @@ export type Analytics =
provider: "tinylytics" provider: "tinylytics"
siteId: string siteId: string
} }
| {
provider: "cabin"
host?: string
}
| {
provider: "clarity"
projectId?: string
}
export interface GlobalConfiguration { export interface GlobalConfiguration {
pageTitle: string pageTitle: string
pageTitleSuffix?: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */ /** Whether to display Wikipedia-style popovers when hovering over links */
@ -51,11 +61,15 @@ export interface GlobalConfiguration {
* Quartz will avoid using this as much as possible and use relative URLs most of the time * Quartz will avoid using this as much as possible and use relative URLs most of the time
*/ */
baseUrl?: string baseUrl?: string
/**
* Whether to generate social images (Open Graph and Twitter standard) for link previews
*/
generateSocialImages: boolean | Partial<SocialImageOptions>
theme: Theme theme: Theme
/** /**
* Allow to translate the date in the language of your choice. * Allow to translate the date in the language of your choice.
* Also used for UI translation (default: en-US) * Also used for UI translation (default: en-US)
* Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag * Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* The first part is the language (en) and the second part is the script/region (US) * The first part is the language (en) and the second part is the script/region (US)
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes * Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
@ -73,10 +87,11 @@ export interface FullPageLayout {
header: QuartzComponent[] header: QuartzComponent[]
beforeBody: QuartzComponent[] beforeBody: QuartzComponent[]
pageBody: QuartzComponent pageBody: QuartzComponent
afterBody: QuartzComponent[]
left: QuartzComponent[] left: QuartzComponent[]
right: QuartzComponent[] right: QuartzComponent[]
footer: QuartzComponent footer: QuartzComponent
} }
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right"> 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

@ -15,6 +15,7 @@ import { WebSocketServer } from "ws"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { Mutex } from "async-mutex" import { Mutex } from "async-mutex"
import { CreateArgv } from "./args.js" import { CreateArgv } from "./args.js"
import { globby } from "globby"
import { import {
exitIfCancel, exitIfCancel,
escapePath, escapePath,
@ -44,7 +45,7 @@ export async function handleCreate(argv) {
let linkResolutionStrategy = argv.links?.toLowerCase() let linkResolutionStrategy = argv.links?.toLowerCase()
const sourceDirectory = argv.source const sourceDirectory = argv.source
// If all cmd arguments were provided, check if theyre valid // If all cmd arguments were provided, check if they're valid
if (setupStrategy && linkResolutionStrategy) { if (setupStrategy && linkResolutionStrategy) {
// If setup isn't, "new", source argument is required // If setup isn't, "new", source argument is required
if (setupStrategy !== "new") { if (setupStrategy !== "new") {
@ -236,6 +237,11 @@ export async function handleBuild(argv) {
type: "css-text", type: "css-text",
cssImports: true, cssImports: true,
}), }),
sassPlugin({
filter: /\.inline\.scss$/,
type: "css",
cssImports: true,
}),
{ {
name: "inline-script-loader", name: "inline-script-loader",
setup(build) { setup(build) {
@ -285,8 +291,8 @@ export async function handleBuild(argv) {
} }
if (cleanupBuild) { if (cleanupBuild) {
await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild...")) console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
await cleanupBuild()
} }
const result = await ctx.rebuild().catch((err) => { const result = await ctx.rebuild().catch((err) => {
@ -350,6 +356,15 @@ export async function handleBuild(argv) {
source: "**/*.*", source: "**/*.*",
headers: [{ key: "Content-Disposition", value: "inline" }], headers: [{ key: "Content-Disposition", value: "inline" }],
}, },
{
source: "**/*.webp",
headers: [{ key: "Content-Type", value: "image/webp" }],
},
// fixes bug where avif images are displayed as text instead of images (future proof)
{
source: "**/*.avif",
headers: [{ key: "Content-Type", value: "image/avif" }],
},
], ],
}) })
const status = res.statusCode const status = res.statusCode
@ -418,13 +433,12 @@ export async function handleBuild(argv) {
), ),
) )
console.log("hint: exit with ctrl+c") console.log("hint: exit with ctrl+c")
const paths = await globby(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"])
chokidar chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], { .watch(paths, { ignoreInitial: true })
ignoreInitial: true, .on("add", () => build(clientRefresh))
}) .on("change", () => build(clientRefresh))
.on("all", async () => { .on("unlink", () => build(clientRefresh))
build(clientRefresh)
})
} else { } else {
await build(() => {}) await build(() => {})
ctx.dispose() ctx.dispose()
@ -457,7 +471,25 @@ export async function handleUpdate(argv) {
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date") console.log("Ensuring dependencies are up to date")
const res = spawnSync("npm", ["i"], { stdio: "inherit" })
/*
On Windows, if the command `npm` is really `npm.cmd', this call fails
as it will be unable to find `npm`. This is often the case on systems
where `npm` is installed via a package manager.
This means `npx quartz update` will not actually update dependencies
on Windows, without a manual `npm i` from the caller.
However, by spawning a shell, we are able to call `npm.cmd`.
See: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
const opts = { stdio: "inherit" }
if (process.platform === "win32") {
opts.shell = true
}
const res = spawnSync("npm", ["i"], opts)
if (res.status === 0) { if (res.status === 0) {
console.log(chalk.green("Done!")) console.log(chalk.green("Done!"))
} else { } else {

View File

@ -0,0 +1,59 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/comments.inline"
type Options = {
provider: "giscus"
options: {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: string
themeUrl?: string
lightTheme?: string
darkTheme?: string
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict?: boolean
reactionsEnabled?: boolean
inputPosition?: "top" | "bottom"
}
}
function boolToStringBool(b: boolean): string {
return b ? "1" : "0"
}
export default ((opts: Options) => {
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
// check if comments should be displayed according to frontmatter
const disableComment: boolean =
!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false"
if (disableComment) {
return <></>
}
return (
<div
class={classNames(displayClass, "giscus")}
data-repo={opts.options.repo}
data-repo-id={opts.options.repoId}
data-category={opts.options.category}
data-category-id={opts.options.categoryId}
data-mapping={opts.options.mapping ?? "url"}
data-strict={boolToStringBool(opts.options.strict ?? true)}
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
data-input-position={opts.options.inputPosition ?? "bottom"}
data-light-theme={opts.options.lightTheme ?? "light"}
data-dark-theme={opts.options.darkTheme ?? "dark"}
data-theme-url={
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
}
></div>
)
}
Comments.afterDOMLoaded = script
return Comments
}) satisfies QuartzComponentConstructor<Options>

View File

@ -9,9 +9,7 @@ import { classNames } from "../util/lang"
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
return ( return (
<div class={classNames(displayClass, "darkmode")}> <button class={classNames(displayClass, "darkmode")} id="darkmode">
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
@ -22,12 +20,11 @@ const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps)
viewBox="0 0 35 35" viewBox="0 0 35 35"
style="enable-background:new 0 0 35 35" style="enable-background:new 0 0 35 35"
xmlSpace="preserve" xmlSpace="preserve"
aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
> >
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title> <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> <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>
</label>
<label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
@ -38,12 +35,12 @@ const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps)
viewBox="0 0 100 100" viewBox="0 0 100 100"
style="enable-background:new 0 0 100 100" style="enable-background:new 0 0 100 100"
xmlSpace="preserve" xmlSpace="preserve"
aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
> >
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title> <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> <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> </svg>
</label> </button>
</div>
) )
} }

View File

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

View File

@ -168,10 +168,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
const isDefaultOpen = opts.folderDefaultState === "open" const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath // Calculate current folderPath
let folderPath = "" const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
if (node.name !== "") { const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
folderPath = joinSegments(fullPath ?? "", node.name)
}
return ( 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 */} {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
<div key={node.name} data-folderpath={folderPath}> <div key={node.name} data-folderpath={folderPath}>
{folderBehavior === "link" ? ( {folderBehavior === "link" ? (
<a <a href={href} data-for={node.name} class="folder-title">
href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
data-for={node.name}
class="folder-title"
>
{node.displayName} {node.displayName}
</a> </a>
) : ( ) : (

View File

@ -65,9 +65,9 @@ export default ((opts?: GraphOptions) => {
<h3>{i18n(cfg.locale).components.graph.title}</h3> <h3>{i18n(cfg.locale).components.graph.title}</h3>
<div class="graph-outer"> <div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<button id="global-graph-icon" aria-label="Global Graph">
<svg <svg
version="1.1" version="1.1"
id="global-graph-icon"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px" x="0px"
@ -90,6 +90,7 @@ export default ((opts?: GraphOptions) => {
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" 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> </svg>
</button>
</div> </div>
<div id="global-graph-outer"> <div id="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div> <div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>

View File

@ -1,14 +1,120 @@
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { FullSlug, joinSegments, pathToRoot } from "../util/path" import { FullSlug, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources" import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
import { googleFontHref } from "../util/theme" import { googleFontHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import satori, { SatoriOptions } from "satori"
import fs from "fs"
import sharp from "sharp"
import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og"
import { unescapeHTML } from "../util/escape"
/**
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
* @param opts options for generating image
*/
async function generateSocialImage(
{ cfg, description, fileName, fontsPromise, title, fileData }: ImageOptions,
userOpts: SocialImageOptions,
imageDir: string,
) {
const fonts = await fontsPromise
const { width, height } = userOpts
// JSX that will be used to generate satori svg
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
const svg = await satori(imageComponent, { width, height, fonts })
// Convert svg directly to webp (with additional compression)
const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer()
// Write to file system
const filePath = joinSegments(imageDir, `${fileName}.${extension}`)
fs.writeFileSync(filePath, compressed)
}
const extension = "webp"
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
width: 1200,
height: 630,
imageStructure: defaultImage,
excludeRoot: false,
}
export default (() => { export default (() => {
const Head: QuartzComponent = ({ cfg, fileData, externalResources }: QuartzComponentProps) => { let fontsPromise: Promise<SatoriOptions["fonts"]>
const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
const description = let fullOptions: SocialImageOptions
const Head: QuartzComponent = ({
cfg,
fileData,
externalResources,
ctx,
}: QuartzComponentProps) => {
// Initialize options if not set
if (!fullOptions) {
if (typeof cfg.generateSocialImages !== "boolean") {
fullOptions = { ...defaultOptions, ...cfg.generateSocialImages }
} else {
fullOptions = defaultOptions
}
}
// Memoize google fonts
if (!fontsPromise && cfg.generateSocialImages) {
fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body)
}
const slug = fileData.filePath
// since "/" is not a valid character in file names, replace with "-"
const fileName = slug?.replaceAll("/", "-")
// Get file description (priority: frontmatter > fileData > default)
const fdDescription =
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
let description = ""
if (fdDescription) {
description = unescapeHTML(fdDescription)
}
if (fileData.frontmatter?.socialDescription) {
description = fileData.frontmatter?.socialDescription as string
} else if (fileData.frontmatter?.description) {
description = fileData.frontmatter?.description
}
const fileDir = joinSegments(ctx.argv.output, "static", "social-images")
if (cfg.generateSocialImages) {
// Generate folders for social images (if they dont exist yet)
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir, { recursive: true })
}
if (fileName) {
// Generate social image (happens async)
generateSocialImage(
{
title,
description,
fileName,
fileDir,
fileExt: extension,
fontsPromise,
cfg,
fileData,
},
fullOptions,
fileDir,
)
}
}
const { css, js } = externalResources const { css, js } = externalResources
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
@ -16,7 +122,37 @@ export default (() => {
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
const iconPath = joinSegments(baseDir, "static/icon.png") const iconPath = joinSegments(baseDir, "static/icon.png")
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
// "static/social-images/slug-filename.md.webp"
const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace(
`${ctx.argv.output}/`,
"",
)}/${fileName}.${extension}`
// Use default og image if filePath doesnt exist (for autogenerated paths with no .md file)
const useDefaultOgImage = fileName === undefined || !cfg.generateSocialImages
// Path to og/social image (priority: frontmatter > generated image (if enabled) > default image)
let ogImagePath = useDefaultOgImage ? ogImageDefaultPath : ogImageGeneratedPath
// TODO: could be improved to support external images in the future
// Aliases for image and cover handled in `frontmatter.ts`
const frontmatterImgUrl = fileData.frontmatter?.socialImage
// Override with default og image if config option is set
if (fileData.slug === "index") {
ogImagePath = ogImageDefaultPath
}
// Override with frontmatter url if existing
if (frontmatterImgUrl) {
ogImagePath = `https://${cfg.baseUrl}/static/${frontmatterImgUrl}`
}
// Url of current page
const socialUrl =
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
return ( return (
<head> <head>
@ -30,17 +166,39 @@ export default (() => {
</> </>
)} )}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
{/* OG/Twitter meta tags */}
<meta name="og:site_name" content={cfg.pageTitle}></meta>
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
{cfg.baseUrl && <meta property="og:image" content={ogImagePath} />} <meta property="og:image:type" content={`image/${extension}`} />
<meta property="og:width" content="1200" /> <meta property="og:image:alt" content={description} />
<meta property="og:height" content="675" /> {/* Dont set width and height if unknown (when using custom frontmatter image) */}
{!frontmatterImgUrl && (
<>
<meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} />
<meta property="og:width" content={fullOptions.width.toString()} />
<meta property="og:height" content={fullOptions.height.toString()} />
</>
)}
<meta property="og:image:url" content={ogImagePath} />
{cfg.baseUrl && (
<>
<meta name="twitter:image" content={ogImagePath} />
<meta property="og:image" content={ogImagePath} />
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
<meta property="og:url" content={socialUrl}></meta>
<meta property="twitter:url" content={socialUrl}></meta>
</>
)}
<link rel="icon" href={iconPath} /> <link rel="icon" href={iconPath} />
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="generator" content="Quartz" /> <meta name="generator" content="Quartz" />
{css.map((href) => ( {css.map((resource) => CSSResourceToStyleElement(resource, true))}
<link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />
))}
{js {js
.filter((resource) => resource.loadTime === "beforeDOMReady") .filter((resource) => resource.loadTime === "beforeDOMReady")
.map((res) => JSResourceToScriptElement(res, true))} .map((res) => JSResourceToScriptElement(res, true))}

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,6 @@ TagList.css = `
gap: 0.4rem; gap: 0.4rem;
margin: 1rem 0; margin: 1rem 0;
flex-wrap: wrap; flex-wrap: wrap;
justify-self: end;
} }
.section-li > .section > .tags { .section-li > .section > .tags {

View File

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

View File

@ -2,21 +2,25 @@ import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } fro
import path from "path" import path from "path"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { byDateAndAlphabetical, PageList, SortFn } from "../PageList"
import { stripSlashes, simplifySlug } from "../../util/path" import { stripSlashes, simplifySlug, joinSegments, FullSlug } from "../../util/path"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
interface FolderContentOptions { interface FolderContentOptions {
/** /**
* Whether to display number of folders * Whether to display number of folders
*/ */
showFolderCount: boolean showFolderCount: boolean
showSubfolders: boolean
sort?: SortFn
} }
const defaultOptions: FolderContentOptions = { const defaultOptions: FolderContentOptions = {
showFolderCount: true, showFolderCount: true,
showSubfolders: true,
} }
export default ((opts?: Partial<FolderContentOptions>) => { export default ((opts?: Partial<FolderContentOptions>) => {
@ -25,18 +29,52 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
const folderSlug = stripSlashes(simplifySlug(fileData.slug!)) const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => { const folderParts = folderSlug.split(path.posix.sep)
const allPagesInFolder: QuartzPluginData[] = []
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
allFiles.forEach((file) => {
const fileSlug = stripSlashes(simplifySlug(file.slug!)) const fileSlug = stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const folderParts = folderSlug.split(path.posix.sep)
const fileParts = fileSlug.split(path.posix.sep) const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1 const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild
if (!prefixed) {
return
}
if (isDirectChild) {
allPagesInFolder.push(file)
} else if (options.showSubfolders) {
const subfolderSlug = joinSegments(
...fileParts.slice(0, folderParts.length + 1),
) as FullSlug
const pagesInFolder = allPagesInSubfolders.get(subfolderSlug) || []
allPagesInSubfolders.set(subfolderSlug, [...pagesInFolder, file])
}
}) })
allPagesInSubfolders.forEach((files, subfolderSlug) => {
const hasIndex = allPagesInFolder.some(
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
)
if (!hasIndex) {
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
allPagesInFolder.push({
slug: subfolderSlug,
dates: subfolderDates,
frontmatter: { title: subfolderTitle, tags: ["folder"] },
})
}
})
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? [] const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ") const classes = ["popover-hint", ...cssClasses].join(" ")
const listProps = { const listProps = {
...props, ...props,
sort: options.sort,
allFiles: allPagesInFolder, allFiles: allPagesInFolder,
} }

View File

@ -1,13 +1,24 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList, SortFn } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
const numPages = 10 interface TagContentOptions {
sort?: SortFn
numPages: number
}
const defaultOptions: TagContentOptions = {
numPages: 10,
}
export default ((opts?: Partial<TagContentOptions>) => {
const options: TagContentOptions = { ...defaultOptions, ...opts }
const TagContent: QuartzComponent = (props: QuartzComponentProps) => { const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
const slug = fileData.slug const slug = fileData.slug
@ -71,16 +82,18 @@ const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
<div class="page-listing"> <div class="page-listing">
<p> <p>
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })} {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
{pages.length > numPages && ( {pages.length > options.numPages && (
<> <>
{" "} {" "}
<span> <span>
{i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })} {i18n(cfg.locale).pages.tagContent.showingFirst({
count: options.numPages,
})}
</span> </span>
</> </>
)} )}
</p> </p>
<PageList limit={numPages} {...listProps} /> <PageList limit={options.numPages} {...listProps} sort={opts?.sort} />
</div> </div>
</div> </div>
) )
@ -110,4 +123,5 @@ const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
} }
TagContent.css = style + PageList.css TagContent.css = style + PageList.css
export default (() => TagContent) satisfies QuartzComponentConstructor return TagContent
}) satisfies QuartzComponentConstructor

View File

@ -14,6 +14,7 @@ interface RenderComponents {
header: QuartzComponent[] header: QuartzComponent[]
beforeBody: QuartzComponent[] beforeBody: QuartzComponent[]
pageBody: QuartzComponent pageBody: QuartzComponent
afterBody: QuartzComponent[]
left: QuartzComponent[] left: QuartzComponent[]
right: QuartzComponent[] right: QuartzComponent[]
footer: QuartzComponent footer: QuartzComponent
@ -28,7 +29,12 @@ export function pageResources(
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return { return {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css], css: [
{
content: joinSegments(baseDir, "index.css"),
},
...staticResources.css,
],
js: [ js: [
{ {
src: joinSegments(baseDir, "prescript.js"), src: joinSegments(baseDir, "prescript.js"),
@ -187,6 +193,7 @@ export function renderPage(
header, header,
beforeBody, beforeBody,
pageBody: Content, pageBody: Content,
afterBody,
left, left,
right, right,
footer: Footer, footer: Footer,
@ -232,10 +239,16 @@ export function renderPage(
</div> </div>
</div> </div>
<Content {...componentData} /> <Content {...componentData} />
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div> </div>
{RightComponent} {RightComponent}
</Body>
<Footer {...componentData} /> <Footer {...componentData} />
</Body>
</div> </div>
</body> </body>
{pageResources.js {pageResources.js

View File

@ -8,7 +8,9 @@ document.addEventListener("nav", () => {
for (let i = 0; i < els.length; i++) { for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0] const codeBlock = els[i].getElementsByTagName("code")[0]
if (codeBlock) { if (codeBlock) {
const source = codeBlock.innerText.replace(/\n\n/g, "\n") const source = (
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
).replace(/\n\n/g, "\n")
const button = document.createElement("button") const button = document.createElement("button")
button.className = "clipboard-button" button.className = "clipboard-button"
button.type = "button" button.type = "button"

View File

@ -0,0 +1,91 @@
const changeTheme = (e: CustomEventMap["themechange"]) => {
const theme = e.detail.theme
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
if (!iframe) {
return
}
if (!iframe.contentWindow) {
return
}
iframe.contentWindow.postMessage(
{
giscus: {
setConfig: {
theme: getThemeUrl(getThemeName(theme)),
},
},
},
"https://giscus.app",
)
}
const getThemeName = (theme: string) => {
if (theme !== "dark" && theme !== "light") {
return theme
}
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return theme
}
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
return theme === "dark" ? darkGiscus : lightGiscus
}
const getThemeUrl = (theme: string) => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return `https://giscus.app/themes/${theme}.css`
}
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
}
type GiscusElement = Omit<HTMLElement, "dataset"> & {
dataset: DOMStringMap & {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: string
themeUrl: string
lightTheme: string
darkTheme: string
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict: string
reactionsEnabled: string
inputPosition: "top" | "bottom"
}
}
document.addEventListener("nav", () => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return
}
const giscusScript = document.createElement("script")
giscusScript.src = "https://giscus.app/client.js"
giscusScript.async = true
giscusScript.crossOrigin = "anonymous"
giscusScript.setAttribute("data-loading", "lazy")
giscusScript.setAttribute("data-emit-metadata", "0")
giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo)
giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId)
giscusScript.setAttribute("data-category", giscusContainer.dataset.category)
giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId)
giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping)
giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict)
giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled)
giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition)
const theme = document.documentElement.getAttribute("saved-theme")
if (theme) {
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
}
giscusContainer.appendChild(giscusScript)
document.addEventListener("themechange", changeTheme)
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
})

View File

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

View File

@ -17,11 +17,14 @@ const observer = new IntersectionObserver((entries) => {
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as MaybeHTMLElement const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {

View File

@ -1,19 +1,56 @@
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex" import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" 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 { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" 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 = { type NodeData = {
id: SimpleSlug id: SimpleSlug
text: string text: string
tags: string[] tags: string[]
} & d3.SimulationNodeDatum } & SimulationNodeDatum
type LinkData = { type SimpleLinkData = {
source: SimpleSlug source: SimpleSlug
target: 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" const localStorageKey = "graph-visited"
function getVisited(): Set<SimpleSlug> { function getVisited(): Set<SimpleSlug> {
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
@ -25,6 +62,11 @@ function addToVisited(slug: SimpleSlug) {
localStorage.setItem(localStorageKey, JSON.stringify([...visited])) localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
} }
type TweenNode = {
update: (time: number) => void
stop: () => void
}
async function renderGraph(container: string, fullSlug: FullSlug) { async function renderGraph(container: string, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
@ -45,7 +87,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags, removeTags,
showTags, showTags,
focusOnHover, focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!) } = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map( const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [ Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
@ -53,10 +95,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
v, v,
]), ]),
) )
const links: LinkData[] = [] const links: SimpleLinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
const tweens = new Map<string, TweenNode>()
for (const [source, details] of data.entries()) { for (const [source, details] of data.entries()) {
const outgoing = details.links ?? [] const outgoing = details.links ?? []
@ -100,246 +143,459 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
} }
const graphData: { nodes: NodeData[]; links: LinkData[] } = { const nodes = [...neighbourhood].map((url) => {
nodes: [...neighbourhood].map((url) => { const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
return { return {
id: url, id: url,
text: text, text,
tags: data.get(url)?.tags ?? [], tags: data.get(url)?.tags ?? [],
} }
}), })
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes,
links: links
.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
.map((l) => ({
source: nodes.find((n) => n.id === l.source)!,
target: nodes.find((n) => n.id === l.target)!,
})),
} }
const simulation: d3.Simulation<NodeData, LinkData> = d3 // we virtualize the simulation and use pixi to actually render it
.forceSimulation(graphData.nodes) const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce)) .force("charge", forceManyBody().strength(-100 * repelForce))
.force( .force("center", forceCenter().strength(centerForce))
"link", .force("link", forceLink(graphData.links).distance(linkDistance))
d3 .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force("center", d3.forceCenter().strength(centerForce))
const height = Math.max(graph.offsetHeight, 250)
const width = graph.offsetWidth const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
const svg = d3 // precompute style prop strings as pixi doesn't support css variables
.select<HTMLElement, NodeData>("#" + container) const cssVars = [
.append("svg") "--secondary",
.attr("width", width) "--tertiary",
.attr("height", height) "--gray",
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]) "--light",
"--lightgray",
// draw links between nodes "--dark",
const link = svg "--darkgray",
.append("g") "--bodyFont",
.selectAll("line") ] as const
.data(graphData.links) const computedStyleMap = cssVars.reduce(
.join("line") (acc, key) => {
.attr("class", "link") acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
.attr("stroke", "var(--lightgray)") return acc
.attr("stroke-width", 1) },
{} as Record<(typeof cssVars)[number], string>,
// svg groups )
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
// calculate color // calculate color
const color = (d: NodeData) => { const color = (d: NodeData) => {
const isCurrent = d.id === slug const isCurrent = d.id === slug
if (isCurrent) { if (isCurrent) {
return "var(--secondary)" return computedStyleMap["--secondary"]
} else if (visited.has(d.id) || d.id.startsWith("tags/")) { } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)" return computedStyleMap["--tertiary"]
} else { } 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) { 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) return 2 + Math.sqrt(numLinks)
} }
let connectedNodes: SimpleSlug[] = [] let hoveredNodeId: string | null = null
let hoveredNeighbours: Set<string> = new Set()
const linkRenderData: LinkRenderData[] = []
const nodeRenderData: NodeRenderData[] = []
function updateHoverInfo(newHoveredId: string | null) {
hoveredNodeId = newHoveredId
// draw individual nodes if (newHoveredId === null) {
const node = graphNode hoveredNeighbours = new Set()
.append("circle") for (const n of nodeRenderData) {
.attr("class", "node") n.active = false
.attr("id", (d) => d.id) }
.attr("r", nodeRadius)
.attr("fill", color) for (const l of linkRenderData) {
.style("cursor", "pointer") l.active = false
.on("click", (_, d) => { }
const targ = resolveRelative(fullSlug, d.id) } else {
hoveredNeighbours = new Set()
for (const l of linkRenderData) {
const linkData = l.simulationData
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
hoveredNeighbours.add(linkData.source.id)
hoveredNeighbours.add(linkData.target.id)
}
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
}
for (const n of nodeRenderData) {
n.active = hoveredNeighbours.has(n.simulationData.id)
}
}
}
let dragStartTime = 0
let dragging = false
function renderLinks() {
tweens.get("link")?.stop()
const tweenGroup = new TweenGroup()
for (const l of linkRenderData) {
let alpha = 1
// if we are hovering over a node, we want to highlight the immediate neighbours
// with full alpha and the rest with default alpha
if (hoveredNodeId) {
alpha = l.active ? 1 : 0.2
}
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("link", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
}
function renderLabels() {
tweens.get("label")?.stop()
const tweenGroup = new TweenGroup()
const defaultScale = 1 / scale
const activeScale = defaultScale * 1.1
for (const n of nodeRenderData) {
const nodeId = n.simulationData.id
if (hoveredNodeId === nodeId) {
tweenGroup.add(
new Tweened<Text>(n.label).to(
{
alpha: 1,
scale: { x: activeScale, y: activeScale },
},
100,
),
)
} else {
tweenGroup.add(
new Tweened<Text>(n.label).to(
{
alpha: n.label.alpha,
scale: { x: defaultScale, y: defaultScale },
},
100,
),
)
}
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("label", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
}
function renderNodes() {
tweens.get("hover")?.stop()
const tweenGroup = new TweenGroup()
for (const n of nodeRenderData) {
let alpha = 1
// if we are hovering over a node, we want to highlight the immediate neighbours
if (hoveredNodeId !== null && focusOnHover) {
alpha = n.active ? 1 : 0.2
}
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("hover", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
}
function renderPixiFromD3() {
renderNodes()
renderLinks()
renderLabels()
}
tweens.forEach((tween) => tween.stop())
tweens.clear()
const app = new Application()
await app.init({
width,
height,
antialias: true,
autoStart: false,
autoDensity: true,
backgroundAlpha: 0,
preference: "webgpu",
resolution: window.devicePixelRatio,
eventMode: "static",
})
graph.appendChild(app.canvas)
const stage = app.stage
stage.interactive = false
const labelsContainer = new Container<Text>({ zIndex: 3 })
const nodesContainer = new Container<Graphics>({ zIndex: 2 })
const linkContainer = new Container<Graphics>({ zIndex: 1 })
stage.addChild(nodesContainer, labelsContainer, linkContainer)
for (const n of graphData.nodes) {
const nodeId = n.id
const label = new Text({
interactive: false,
eventMode: "none",
text: n.text,
alpha: 0,
anchor: { x: 0.5, y: 1.2 },
style: {
fontSize: fontSize * 15,
fill: computedStyleMap["--dark"],
fontFamily: computedStyleMap["--bodyFont"],
},
resolution: window.devicePixelRatio * 4,
})
label.scale.set(1 / scale)
let oldLabelOpacity = 0
const isTagNode = nodeId.startsWith("tags/")
const gfx = new Graphics({
interactive: true,
label: nodeId,
eventMode: "static",
hitArea: new Circle(0, 0, nodeRadius(n)),
cursor: "pointer",
})
.circle(0, 0, nodeRadius(n))
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
.stroke({ width: isTagNode ? 2 : 0, color: color(n) })
.on("pointerover", (e) => {
updateHoverInfo(e.target.label)
oldLabelOpacity = label.alpha
if (!dragging) {
renderPixiFromD3()
}
})
.on("pointerleave", () => {
updateHoverInfo(null)
label.alpha = oldLabelOpacity
if (!dragging) {
renderPixiFromD3()
}
})
nodesContainer.addChild(gfx)
labelsContainer.addChild(label)
const nodeRenderDatum: NodeRenderData = {
simulationData: n,
gfx,
label,
color: color(n),
alpha: 1,
active: false,
}
nodeRenderData.push(nodeRenderDatum)
}
for (const l of graphData.links) {
const gfx = new Graphics({ interactive: false, eventMode: "none" })
linkContainer.addChild(gfx)
const linkRenderDatum: LinkRenderData = {
simulationData: l,
gfx,
color: computedStyleMap["--lightgray"],
alpha: 1,
active: false,
}
linkRenderData.push(linkRenderDatum)
}
let currentTransform = zoomIdentity
if (enableDrag) {
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
drag<HTMLCanvasElement, NodeData | undefined>()
.container(() => app.canvas)
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
.on("start", function dragstarted(event) {
if (!event.active) simulation.alphaTarget(1).restart()
event.subject.fx = event.subject.x
event.subject.fy = event.subject.y
event.subject.__initialDragPos = {
x: event.subject.x,
y: event.subject.y,
fx: event.subject.fx,
fy: event.subject.fy,
}
dragStartTime = Date.now()
dragging = true
})
.on("drag", function dragged(event) {
const initPos = event.subject.__initialDragPos
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
})
.on("end", function dragended(event) {
if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null
event.subject.fy = null
dragging = false
// if the time between mousedown and mouseup is short, we consider it a click
if (Date.now() - dragStartTime < 500) {
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
const targ = resolveRelative(fullSlug, node.id)
window.spaNavigate(new URL(targ, window.location.toString()))
}
}),
)
} else {
for (const node of nodeRenderData) {
node.gfx.on("click", () => {
const targ = resolveRelative(fullSlug, node.simulationData.id)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
}) })
.on("mouseover", function (_, d) { }
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
if (focusOnHover) {
// fade out non-neighbour nodes
connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id])
d3.selectAll<HTMLElement, NodeData>(".link")
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.transition()
.duration(200)
.style("opacity", 0.2)
} }
// highlight links
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
const bigFont = fontSize * 1.5
// 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")
})
.on("mouseleave", function (_, d) {
if (focusOnHover) {
d3.selectAll<HTMLElement, NodeData>(".link").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".node").transition().duration(200).style("opacity", 1)
}
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)")
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")
})
// @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))
// set panning
if (enableZoom) { if (enableZoom) {
svg.call( select<HTMLCanvasElement, NodeData>(app.canvas).call(
d3 zoom<HTMLCanvasElement, NodeData>()
.zoom<SVGSVGElement, NodeData>()
.extent([ .extent([
[0, 0], [0, 0],
[width, height], [width, height],
]) ])
.scaleExtent([0.25, 4]) .scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => { .on("zoom", ({ transform }) => {
link.attr("transform", transform) currentTransform = transform
node.attr("transform", 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 scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0) let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity) 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 function animate(time: number) {
simulation.on("tick", () => { for (const n of nodeRenderData) {
link const { x, y } = n.simulationData
.attr("x1", (d: any) => d.source.x) if (!x || !y) continue
.attr("y1", (d: any) => d.source.y) n.gfx.position.set(x + width / 2, y + height / 2)
.attr("x2", (d: any) => d.target.x) if (n.label) {
.attr("y2", (d: any) => d.target.y) n.label.position.set(x + width / 2, y + height / 2)
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)
})
} }
}
for (const l of linkRenderData) {
const linkData = l.simulationData
l.gfx.clear()
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
l.gfx
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
.stroke({ alpha: l.alpha, width: 1, color: l.color })
}
tweens.forEach((t) => t.update(time))
app.renderer.render(stage)
requestAnimationFrame(animate)
}
const graphAnimationFrameHandle = requestAnimationFrame(animate)
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url
addToVisited(simplifySlug(slug))
await renderGraph("graph-container", slug)
// Function to re-render the graph when the theme changes
const handleThemeChange = () => {
renderGraph("graph-container", slug)
}
// event listener for theme change
document.addEventListener("themechange", handleThemeChange)
// cleanup for the event listener
window.addCleanup(() => {
document.removeEventListener("themechange", handleThemeChange)
})
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
function renderGlobalGraph() { function renderGlobalGraph() {
const slug = getFullSlug(window) const slug = getFullSlug(window)
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
container?.classList.add("active") container?.classList.add("active")
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "1" sidebar.style.zIndex = "1"
} }
renderGraph("global-graph-container", slug) renderGraph("global-graph-container", slug)
function hideGlobalGraph() {
container?.classList.remove("active")
const graph = document.getElementById("global-graph-container")
if (sidebar) {
sidebar.style.zIndex = "unset"
}
if (!graph) return
removeAllChildren(graph)
}
registerEscapeHandler(container, hideGlobalGraph) registerEscapeHandler(container, hideGlobalGraph)
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { function hideGlobalGraph() {
const slug = e.detail.url container?.classList.remove("active")
addToVisited(slug) if (sidebar) {
await renderGraph("graph-container", slug) sidebar.style.zIndex = ""
}
}
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const globalGraphOpen = container?.classList.contains("active")
globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
}
}
const containerIcon = document.getElementById("global-graph-icon") const containerIcon = document.getElementById("global-graph-icon")
containerIcon?.addEventListener("click", renderGlobalGraph) containerIcon?.addEventListener("click", renderGlobalGraph)
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
}) })

View File

@ -0,0 +1,242 @@
import { removeAllChildren } from "./util"
import mermaid from "mermaid"
interface Position {
x: number
y: number
}
class DiagramPanZoom {
private isDragging = false
private startPan: Position = { x: 0, y: 0 }
private currentPan: Position = { x: 0, y: 0 }
private scale = 1
private readonly MIN_SCALE = 0.5
private readonly MAX_SCALE = 3
private readonly ZOOM_SENSITIVITY = 0.001
constructor(
private container: HTMLElement,
private content: HTMLElement,
) {
this.setupEventListeners()
this.setupNavigationControls()
}
private setupEventListeners() {
// Mouse drag events
this.container.addEventListener("mousedown", this.onMouseDown.bind(this))
document.addEventListener("mousemove", this.onMouseMove.bind(this))
document.addEventListener("mouseup", this.onMouseUp.bind(this))
// Wheel zoom events
this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false })
// Reset on window resize
window.addEventListener("resize", this.resetTransform.bind(this))
}
private setupNavigationControls() {
const controls = document.createElement("div")
controls.className = "mermaid-controls"
// Zoom controls
const zoomIn = this.createButton("+", () => this.zoom(0.1))
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
const resetBtn = this.createButton("Reset", () => this.resetTransform())
controls.appendChild(zoomOut)
controls.appendChild(resetBtn)
controls.appendChild(zoomIn)
this.container.appendChild(controls)
}
private createButton(text: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button")
button.textContent = text
button.className = "mermaid-control-button"
button.addEventListener("click", onClick)
window.addCleanup(() => button.removeEventListener("click", onClick))
return button
}
private onMouseDown(e: MouseEvent) {
if (e.button !== 0) return // Only handle left click
this.isDragging = true
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
this.container.style.cursor = "grabbing"
}
private onMouseMove(e: MouseEvent) {
if (!this.isDragging) return
e.preventDefault()
this.currentPan = {
x: e.clientX - this.startPan.x,
y: e.clientY - this.startPan.y,
}
this.updateTransform()
}
private onMouseUp() {
this.isDragging = false
this.container.style.cursor = "grab"
}
private onWheel(e: WheelEvent) {
e.preventDefault()
const delta = -e.deltaY * this.ZOOM_SENSITIVITY
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
// Calculate mouse position relative to content
const rect = this.content.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
// Adjust pan to zoom around mouse position
const scaleDiff = newScale - this.scale
this.currentPan.x -= mouseX * scaleDiff
this.currentPan.y -= mouseY * scaleDiff
this.scale = newScale
this.updateTransform()
}
private zoom(delta: number) {
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
// Zoom around center
const rect = this.content.getBoundingClientRect()
const centerX = rect.width / 2
const centerY = rect.height / 2
const scaleDiff = newScale - this.scale
this.currentPan.x -= centerX * scaleDiff
this.currentPan.y -= centerY * scaleDiff
this.scale = newScale
this.updateTransform()
}
private updateTransform() {
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
}
private resetTransform() {
this.scale = 1
this.currentPan = { x: 0, y: 0 }
this.updateTransform()
}
}
const cssVars = [
"--secondary",
"--tertiary",
"--gray",
"--light",
"--lightgray",
"--highlight",
"--dark",
"--darkgray",
"--codeFont",
] as const
document.addEventListener("nav", async () => {
const center = document.querySelector(".center") as HTMLElement
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
if (nodes.length === 0) return
const computedStyleMap = cssVars.reduce(
(acc, key) => {
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
return acc
},
{} as Record<(typeof cssVars)[number], string>,
)
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: darkMode ? "dark" : "base",
themeVariables: {
fontFamily: computedStyleMap["--codeFont"],
primaryColor: computedStyleMap["--light"],
primaryTextColor: computedStyleMap["--darkgray"],
primaryBorderColor: computedStyleMap["--tertiary"],
lineColor: computedStyleMap["--darkgray"],
secondaryColor: computedStyleMap["--secondary"],
tertiaryColor: computedStyleMap["--tertiary"],
clusterBkg: computedStyleMap["--light"],
edgeLabelBackground: computedStyleMap["--highlight"],
},
})
await mermaid.run({ nodes })
for (let i = 0; i < nodes.length; i++) {
const codeBlock = nodes[i] as HTMLElement
const pre = codeBlock.parentElement as HTMLPreElement
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
const clipboardStyle = window.getComputedStyle(clipboardBtn)
const clipboardWidth =
clipboardBtn.offsetWidth +
parseFloat(clipboardStyle.marginLeft || "0") +
parseFloat(clipboardStyle.marginRight || "0")
// Set expand button position
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
pre.prepend(expandBtn)
// query popup container
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
if (!popupContainer) return
let panZoom: DiagramPanZoom | null = null
function showMermaid() {
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
if (!content) return
removeAllChildren(content)
// Clone the mermaid content
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
content.appendChild(mermaidContent)
// Show container
popupContainer.classList.add("active")
container.style.cursor = "grab"
// Initialize pan-zoom after showing the popup
panZoom = new DiagramPanZoom(container, content)
}
function hideMermaid() {
popupContainer.classList.remove("active")
panZoom = null
}
function handleEscape(e: any) {
if (e.key === "Escape") {
hideMermaid()
}
}
const closeBtn = popupContainer.querySelector(".close-button") as HTMLButtonElement
closeBtn.addEventListener("click", hideMermaid)
expandBtn.addEventListener("click", showMermaid)
document.addEventListener("keydown", handleEscape)
window.addCleanup(() => {
closeBtn.removeEventListener("click", hideMermaid)
expandBtn.removeEventListener("click", showMermaid)
document.removeEventListener("keydown", handleEscape)
})
}
})

View File

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

View File

@ -148,7 +148,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const data = await fetchData const data = await fetchData
const container = document.getElementById("search-container") const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement 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 searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const searchLayout = document.getElementById("search-layout") const searchLayout = document.getElementById("search-layout")
const idDataMap = Object.keys(data) as FullSlug[] const idDataMap = Object.keys(data) as FullSlug[]
@ -178,7 +178,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
searchBar.value = "" // clear the input when we dismiss the search searchBar.value = "" // clear the input when we dismiss the search
} }
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "unset" sidebar.style.zIndex = ""
} }
if (results) { if (results) {
removeAllChildren(results) removeAllChildren(results)
@ -191,6 +191,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
} }
searchType = "basic" // reset search type after closing searchType = "basic" // reset search type after closing
searchButton?.focus()
} }
function showSearch(searchTypeNew: SearchType) { function showSearch(searchTypeNew: SearchType) {
@ -458,8 +460,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.addEventListener("click", () => showSearch("basic")) searchButton?.addEventListener("click", () => showSearch("basic"))
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) window.addCleanup(() => searchButton?.removeEventListener("click", () => showSearch("basic")))
searchBar?.addEventListener("input", onType) searchBar?.addEventListener("input", onType)
window.addCleanup(() => searchBar?.removeEventListener("input", onType)) window.addCleanup(() => searchBar?.removeEventListener("input", onType))

View File

@ -16,10 +16,13 @@ const observer = new IntersectionObserver((entries) => {
function toggleToc(this: HTMLElement) { function toggleToc(this: HTMLElement) {
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as HTMLElement | undefined const content = this.nextElementSibling as HTMLElement | undefined
if (!content) return if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
} }
function setupToc() { function setupToc() {
@ -28,7 +31,6 @@ function setupToc() {
const collapsed = toc.classList.contains("collapsed") const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement | undefined const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return if (!content) return
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.addEventListener("click", toggleToc) toc.addEventListener("click", toggleToc)
window.addCleanup(() => toc.removeEventListener("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"]) { function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
if (e.target !== this) return if (e.target !== this) return
e.preventDefault() e.preventDefault()
e.stopPropagation()
cb() cb()
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -11,9 +11,9 @@ li.section-li {
& > .section { & > .section {
display: grid; 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 { & > .tags {
display: none; display: none;
} }
@ -23,9 +23,8 @@ li.section-li {
background-color: transparent; background-color: transparent;
} }
& > .meta { & .meta {
margin: 0; margin: 0 1em 0 0;
flex-basis: 6em;
opacity: 0.6; opacity: 0.6;
} }
} }
@ -33,7 +32,8 @@ li.section-li {
// modifications in popover context // modifications in popover context
.popover .section { .popover .section {
grid-template-columns: 6em 1fr !important; grid-template-columns: fit-content(8em) 1fr !important;
& > .tags { & > .tags {
display: none; display: none;
} }

View File

@ -0,0 +1,163 @@
.expand-button {
position: absolute;
display: flex;
float: right;
padding: 0.4rem;
margin: 0.3rem;
right: 0; // NOTE: right will be set in mermaid.inline.ts
color: var(--gray);
border-color: var(--dark);
background-color: var(--light);
border: 1px solid;
border-radius: 5px;
opacity: 0;
transition: 0.2s;
& > svg {
fill: var(--light);
filter: contrast(0.3);
}
&:hover {
cursor: pointer;
border-color: var(--secondary);
}
&:focus {
outline: 0;
}
}
pre {
&:hover > .expand-button {
opacity: 1;
transition: 0.2s;
}
}
#mermaid-container {
position: fixed;
contain: layout;
z-index: 999;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
display: none;
backdrop-filter: blur(4px);
background: rgba(0, 0, 0, 0.5);
&.active {
display: inline-block;
}
& > #mermaid-space {
display: grid;
width: 90%;
height: 90vh;
margin: 5vh auto;
background: var(--light);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
0 10px 30px rgba(27, 33, 48, 0.16);
overflow: hidden;
position: relative;
& > .mermaid-header {
display: flex;
justify-content: flex-end;
padding: 1rem;
border-bottom: 1px solid var(--lightgray);
background: var(--light);
z-index: 2;
max-height: fit-content;
& > .close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: var(--darkgray);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--lightgray);
color: var(--dark);
}
}
}
& > .mermaid-content {
padding: 2rem;
position: relative;
transform-origin: 0 0;
transition: transform 0.1s ease;
overflow: visible;
min-height: 200px;
min-width: 200px;
pre {
margin: 0;
border: none;
}
svg {
max-width: none;
height: auto;
}
}
& > .mermaid-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
padding: 8px;
background: var(--light);
border: 1px solid var(--lightgray);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 2;
.mermaid-control-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--lightgray);
background: var(--light);
color: var(--dark);
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-family: var(--bodyFont);
transition: all 0.2s ease;
&:hover {
background: var(--lightgray);
}
&:active {
transform: translateY(1px);
}
// Style the reset button differently
&:nth-child(2) {
width: auto;
padding: 0 12px;
font-size: 14px;
}
}
}
}
}

View File

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

View File

@ -3,20 +3,25 @@
.search { .search {
min-width: fit-content; min-width: fit-content;
max-width: 14rem; max-width: 14rem;
@media all and ($mobile) {
flex-grow: 0.3; flex-grow: 0.3;
}
& > #search-icon { & > .search-button {
background-color: var(--lightgray); background-color: var(--lightgray);
border: none;
border-radius: 4px; border-radius: 4px;
font-family: inherit;
font-size: inherit;
height: 2rem; height: 2rem;
padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
text-align: inherit;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
width: 100%;
& > div { justify-content: space-between;
flex-grow: 1;
}
& > p { & > p {
display: inline; display: inline;
@ -59,7 +64,7 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@media all and (max-width: $fullPageWidth) { @media all and not ($desktop) {
width: 90%; width: 90%;
} }
@ -101,7 +106,7 @@
flex: 0 0 min(30%, 450px); flex: 0 0 min(30%, 450px);
} }
@media all and (min-width: $tabletBreakpoint) { @media all and not ($tablet) {
&[data-preview] { &[data-preview] {
& .result-card > p.preview { & .result-card > p.preview {
display: none; display: none;
@ -127,7 +132,7 @@
border-radius: 5px; border-radius: 5px;
} }
@media all and (max-width: $tabletBreakpoint) { @media all and ($tablet) {
& > #preview-container { & > #preview-container {
display: none !important; display: none !important;
} }

View File

@ -1,3 +1,20 @@
@use "../../styles/variables.scss" as *;
.toc {
display: flex;
flex-direction: column;
&.desktop-only {
max-height: 40%;
}
}
@media all and not ($mobile) {
.toc {
display: flex;
}
}
button#toc { button#toc {
background-color: transparent; background-color: transparent;
border: none; border: none;
@ -28,9 +45,21 @@ button#toc {
#toc-content { #toc-content {
list-style: none; list-style: none;
overflow: hidden; overflow: hidden;
max-height: none; overflow-y: auto;
transition: max-height 0.5s ease; max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
position: relative; position: relative;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
&.collapsed > .overflow::after { &.collapsed > .overflow::after {
opacity: 0; opacity: 0;
@ -51,6 +80,10 @@ button#toc {
} }
} }
} }
> ul.overflow {
max-height: none;
width: 100%;
}
@for $i from 0 through 6 { @for $i from 0 through 6 {
& .depth-#{$i} { & .depth-#{$i} {

View File

@ -1,11 +1,13 @@
import { Translation, CalloutTranslation } from "./locales/definition" 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 fr from "./locales/fr-FR"
import it from "./locales/it-IT" import it from "./locales/it-IT"
import ja from "./locales/ja-JP" import ja from "./locales/ja-JP"
import de from "./locales/de-DE" import de from "./locales/de-DE"
import nl from "./locales/nl-NL" import nl from "./locales/nl-NL"
import ro from "./locales/ro-RO" import ro from "./locales/ro-RO"
import ca from "./locales/ca-ES"
import es from "./locales/es-ES" import es from "./locales/es-ES"
import ar from "./locales/ar-SA" import ar from "./locales/ar-SA"
import uk from "./locales/uk-UA" import uk from "./locales/uk-UA"
@ -17,9 +19,12 @@ import pt from "./locales/pt-BR"
import hu from "./locales/hu-HU" import hu from "./locales/hu-HU"
import fa from "./locales/fa-IR" import fa from "./locales/fa-IR"
import pl from "./locales/pl-PL" import pl from "./locales/pl-PL"
import cs from "./locales/cs-CZ"
import tr from "./locales/tr-TR"
export const TRANSLATIONS = { export const TRANSLATIONS = {
"en-US": en, "en-US": enUs,
"en-GB": enGb,
"fr-FR": fr, "fr-FR": fr,
"it-IT": it, "it-IT": it,
"ja-JP": ja, "ja-JP": ja,
@ -28,6 +33,7 @@ export const TRANSLATIONS = {
"nl-BE": nl, "nl-BE": nl,
"ro-RO": ro, "ro-RO": ro,
"ro-MD": ro, "ro-MD": ro,
"ca-ES": ca,
"es-ES": es, "es-ES": es,
"ar-SA": ar, "ar-SA": ar,
"ar-AE": ar, "ar-AE": ar,
@ -58,6 +64,8 @@ export const TRANSLATIONS = {
"hu-HU": hu, "hu-HU": hu,
"fa-IR": fa, "fa-IR": fa,
"pl-PL": pl, "pl-PL": pl,
"cs-CZ": cs,
"tr-TR": tr,
} as const } as const
export const defaultTranslation = "en-US" export const defaultTranslation = "en-US"

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

View File

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Bez názvu",
description: "Nebyl uveden žádný popis",
},
components: {
callout: {
note: "Poznámka",
abstract: "Abstract",
info: "Info",
todo: "Todo",
tip: "Tip",
success: "Úspěch",
question: "Otázka",
warning: "Upozornění",
failure: "Chyba",
danger: "Nebezpečí",
bug: "Bug",
example: "Příklad",
quote: "Citace",
},
backlinks: {
title: "Příchozí odkazy",
noBacklinksFound: "Nenalezeny žádné příchozí odkazy",
},
themeToggle: {
lightMode: "Světlý režim",
darkMode: "Tmavý režim",
},
explorer: {
title: "Procházet",
},
footer: {
createdWith: "Vytvořeno pomocí",
},
graph: {
title: "Graf",
},
recentNotes: {
title: "Nejnovější poznámky",
seeRemainingMore: ({ remaining }) => `Zobraz ${remaining} dalších →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Zobrazení ${targetSlug}`,
linkToOriginal: "Odkaz na původní dokument",
},
search: {
title: "Hledat",
searchBarPlaceholder: "Hledejte něco",
},
tableOfContents: {
title: "Obsah",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min čtení`,
},
},
pages: {
rss: {
recentNotes: "Nejnovější poznámky",
lastFewNotes: ({ count }) => `Posledních ${count} poznámek`,
},
error: {
title: "Nenalezeno",
notFound: "Tato stránka je buď soukromá, nebo neexistuje.",
home: "Návrat na domovskou stránku",
},
folderContent: {
folder: "Složka",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 položka v této složce." : `${count} položek v této složce.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Rejstřík tagů",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 položka s tímto tagem." : `${count} položek s tímto tagem.`,
showingFirst: ({ count }) => `Zobrazují se první ${count} tagy.`,
totalTags: ({ count }) => `Nalezeno celkem ${count} tagů.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Untitled",
description: "No description provided",
},
components: {
callout: {
note: "Note",
abstract: "Abstract",
info: "Info",
todo: "To-Do",
tip: "Tip",
success: "Success",
question: "Question",
warning: "Warning",
failure: "Failure",
danger: "Danger",
bug: "Bug",
example: "Example",
quote: "Quote",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "No backlinks found",
},
themeToggle: {
lightMode: "Light mode",
darkMode: "Dark mode",
},
explorer: {
title: "Explorer",
},
footer: {
createdWith: "Created with",
},
graph: {
title: "Graph View",
},
recentNotes: {
title: "Recent Notes",
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
linkToOriginal: "Link to original",
},
search: {
title: "Search",
searchBarPlaceholder: "Search for something",
},
tableOfContents: {
title: "Table of Contents",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Recent notes",
lastFewNotes: ({ count }) => `Last ${count} notes`,
},
error: {
title: "Not Found",
notFound: "Either this page is private or doesn't exist.",
home: "Return to Homepage",
},
folderContent: {
folder: "Folder",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 item under this folder." : `${count} items under this folder.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Tag Index",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 item with this tag." : `${count} items with this tag.`,
showingFirst: ({ count }) => `Showing first ${count} tags.`,
totalTags: ({ count }) => `Found ${count} total tags.`,
},
},
} as const satisfies Translation

View File

@ -22,8 +22,8 @@ export default {
quote: "Cita", quote: "Cita",
}, },
backlinks: { backlinks: {
title: "Enlaces de Retroceso", title: "Retroenlaces",
noBacklinksFound: "No se han encontrado enlaces traseros", noBacklinksFound: "No se han encontrado retroenlaces",
}, },
themeToggle: { themeToggle: {
lightMode: "Modo claro", lightMode: "Modo claro",
@ -54,18 +54,18 @@ export default {
title: "Tabla de Contenidos", title: "Tabla de Contenidos",
}, },
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, readingTime: ({ minutes }) => `Se lee en ${minutes} min`,
}, },
}, },
pages: { pages: {
rss: { rss: {
recentNotes: "Notas recientes", recentNotes: "Notas recientes",
lastFewNotes: ({ count }) => `Últimás ${count} notas`, lastFewNotes: ({ count }) => `Últimas ${count} notas`,
}, },
error: { error: {
title: "No se encontró.", title: "No se ha encontrado.",
notFound: "Esta página es privada o no existe.", notFound: "Esta página es privada o no existe.",
home: "Regresar a la página principal", home: "Regresa a la página principal",
}, },
folderContent: { folderContent: {
folder: "Carpeta", folder: "Carpeta",
@ -78,7 +78,7 @@ export default {
itemsUnderTag: ({ count }) => itemsUnderTag: ({ count }) =>
count === 1 ? "1 artículo con esta etiqueta." : `${count} artículos con esta etiqueta.`, count === 1 ? "1 artículo con esta etiqueta." : `${count} artículos con esta etiqueta.`,
showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`, showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`, totalTags: ({ count }) => `Se han encontrado ${count} etiquetas en total.`,
}, },
}, },
} as const satisfies Translation } as const satisfies Translation

View File

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "İsimsiz",
description: "Herhangi bir açıklama eklenmedi",
},
components: {
callout: {
note: "Not",
abstract: "Özet",
info: "Bilgi",
todo: "Yapılacaklar",
tip: "İpucu",
success: "Başarılı",
question: "Soru",
warning: "Uyarı",
failure: "Başarısız",
danger: "Tehlike",
bug: "Hata",
example: "Örnek",
quote: "Alıntı",
},
backlinks: {
title: "Backlinkler",
noBacklinksFound: "Backlink bulunamadı",
},
themeToggle: {
lightMode: "Açık mod",
darkMode: "Koyu mod",
},
explorer: {
title: "Gezgin",
},
footer: {
createdWith: "Şununla oluşturuldu",
},
graph: {
title: "Grafik Görünümü",
},
recentNotes: {
title: "Son Notlar",
seeRemainingMore: ({ remaining }) => `${remaining} tane daha gör →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} sayfasından alıntı`,
linkToOriginal: "Orijinal bağlantı",
},
search: {
title: "Arama",
searchBarPlaceholder: "Bir şey arayın",
},
tableOfContents: {
title: "İçindekiler",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`,
},
},
pages: {
rss: {
recentNotes: "Son notlar",
lastFewNotes: ({ count }) => `Son ${count} not`,
},
error: {
title: "Bulunamadı",
notFound: "Bu sayfa ya özel ya da mevcut değil.",
home: "Anasayfaya geri dön",
},
folderContent: {
folder: "Klasör",
itemsUnderFolder: ({ count }) =>
count === 1 ? "Bu klasör altında 1 öğe." : `Bu klasör altındaki ${count} öğe.`,
},
tagContent: {
tag: "Etiket",
tagIndex: "Etiket Sırası",
itemsUnderTag: ({ count }) =>
count === 1 ? "Bu etikete sahip 1 öğe." : `Bu etiket altındaki ${count} öğe.`,
showingFirst: ({ count }) => `İlk ${count} etiket gösteriliyor.`,
totalTags: ({ count }) => `Toplam ${count} adet etiket bulundu.`,
},
},
} as const satisfies Translation

View File

@ -54,7 +54,7 @@ export default {
title: "Зміст", title: "Зміст",
}, },
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, readingTime: ({ minutes }) => `${minutes} хв читання`,
}, },
}, },
pages: { pages: {
@ -68,17 +68,17 @@ export default {
home: "Повернутися на головну сторінку", home: "Повернутися на головну сторінку",
}, },
folderContent: { folderContent: {
folder: "Папка", folder: "Тека",
itemsUnderFolder: ({ count }) => itemsUnderFolder: ({ count }) =>
count === 1 ? "У цій папці 1 елемент." : `Елементів у цій папці: ${count}.`, count === 1 ? "У цій теці 1 елемент." : `Елементів у цій теці: ${count}.`,
}, },
tagContent: { tagContent: {
tag: "Тег", tag: "Мітка",
tagIndex: "Індекс тегу", tagIndex: "Індекс мітки",
itemsUnderTag: ({ count }) => itemsUnderTag: ({ count }) =>
count === 1 ? "1 елемент з цим тегом." : `Елементів з цим тегом: ${count}.`, count === 1 ? "1 елемент з цією міткою." : `Елементів з цією міткою: ${count}.`,
showingFirst: ({ count }) => `Показ перших ${count} тегів.`, showingFirst: ({ count }) => `Показ перших ${count} міток.`,
totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`, totalTags: ({ count }) => `Всього знайдено міток: ${count}.`,
}, },
}, },
} as const satisfies Translation } as const satisfies Translation

View File

@ -144,6 +144,23 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
tinylyticsScript.defer = true tinylyticsScript.defer = true
document.head.appendChild(tinylyticsScript) document.head.appendChild(tinylyticsScript)
`) `)
} else if (cfg.analytics?.provider === "cabin") {
componentResources.afterDOMLoaded.push(`
const cabinScript = document.createElement("script")
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
cabinScript.defer = true
cabinScript.async = true
document.head.appendChild(cabinScript)
`)
} else if (cfg.analytics?.provider === "clarity") {
componentResources.afterDOMLoaded.push(`
const clarityScript = document.createElement("script")
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
document.head.appendChild(clarityScript)
`)
} }
if (cfg.enableSPA) { if (cfg.enableSPA) {

View File

@ -59,14 +59,25 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
...userOpts, ...userOpts,
} }
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor() const Header = HeaderConstructor()
const Body = BodyConstructor() const Body = BodyConstructor()
return { return {
name: "ContentPage", name: "ContentPage",
getQuartzComponents() { getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
}, },
async getDependencyGraph(ctx, content, _resources) { async getDependencyGraph(ctx, content, _resources) {
const graph = new DepGraph<FilePath>() const graph = new DepGraph<FilePath>()

View File

@ -3,7 +3,7 @@ import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header" import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import path from "path" import path from "path"
import { import {
@ -21,22 +21,37 @@ import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import DepGraph from "../../depgraph" import DepGraph from "../../depgraph"
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { interface FolderPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
}
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
pageBody: FolderContent(), pageBody: FolderContent({ sort: userOpts?.sort }),
...userOpts, ...userOpts,
} }
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor() const Header = HeaderConstructor()
const Body = BodyConstructor() const Body = BodyConstructor()
return { return {
name: "FolderPage", name: "FolderPage",
getQuartzComponents() { getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
}, },
async getDependencyGraph(_ctx, content, _resources) { async getDependencyGraph(_ctx, content, _resources) {
// Example graph: // Example graph:
@ -61,12 +76,11 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
const folders: Set<SimpleSlug> = new Set( const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => { allFiles.flatMap((data) => {
const slug = data.slug return data.slug
const folderName = path.dirname(slug ?? "") as SimpleSlug ? _getFolders(data.slug).filter(
if (slug && folderName !== "." && folderName !== "tags") { (folderName) => folderName !== "." && folderName !== "tags",
return [folderName] )
} : []
return []
}), }),
) )
@ -118,3 +132,14 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
}, },
} }
} }
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}

View File

@ -3,7 +3,7 @@ import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header" import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { import {
FilePath, FilePath,
@ -18,22 +18,37 @@ import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import DepGraph from "../../depgraph" import DepGraph from "../../depgraph"
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { interface TagPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
}
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
pageBody: TagContent(), pageBody: TagContent({ sort: userOpts?.sort }),
...userOpts, ...userOpts,
} }
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor() const Header = HeaderConstructor()
const Body = BodyConstructor() const Body = BodyConstructor()
return { return {
name: "TagPage", name: "TagPage",
getQuartzComponents() { getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
}, },
async getDependencyGraph(ctx, content, _resources) { async getDependencyGraph(ctx, content, _resources) {
const graph = new DepGraph<FilePath>() const graph = new DepGraph<FilePath>()

View File

@ -3,7 +3,8 @@ import { QuartzFilterPlugin } from "../types"
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({ export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
name: "RemoveDrafts", name: "RemoveDrafts",
shouldPublish(_ctx, [_tree, vfile]) { shouldPublish(_ctx, [_tree, vfile]) {
const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false const draftFlag: boolean =
vfile.data?.frontmatter?.draft === true || vfile.data?.frontmatter?.draft === "true"
return !draftFlag return !draftFlag
}, },
}) })

View File

@ -3,6 +3,6 @@ import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({ export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish", name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) { shouldPublish(_ctx, [_tree, vfile]) {
return vfile.data?.frontmatter?.publish ?? false return vfile.data?.frontmatter?.publish === true || vfile.data?.frontmatter?.publish === "true"
}, },
}) })

View File

@ -17,11 +17,11 @@ const defaultOptions: Options = {
csl: "apa", csl: "apa",
} }
export const Citations: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "Citations", name: "Citations",
htmlPlugins() { htmlPlugins(ctx) {
const plugins: PluggableList = [] const plugins: PluggableList = []
// Add rehype-citation to the list of plugins // Add rehype-citation to the list of plugins
@ -31,6 +31,8 @@ export const Citations: QuartzTransformerPlugin<Partial<Options> | undefined> =
bibliography: opts.bibliographyFile, bibliography: opts.bibliographyFile,
suppressBibliography: opts.suppressBibliography, suppressBibliography: opts.suppressBibliography,
linkCitations: opts.linkCitations, linkCitations: opts.linkCitations,
csl: opts.csl,
lang: ctx.cfg.configuration.locale ?? "en-US",
}, },
]) ])
@ -38,7 +40,7 @@ export const Citations: QuartzTransformerPlugin<Partial<Options> | undefined> =
// using https://github.com/syntax-tree/unist-util-visit as they're just anochor links // using https://github.com/syntax-tree/unist-util-visit as they're just anochor links
plugins.push(() => { plugins.push(() => {
return (tree, _file) => { return (tree, _file) => {
visit(tree, "element", (node, index, parent) => { visit(tree, "element", (node, _index, _parent) => {
if (node.tagName === "a" && node.properties?.href?.startsWith("#bib")) { if (node.tagName === "a" && node.properties?.href?.startsWith("#bib")) {
node.properties["data-no-popover"] = true node.properties["data-no-popover"] = true
} }

View File

@ -18,7 +18,7 @@ const urlRegex = new RegExp(
"g", "g",
) )
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "Description", name: "Description",

View File

@ -40,7 +40,7 @@ function coerceToArray(input: string | string[]): string[] | undefined {
.map((tag: string | number) => tag.toString()) .map((tag: string | number) => tag.toString())
} }
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "FrontMatter", name: "FrontMatter",
@ -71,6 +71,10 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"])) const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
if (cssclasses) data.cssclasses = cssclasses if (cssclasses) data.cssclasses = cssclasses
const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"])
if (socialImage) data.socialImage = socialImage
// fill in frontmatter // fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"] file.data.frontmatter = data as QuartzPluginData["frontmatter"]
} }
@ -88,11 +92,13 @@ declare module "vfile" {
tags: string[] tags: string[]
aliases: string[] aliases: string[]
description: string description: string
publish: boolean publish: boolean | string
draft: boolean draft: boolean | string
lang: string lang: string
enableToc: string enableToc: string
cssclasses: string[] cssclasses: string[]
socialImage: string
comments: boolean | string
}> }>
} }
} }

View File

@ -14,9 +14,7 @@ const defaultOptions: Options = {
linkHeadings: true, linkHeadings: true,
} }
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "GitHubFlavoredMarkdown", name: "GitHubFlavoredMarkdown",

View File

@ -10,3 +10,4 @@ export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax" export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc" export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks" export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam"

View File

@ -27,9 +27,7 @@ function coerceDate(fp: string, d: any): Date {
} }
type MaybeDate = undefined | string | number type MaybeDate = undefined | string | number
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "CreatedModifiedDate", name: "CreatedModifiedDate",

View File

@ -1,44 +1,66 @@
import remarkMath from "remark-math" import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex" import rehypeKatex from "rehype-katex"
import rehypeMathjax from "rehype-mathjax/svg" import rehypeMathjax from "rehype-mathjax/svg"
//@ts-ignore
import rehypeTypst from "@myriaddreamin/rehype-typst"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { KatexOptions } from "katex"
import { Options as MathjaxOptions } from "rehype-mathjax/svg"
//@ts-ignore
import { Options as TypstOptions } from "@myriaddreamin/rehype-typst"
interface Options { interface Options {
renderEngine: "katex" | "mathjax" renderEngine: "katex" | "mathjax" | "typst"
customMacros: MacroType
katexOptions: Omit<KatexOptions, "macros" | "output">
mathJaxOptions: Omit<MathjaxOptions, "macros">
typstOptions: TypstOptions
} }
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => { interface MacroType {
[key: string]: string
}
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
const engine = opts?.renderEngine ?? "katex" const engine = opts?.renderEngine ?? "katex"
const macros = opts?.customMacros ?? {}
return { return {
name: "Latex", name: "Latex",
markdownPlugins() { markdownPlugins() {
return [remarkMath] return [remarkMath]
}, },
htmlPlugins() { htmlPlugins() {
if (engine === "katex") { switch (engine) {
return [[rehypeKatex, { output: "html" }]] case "katex": {
} else { return [[rehypeKatex, { output: "html", macros, ...(opts?.katexOptions ?? {}) }]]
return [rehypeMathjax] }
case "typst": {
return [[rehypeTypst, opts?.typstOptions ?? {}]]
}
case "mathjax": {
return [[rehypeMathjax, { macros, ...(opts?.mathJaxOptions ?? {}) }]]
}
default: {
return [[rehypeMathjax, { macros, ...(opts?.mathJaxOptions ?? {}) }]]
}
} }
}, },
externalResources() { externalResources() {
if (engine === "katex") { switch (engine) {
case "katex":
return { return {
css: [ css: [{ content: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" }],
// base css
"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
],
js: [ js: [
{ {
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
src: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js", src: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/copy-tex.min.js",
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "external", contentType: "external",
}, },
], ],
} }
} else { default:
return {} return { css: [], js: [] }
} }
}, },
} }

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