mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-05-18 22:44:14 +02:00
Compare commits
79 Commits
70cf007975
...
e31527ac74
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e31527ac74 | ||
![]() |
9786ead130 | ||
![]() |
849e8accfc | ||
![]() |
a1c36ec01f | ||
![]() |
9b8dec0c7c | ||
![]() |
7be47742a6 | ||
![]() |
4e4930ef9c | ||
![]() |
01943ff5a0 | ||
![]() |
8cf3e3001f | ||
![]() |
992ac00f7c | ||
![]() |
09f8670db7 | ||
![]() |
7e828252bb | ||
![]() |
c90dbacab0 | ||
![]() |
b7a945e034 | ||
![]() |
dc3323b574 | ||
![]() |
c98ef7e89f | ||
![]() |
2e6a675edd | ||
![]() |
e3162f7a7e | ||
![]() |
9466c145b1 | ||
![]() |
a934397961 | ||
![]() |
b2752e7262 | ||
![]() |
d9e36e60d1 | ||
![]() |
46adb35966 | ||
![]() |
e1c9eabef4 | ||
![]() |
d88e43010a | ||
![]() |
ef72f1bf70 | ||
![]() |
7d4bed64a9 | ||
![]() |
99011cb1b0 | ||
![]() |
c91cf97f99 | ||
![]() |
05e6f05a50 | ||
![]() |
7533d504dc | ||
![]() |
69a0ddf733 | ||
![]() |
a582505daf | ||
![]() |
965f9e123c | ||
![]() |
ff9e60a7fc | ||
![]() |
367bb0e6ce | ||
![]() |
8141cb1587 | ||
![]() |
5ccc2dcbba | ||
![]() |
11c98f5600 | ||
![]() |
5a5ef670fe | ||
![]() |
285c0e9768 | ||
![]() |
adb326c933 | ||
![]() |
0c4281eb53 | ||
![]() |
7ac94e1d84 | ||
![]() |
e1d754ef79 | ||
![]() |
a727b57e46 | ||
![]() |
1a4cfa606f | ||
![]() |
410001b942 | ||
![]() |
f24a147276 | ||
![]() |
137d55eb1b | ||
![]() |
9188939b1f | ||
![]() |
92676d746e | ||
![]() |
1893196939 | ||
![]() |
1ab9c91df1 | ||
![]() |
0ad9111388 | ||
![]() |
cbeef5541f | ||
![]() |
0f04f1262c | ||
![]() |
31e0b7c6f8 | ||
![]() |
a6b2967df8 | ||
![]() |
f84a6554f6 | ||
![]() |
1d5b6f58d8 | ||
![]() |
74f8c13598 | ||
![]() |
56ba2f4fa7 | ||
![]() |
314a88d5c6 | ||
![]() |
a53772bbd1 | ||
![]() |
1915a198d9 | ||
![]() |
1d2dc167f4 | ||
![]() |
3ef2a24f4b | ||
![]() |
d98a3a088a | ||
![]() |
ad52d09567 | ||
![]() |
9f701e5045 | ||
![]() |
67e1beea70 | ||
![]() |
3aa11357aa | ||
![]() |
b3a02909ba | ||
![]() |
1dc208356a | ||
![]() |
3d0ba32070 | ||
![]() |
c5d97db000 | ||
![]() |
0d1f15d37c | ||
![]() |
11c23a137a |
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@ -9,3 +9,12 @@ updates:
|
|||||||
applies-to: "version-updates"
|
applies-to: "version-updates"
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
ci-dependencies:
|
||||||
|
applies-to: "version-updates"
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
2
.github/workflows/docker-build-push.yaml
vendored
2
.github/workflows/docker-build-push.yaml
vendored
@ -25,7 +25,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Inject slug/short variables
|
- name: Inject slug/short variables
|
||||||
uses: rlespinasse/github-slug-action@v4.4.1
|
uses: rlespinasse/github-slug-action@v5.0.0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
@ -272,7 +274,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
|
|||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = canonicalizeServer(file.data.slug!)
|
const slug = canonicalizeServer(file.data.slug!)
|
||||||
const externalResources = pageResources(slug, resources)
|
const externalResources = pageResources(slug, file.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
externalResources,
|
externalResources,
|
||||||
|
@ -9,6 +9,7 @@ A backlink for a note is a link from another note to that note. Links in the bac
|
|||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`.
|
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`.
|
||||||
|
- Hide when empty: hide `Backlinks` if given page doesn't contain any backlinks (default to `true`). To disable this, use `Component.Backlinks({ hideWhenEmpty: false })`.
|
||||||
- Component: `quartz/components/Backlinks.tsx`
|
- Component: `quartz/components/Backlinks.tsx`
|
||||||
- Style: `quartz/components/styles/backlinks.scss`
|
- Style: `quartz/components/styles/backlinks.scss`
|
||||||
- Script: `quartz/components/scripts/search.inline.ts`
|
- Script: `quartz/components/scripts/search.inline.ts`
|
||||||
|
@ -63,6 +63,18 @@ type Options = {
|
|||||||
category: string
|
category: string
|
||||||
categoryId: 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
|
// how to map pages -> discussions
|
||||||
// defaults to 'url'
|
// defaults to 'url'
|
||||||
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||||
@ -81,3 +93,35 @@ type Options = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
---
|
||||||
|
```
|
||||||
|
401
docs/features/social images.md
Normal file
401
docs/features/social images.md
Normal 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>
|
||||||
|
> )
|
||||||
|
> }
|
||||||
|
> ```
|
@ -247,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:
|
||||||
|
BIN
docs/images/custom-social-image-preview-dark.png
Normal file
BIN
docs/images/custom-social-image-preview-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
BIN
docs/images/custom-social-image-preview-light.png
Normal file
BIN
docs/images/custom-social-image-preview-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 132 KiB |
BIN
docs/images/social-image-preview-dark.png
Normal file
BIN
docs/images/social-image-preview-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 139 KiB |
BIN
docs/images/social-image-preview-light.png
Normal file
BIN
docs/images/social-image-preview-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
@ -11,9 +11,13 @@ 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}"}`
|
- `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
|
||||||
|
|
||||||
- Category: Transformer
|
- Category: Transformer
|
||||||
|
@ -31,4 +31,4 @@ This plugin accepts the following configuration options:
|
|||||||
|
|
||||||
- Category: Transformer
|
- Category: Transformer
|
||||||
- Function name: `Plugin.ObsidianFlavoredMarkdown()`.
|
- Function name: `Plugin.ObsidianFlavoredMarkdown()`.
|
||||||
- Source: [`quartz/plugins/transformers/toc.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/toc.ts).
|
- Source: [`quartz/plugins/transformers/ofm.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/ofm.ts)
|
||||||
|
@ -9,6 +9,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
|||||||
- [Socratica Toolbox](https://toolbox.socratica.info/)
|
- [Socratica Toolbox](https://toolbox.socratica.info/)
|
||||||
- [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
|
- [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
|
||||||
- [Aaron Pham's Garden](https://aarnphm.xyz/)
|
- [Aaron Pham's Garden](https://aarnphm.xyz/)
|
||||||
|
- [The Pond](https://turntrout.com/welcome)
|
||||||
- [Pelayo Arbues' Notes](https://pelayoarbues.com/)
|
- [Pelayo Arbues' Notes](https://pelayoarbues.com/)
|
||||||
- [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
|
- [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
|
||||||
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
|
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
|
||||||
@ -20,13 +21,16 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
|||||||
- [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/)
|
||||||
- [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)
|
- [Gatekeeper Wiki](https://www.gatekeeper.wiki)
|
||||||
- [Ellie's Notes](https://ellie.wtf)
|
- [Ellie's Notes](https://ellie.wtf)
|
||||||
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
|
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
|
||||||
- [Eledah's Crystalline](https://blog.eledah.ir/)
|
- [Eledah's Crystalline](https://blog.eledah.ir/)
|
||||||
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)
|
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)
|
||||||
|
- [Zen Browser Docs](https://docs.zen-browser.app)
|
||||||
|
- [🪴8cat life](https://8cat.life)
|
||||||
|
|
||||||
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)!
|
||||||
|
2248
package-lock.json
generated
2248
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@ -35,38 +35,39 @@
|
|||||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^0.7.0",
|
"@clack/prompts": "^0.9.1",
|
||||||
"@floating-ui/dom": "^1.6.11",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
"@myriaddreamin/rehype-typst": "^0.5.4",
|
||||||
"@napi-rs/simple-git": "0.1.19",
|
"@napi-rs/simple-git": "0.1.19",
|
||||||
"@tweenjs/tween.js": "^25.0.0",
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.4.1",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^4.0.3",
|
||||||
"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.2",
|
"globby": "^14.0.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hast-util-to-html": "^9.0.3",
|
"hast-util-to-html": "^9.0.4",
|
||||||
"hast-util-to-jsx-runtime": "^2.3.0",
|
"hast-util-to-jsx-runtime": "^2.3.2",
|
||||||
"hast-util-to-string": "^3.0.1",
|
"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.26.0",
|
"lightningcss": "^1.29.1",
|
||||||
"mdast-util-find-and-replace": "^3.0.1",
|
"mdast-util-find-and-replace": "^3.0.2",
|
||||||
"mdast-util-to-hast": "^13.2.0",
|
"mdast-util-to-hast": "^13.2.0",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"micromorph": "^0.4.5",
|
"micromorph": "^0.4.5",
|
||||||
"pixi.js": "^8.4.1",
|
"pixi.js": "^8.7.3",
|
||||||
"preact": "^10.24.1",
|
"preact": "^10.25.4",
|
||||||
"preact-render-to-string": "^6.5.11",
|
"preact-render-to-string": "^6.5.13",
|
||||||
"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.1.1",
|
"rehype-citation": "^2.2.2",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-mathjax": "^6.0.0",
|
"rehype-mathjax": "^6.0.0",
|
||||||
"rehype-pretty-code": "^0.14.0",
|
"rehype-pretty-code": "^0.14.0",
|
||||||
@ -82,15 +83,17 @@
|
|||||||
"remark-smartypants": "^3.0.2",
|
"remark-smartypants": "^3.0.2",
|
||||||
"rfdc": "^1.4.1",
|
"rfdc": "^1.4.1",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"serve-handler": "^6.1.5",
|
"satori": "^0.12.1",
|
||||||
"shiki": "^1.18.0",
|
"serve-handler": "^6.1.6",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"shiki": "^1.26.2",
|
||||||
"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.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"vfile": "^6.0.3",
|
"vfile": "^6.0.3",
|
||||||
"workerpool": "^9.1.3",
|
"workerpool": "^9.2.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
@ -99,14 +102,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": "^22.7.4",
|
"@types/node": "^22.12.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.12",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"esbuild": "^0.19.9",
|
"esbuild": "^0.24.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.4.2",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ const config: QuartzConfig = {
|
|||||||
baseUrl: "quartz.jzhao.xyz",
|
baseUrl: "quartz.jzhao.xyz",
|
||||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||||
defaultDateType: "created",
|
defaultDateType: "created",
|
||||||
|
generateSocialImages: false,
|
||||||
theme: {
|
theme: {
|
||||||
fontOrigin: "googleFonts",
|
fontOrigin: "googleFonts",
|
||||||
cdnCaching: true,
|
cdnCaching: true,
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import workerpool from "workerpool"
|
import workerpool from "workerpool"
|
||||||
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
|
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
|
||||||
const { parseFiles } = await import(cacheFile)
|
const { parseMarkdown, processHtml } = await import(cacheFile)
|
||||||
workerpool.worker({
|
workerpool.worker({
|
||||||
parseFiles,
|
parseMarkdown,
|
||||||
|
processHtml,
|
||||||
})
|
})
|
||||||
|
@ -139,9 +139,9 @@ async function startServing(
|
|||||||
|
|
||||||
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
|
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
|
||||||
watcher
|
watcher
|
||||||
.on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData))
|
.on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
|
||||||
.on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData))
|
.on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
|
||||||
.on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData))
|
.on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await watcher.close()
|
await watcher.close()
|
||||||
|
@ -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 =
|
||||||
@ -60,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
|
||||||
|
@ -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 {
|
||||||
|
@ -4,6 +4,17 @@ import { resolveRelative, simplifySlug } from "../util/path"
|
|||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
|
interface BacklinksOptions {
|
||||||
|
hideWhenEmpty: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: BacklinksOptions = {
|
||||||
|
hideWhenEmpty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||||
|
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||||
|
|
||||||
const Backlinks: QuartzComponent = ({
|
const Backlinks: QuartzComponent = ({
|
||||||
fileData,
|
fileData,
|
||||||
allFiles,
|
allFiles,
|
||||||
@ -12,6 +23,9 @@ const Backlinks: QuartzComponent = ({
|
|||||||
}: QuartzComponentProps) => {
|
}: QuartzComponentProps) => {
|
||||||
const slug = simplifySlug(fileData.slug!)
|
const slug = simplifySlug(fileData.slug!)
|
||||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||||
|
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div class={classNames(displayClass, "backlinks")}>
|
<div class={classNames(displayClass, "backlinks")}>
|
||||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||||
@ -33,4 +47,6 @@ const Backlinks: QuartzComponent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Backlinks.css = style
|
Backlinks.css = style
|
||||||
export default (() => Backlinks) satisfies QuartzComponentConstructor
|
|
||||||
|
return Backlinks
|
||||||
|
}) satisfies QuartzComponentConstructor
|
||||||
|
@ -10,6 +10,9 @@ type Options = {
|
|||||||
repoId: string
|
repoId: string
|
||||||
category: string
|
category: string
|
||||||
categoryId: string
|
categoryId: string
|
||||||
|
themeUrl?: string
|
||||||
|
lightTheme?: string
|
||||||
|
darkTheme?: string
|
||||||
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||||
strict?: boolean
|
strict?: boolean
|
||||||
reactionsEnabled?: boolean
|
reactionsEnabled?: boolean
|
||||||
@ -22,7 +25,15 @@ function boolToStringBool(b: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default ((opts: Options) => {
|
export default ((opts: Options) => {
|
||||||
const Comments: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
|
||||||
|
// check if comments should be displayed according to frontmatter
|
||||||
|
const disableComment: boolean =
|
||||||
|
typeof fileData.frontmatter?.comments !== "undefined" &&
|
||||||
|
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
|
||||||
|
if (disableComment) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={classNames(displayClass, "giscus")}
|
class={classNames(displayClass, "giscus")}
|
||||||
@ -34,6 +45,11 @@ export default ((opts: Options) => {
|
|||||||
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
||||||
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
||||||
data-input-position={opts.options.inputPosition ?? "bottom"}
|
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>
|
></div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { formatDate, getDate } from "./Date"
|
import { Date, getDate } from "./Date"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import readingTime from "reading-time"
|
import readingTime from "reading-time"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
@ -30,7 +30,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
|
|||||||
const segments: (string | JSX.Element)[] = []
|
const segments: (string | JSX.Element)[] = []
|
||||||
|
|
||||||
if (fileData.dates) {
|
if (fileData.dates) {
|
||||||
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
|
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display reading time if enabled
|
// Display reading time if enabled
|
||||||
@ -39,14 +39,12 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
|
|||||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||||
minutes: Math.ceil(minutes),
|
minutes: Math.ceil(minutes),
|
||||||
})
|
})
|
||||||
segments.push(displayedTime)
|
segments.push(<span>{displayedTime}</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segmentsElements = segments.map((segment) => <span>{segment}</span>)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||||
{segmentsElements}
|
{segments}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Date({ date, locale }: Props) {
|
export function Date({ date, locale }: Props) {
|
||||||
return <>{formatDate(date, locale)}</>
|
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import explorerStyle from "./styles/explorer.scss"
|
import style from "./styles/explorer.scss"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/explorer.inline"
|
import script from "./scripts/explorer.inline"
|
||||||
@ -46,7 +46,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
let jsonTree: string
|
let jsonTree: string
|
||||||
let lastBuildId: string = ""
|
let lastBuildId: string = ""
|
||||||
|
|
||||||
function constructFileTree(allFiles: QuartzPluginData[], currentFilePath: string) {
|
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||||
// 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))
|
||||||
@ -81,7 +81,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
}: QuartzComponentProps) => {
|
}: QuartzComponentProps) => {
|
||||||
if (ctx.buildId !== lastBuildId) {
|
if (ctx.buildId !== lastBuildId) {
|
||||||
lastBuildId = ctx.buildId
|
lastBuildId = ctx.buildId
|
||||||
constructFileTree(allFiles, (fileData.filePath ?? "").replaceAll(" ", "-"))
|
constructFileTree(allFiles)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class={classNames(displayClass, "explorer")}>
|
<div class={classNames(displayClass, "explorer")}>
|
||||||
@ -150,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Explorer.css = explorerStyle
|
Explorer.css = style
|
||||||
Explorer.afterDOMLoaded = script
|
Explorer.afterDOMLoaded = script
|
||||||
return Explorer
|
return Explorer
|
||||||
}) satisfies QuartzComponentConstructor
|
}) satisfies QuartzComponentConstructor
|
||||||
|
@ -1,16 +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"]>
|
||||||
|
|
||||||
|
let fullOptions: SocialImageOptions
|
||||||
|
const Head: QuartzComponent = ({
|
||||||
|
cfg,
|
||||||
|
fileData,
|
||||||
|
externalResources,
|
||||||
|
ctx,
|
||||||
|
}: QuartzComponentProps) => {
|
||||||
|
// Initialize options if not set
|
||||||
|
if (!fullOptions) {
|
||||||
|
if (typeof cfg.generateSocialImages !== "boolean") {
|
||||||
|
fullOptions = { ...defaultOptions, ...cfg.generateSocialImages }
|
||||||
|
} else {
|
||||||
|
fullOptions = defaultOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoize google fonts
|
||||||
|
if (!fontsPromise && cfg.generateSocialImages) {
|
||||||
|
fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = fileData.filePath
|
||||||
|
// since "/" is not a valid character in file names, replace with "-"
|
||||||
|
const fileName = slug?.replaceAll("/", "-")
|
||||||
|
|
||||||
|
// Get file description (priority: frontmatter > fileData > default)
|
||||||
|
const fdDescription =
|
||||||
|
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
|
||||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||||
const title =
|
const title =
|
||||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||||
const description =
|
let description = ""
|
||||||
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.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"}`)
|
||||||
@ -18,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>
|
||||||
@ -31,18 +165,39 @@ export default (() => {
|
|||||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin={"anonymous"} />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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: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))}
|
||||||
|
@ -46,13 +46,9 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
|
|||||||
return (
|
return (
|
||||||
<li class="section-li">
|
<li class="section-li">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div>
|
|
||||||
{page.dates && (
|
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
{page.dates && <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">
|
||||||
|
@ -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 {
|
||||||
|
@ -2,22 +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, SortFn } 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
|
sort?: SortFn
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: FolderContentOptions = {
|
const defaultOptions: FolderContentOptions = {
|
||||||
showFolderCount: true,
|
showFolderCount: true,
|
||||||
|
showSubfolders: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||||
@ -26,16 +29,49 @@ 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 = cssClasses.join(" ")
|
||||||
const listProps = {
|
const listProps = {
|
||||||
...props,
|
...props,
|
||||||
sort: options.sort,
|
sort: options.sort,
|
||||||
@ -48,8 +84,8 @@ export default ((opts?: Partial<FolderContentOptions>) => {
|
|||||||
: htmlToJsx(fileData.filePath!, tree)
|
: htmlToJsx(fileData.filePath!, tree)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={classes}>
|
<div class="popover-hint">
|
||||||
<article>{content}</article>
|
<article class={classes}>{content}</article>
|
||||||
<div class="page-listing">
|
<div class="page-listing">
|
||||||
{options.showFolderCount && (
|
{options.showFolderCount && (
|
||||||
<p>
|
<p>
|
||||||
|
@ -38,7 +38,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
? fileData.description
|
? fileData.description
|
||||||
: htmlToJsx(fileData.filePath!, tree)
|
: htmlToJsx(fileData.filePath!, tree)
|
||||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||||
const classes = ["popover-hint", ...cssClasses].join(" ")
|
const classes = cssClasses.join(" ")
|
||||||
if (tag === "/") {
|
if (tag === "/") {
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(
|
...new Set(
|
||||||
@ -50,8 +50,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class={classes}>
|
<div class="popover-hint">
|
||||||
<article>
|
<article class={classes}>
|
||||||
<p>{content}</p>
|
<p>{content}</p>
|
||||||
</article>
|
</article>
|
||||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||||
@ -93,7 +93,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<PageList limit={options.numPages} {...listProps} sort={opts?.sort} />
|
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -110,11 +110,11 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={classes}>
|
<div class={classes}>
|
||||||
<article>{content}</article>
|
<article class="popover-hint">{content}</article>
|
||||||
<div class="page-listing">
|
<div class="page-listing">
|
||||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||||
<div>
|
<div>
|
||||||
<PageList {...listProps} />
|
<PageList {...listProps} sort={options?.sort} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,10 @@ import { visit } from "unist-util-visit"
|
|||||||
import { Root, Element, ElementContent } from "hast"
|
import { Root, Element, ElementContent } from "hast"
|
||||||
import { GlobalConfiguration } from "../cfg"
|
import { GlobalConfiguration } from "../cfg"
|
||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
|
// @ts-ignore
|
||||||
|
import mermaidScript from "./scripts/mermaid.inline"
|
||||||
|
import mermaidStyle from "./styles/mermaid.inline.scss"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
interface RenderComponents {
|
interface RenderComponents {
|
||||||
head: QuartzComponent
|
head: QuartzComponent
|
||||||
@ -23,13 +27,19 @@ interface RenderComponents {
|
|||||||
const headerRegex = new RegExp(/h[1-6]/)
|
const headerRegex = new RegExp(/h[1-6]/)
|
||||||
export function pageResources(
|
export function pageResources(
|
||||||
baseDir: FullSlug | RelativeURL,
|
baseDir: FullSlug | RelativeURL,
|
||||||
|
fileData: QuartzPluginData,
|
||||||
staticResources: StaticResources,
|
staticResources: StaticResources,
|
||||||
): StaticResources {
|
): StaticResources {
|
||||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||||
|
|
||||||
return {
|
const resources: StaticResources = {
|
||||||
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"),
|
||||||
@ -43,14 +53,28 @@ export function pageResources(
|
|||||||
script: contentIndexScript,
|
script: contentIndexScript,
|
||||||
},
|
},
|
||||||
...staticResources.js,
|
...staticResources.js,
|
||||||
{
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileData.hasMermaidDiagram) {
|
||||||
|
resources.js.push({
|
||||||
|
script: mermaidScript,
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
moduleType: "module",
|
||||||
|
contentType: "inline",
|
||||||
|
})
|
||||||
|
resources.css.push({ content: mermaidStyle, inline: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: we have to put this last to make sure spa.inline.ts is the last item.
|
||||||
|
resources.js.push({
|
||||||
src: joinSegments(baseDir, "postscript.js"),
|
src: joinSegments(baseDir, "postscript.js"),
|
||||||
loadTime: "afterDOMReady",
|
loadTime: "afterDOMReady",
|
||||||
moduleType: "module",
|
moduleType: "module",
|
||||||
contentType: "external",
|
contentType: "external",
|
||||||
},
|
})
|
||||||
],
|
|
||||||
}
|
return resources
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPage(
|
export function renderPage(
|
||||||
|
@ -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"
|
||||||
|
@ -13,7 +13,7 @@ const changeTheme = (e: CustomEventMap["themechange"]) => {
|
|||||||
{
|
{
|
||||||
giscus: {
|
giscus: {
|
||||||
setConfig: {
|
setConfig: {
|
||||||
theme: theme,
|
theme: getThemeUrl(getThemeName(theme)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -21,12 +21,36 @@ const changeTheme = (e: CustomEventMap["themechange"]) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"> & {
|
type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
||||||
dataset: DOMStringMap & {
|
dataset: DOMStringMap & {
|
||||||
repo: `${string}/${string}`
|
repo: `${string}/${string}`
|
||||||
repoId: string
|
repoId: string
|
||||||
category: string
|
category: string
|
||||||
categoryId: string
|
categoryId: string
|
||||||
|
themeUrl: string
|
||||||
|
lightTheme: string
|
||||||
|
darkTheme: string
|
||||||
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||||
strict: string
|
strict: string
|
||||||
reactionsEnabled: string
|
reactionsEnabled: string
|
||||||
@ -57,7 +81,7 @@ document.addEventListener("nav", () => {
|
|||||||
|
|
||||||
const theme = document.documentElement.getAttribute("saved-theme")
|
const theme = document.documentElement.getAttribute("saved-theme")
|
||||||
if (theme) {
|
if (theme) {
|
||||||
giscusScript.setAttribute("data-theme", theme)
|
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
|
||||||
}
|
}
|
||||||
|
|
||||||
giscusContainer.appendChild(giscusScript)
|
giscusContainer.appendChild(giscusScript)
|
||||||
|
@ -27,9 +27,10 @@ document.addEventListener("nav", () => {
|
|||||||
|
|
||||||
// Darkmode toggle
|
// Darkmode toggle
|
||||||
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
|
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
|
||||||
|
if (themeButton) {
|
||||||
themeButton.addEventListener("click", switchTheme)
|
themeButton.addEventListener("click", switchTheme)
|
||||||
window.addCleanup(() => themeButton.removeEventListener("click", switchTheme))
|
window.addCleanup(() => themeButton.removeEventListener("click", switchTheme))
|
||||||
|
}
|
||||||
// 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)")
|
||||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||||
|
@ -38,7 +38,7 @@ function toggleExplorer(this: HTMLElement) {
|
|||||||
|
|
||||||
// Prevent scroll under
|
// Prevent scroll under
|
||||||
if (document.querySelector("#mobile-explorer")) {
|
if (document.querySelector("#mobile-explorer")) {
|
||||||
// Disable scrolling one the page when the explorer is opened on mobile
|
// Disable scrolling on the page when the explorer is opened on mobile
|
||||||
const bodySelector = document.querySelector("#quartz-body")
|
const bodySelector = document.querySelector("#quartz-body")
|
||||||
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
|
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
|
||||||
}
|
}
|
||||||
@ -149,11 +149,14 @@ function toggleExplorerFolders() {
|
|||||||
/\/index$/g,
|
/\/index$/g,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
const listToReplace = document.querySelectorAll(".folder-outer:has(> ul[data-folderul]")
|
const allFolders = document.querySelectorAll(".folder-outer")
|
||||||
|
|
||||||
listToReplace.forEach((element) => {
|
allFolders.forEach((element) => {
|
||||||
if (element.children.length > 0) {
|
const folderUl = Array.from(element.children).find((child) =>
|
||||||
if (currentFile.includes(element.firstElementChild?.getAttribute("data-folderul") ?? "")) {
|
child.matches("ul[data-folderul]"),
|
||||||
|
)
|
||||||
|
if (folderUl) {
|
||||||
|
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
|
||||||
if (!element.classList.contains("open")) {
|
if (!element.classList.contains("open")) {
|
||||||
element.classList.add("open")
|
element.classList.add("open")
|
||||||
}
|
}
|
||||||
|
@ -580,7 +580,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
|||||||
function hideGlobalGraph() {
|
function hideGlobalGraph() {
|
||||||
container?.classList.remove("active")
|
container?.classList.remove("active")
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
sidebar.style.zIndex = "unset"
|
sidebar.style.zIndex = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
248
quartz/components/scripts/mermaid.inline.ts
Normal file
248
quartz/components/scripts/mermaid.inline.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { removeAllChildren } from "./util"
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiagramPanZoom {
|
||||||
|
private isDragging = false
|
||||||
|
private startPan: Position = { x: 0, y: 0 }
|
||||||
|
private currentPan: Position = { x: 0, y: 0 }
|
||||||
|
private scale = 1
|
||||||
|
private readonly MIN_SCALE = 0.5
|
||||||
|
private readonly MAX_SCALE = 3
|
||||||
|
private readonly ZOOM_SENSITIVITY = 0.001
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private container: HTMLElement,
|
||||||
|
private content: HTMLElement,
|
||||||
|
) {
|
||||||
|
this.setupEventListeners()
|
||||||
|
this.setupNavigationControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners() {
|
||||||
|
// Mouse drag events
|
||||||
|
this.container.addEventListener("mousedown", this.onMouseDown.bind(this))
|
||||||
|
document.addEventListener("mousemove", this.onMouseMove.bind(this))
|
||||||
|
document.addEventListener("mouseup", this.onMouseUp.bind(this))
|
||||||
|
|
||||||
|
// Wheel zoom events
|
||||||
|
this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false })
|
||||||
|
|
||||||
|
// Reset on window resize
|
||||||
|
window.addEventListener("resize", this.resetTransform.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupNavigationControls() {
|
||||||
|
const controls = document.createElement("div")
|
||||||
|
controls.className = "mermaid-controls"
|
||||||
|
|
||||||
|
// Zoom controls
|
||||||
|
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
||||||
|
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
||||||
|
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||||
|
|
||||||
|
controls.appendChild(zoomOut)
|
||||||
|
controls.appendChild(resetBtn)
|
||||||
|
controls.appendChild(zoomIn)
|
||||||
|
|
||||||
|
this.container.appendChild(controls)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||||
|
const button = document.createElement("button")
|
||||||
|
button.textContent = text
|
||||||
|
button.className = "mermaid-control-button"
|
||||||
|
button.addEventListener("click", onClick)
|
||||||
|
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return // Only handle left click
|
||||||
|
this.isDragging = true
|
||||||
|
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
|
||||||
|
this.container.style.cursor = "grabbing"
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseMove(e: MouseEvent) {
|
||||||
|
if (!this.isDragging) return
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.currentPan = {
|
||||||
|
x: e.clientX - this.startPan.x,
|
||||||
|
y: e.clientY - this.startPan.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTransform()
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseUp() {
|
||||||
|
this.isDragging = false
|
||||||
|
this.container.style.cursor = "grab"
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const delta = -e.deltaY * this.ZOOM_SENSITIVITY
|
||||||
|
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||||
|
|
||||||
|
// Calculate mouse position relative to content
|
||||||
|
const rect = this.content.getBoundingClientRect()
|
||||||
|
const mouseX = e.clientX - rect.left
|
||||||
|
const mouseY = e.clientY - rect.top
|
||||||
|
|
||||||
|
// Adjust pan to zoom around mouse position
|
||||||
|
const scaleDiff = newScale - this.scale
|
||||||
|
this.currentPan.x -= mouseX * scaleDiff
|
||||||
|
this.currentPan.y -= mouseY * scaleDiff
|
||||||
|
|
||||||
|
this.scale = newScale
|
||||||
|
this.updateTransform()
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoom(delta: number) {
|
||||||
|
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||||
|
|
||||||
|
// Zoom around center
|
||||||
|
const rect = this.content.getBoundingClientRect()
|
||||||
|
const centerX = rect.width / 2
|
||||||
|
const centerY = rect.height / 2
|
||||||
|
|
||||||
|
const scaleDiff = newScale - this.scale
|
||||||
|
this.currentPan.x -= centerX * scaleDiff
|
||||||
|
this.currentPan.y -= centerY * scaleDiff
|
||||||
|
|
||||||
|
this.scale = newScale
|
||||||
|
this.updateTransform()
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTransform() {
|
||||||
|
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTransform() {
|
||||||
|
this.scale = 1
|
||||||
|
this.currentPan = { x: 0, y: 0 }
|
||||||
|
this.updateTransform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssVars = [
|
||||||
|
"--secondary",
|
||||||
|
"--tertiary",
|
||||||
|
"--gray",
|
||||||
|
"--light",
|
||||||
|
"--lightgray",
|
||||||
|
"--highlight",
|
||||||
|
"--dark",
|
||||||
|
"--darkgray",
|
||||||
|
"--codeFont",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
let mermaidImport = undefined
|
||||||
|
document.addEventListener("nav", async () => {
|
||||||
|
const center = document.querySelector(".center") as HTMLElement
|
||||||
|
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||||
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
|
const computedStyleMap = cssVars.reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<(typeof cssVars)[number], string>,
|
||||||
|
)
|
||||||
|
|
||||||
|
mermaidImport ||= await import(
|
||||||
|
//@ts-ignore
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||||
|
)
|
||||||
|
const mermaid = mermaidImport.default
|
||||||
|
|
||||||
|
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "loose",
|
||||||
|
theme: darkMode ? "dark" : "base",
|
||||||
|
themeVariables: {
|
||||||
|
fontFamily: computedStyleMap["--codeFont"],
|
||||||
|
primaryColor: computedStyleMap["--light"],
|
||||||
|
primaryTextColor: computedStyleMap["--darkgray"],
|
||||||
|
primaryBorderColor: computedStyleMap["--tertiary"],
|
||||||
|
lineColor: computedStyleMap["--darkgray"],
|
||||||
|
secondaryColor: computedStyleMap["--secondary"],
|
||||||
|
tertiaryColor: computedStyleMap["--tertiary"],
|
||||||
|
clusterBkg: computedStyleMap["--light"],
|
||||||
|
edgeLabelBackground: computedStyleMap["--highlight"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mermaid.run({ nodes })
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const codeBlock = nodes[i] as HTMLElement
|
||||||
|
const pre = codeBlock.parentElement as HTMLPreElement
|
||||||
|
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
|
||||||
|
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
|
||||||
|
|
||||||
|
const clipboardStyle = window.getComputedStyle(clipboardBtn)
|
||||||
|
const clipboardWidth =
|
||||||
|
clipboardBtn.offsetWidth +
|
||||||
|
parseFloat(clipboardStyle.marginLeft || "0") +
|
||||||
|
parseFloat(clipboardStyle.marginRight || "0")
|
||||||
|
|
||||||
|
// Set expand button position
|
||||||
|
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
|
||||||
|
pre.prepend(expandBtn)
|
||||||
|
|
||||||
|
// query popup container
|
||||||
|
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
|
||||||
|
if (!popupContainer) return
|
||||||
|
|
||||||
|
let panZoom: DiagramPanZoom | null = null
|
||||||
|
|
||||||
|
function showMermaid() {
|
||||||
|
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
|
||||||
|
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
|
||||||
|
if (!content) return
|
||||||
|
removeAllChildren(content)
|
||||||
|
|
||||||
|
// Clone the mermaid content
|
||||||
|
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
|
||||||
|
content.appendChild(mermaidContent)
|
||||||
|
|
||||||
|
// Show container
|
||||||
|
popupContainer.classList.add("active")
|
||||||
|
container.style.cursor = "grab"
|
||||||
|
|
||||||
|
// Initialize pan-zoom after showing the popup
|
||||||
|
panZoom = new DiagramPanZoom(container, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMermaid() {
|
||||||
|
popupContainer.classList.remove("active")
|
||||||
|
panZoom = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(e: any) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
hideMermaid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBtn = popupContainer.querySelector(".close-button") as HTMLButtonElement
|
||||||
|
|
||||||
|
closeBtn.addEventListener("click", hideMermaid)
|
||||||
|
expandBtn.addEventListener("click", showMermaid)
|
||||||
|
document.addEventListener("keydown", handleEscape)
|
||||||
|
|
||||||
|
window.addCleanup(() => {
|
||||||
|
closeBtn.removeEventListener("click", hideMermaid)
|
||||||
|
expandBtn.removeEventListener("click", showMermaid)
|
||||||
|
document.removeEventListener("keydown", handleEscape)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
@ -1,5 +1,6 @@
|
|||||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||||
import { normalizeRelativeURLs } from "../../util/path"
|
import { normalizeRelativeURLs } from "../../util/path"
|
||||||
|
import { fetchCanonical } from "./util"
|
||||||
|
|
||||||
const p = new DOMParser()
|
const p = new DOMParser()
|
||||||
async function mouseEnterHandler(
|
async function mouseEnterHandler(
|
||||||
@ -37,7 +38,7 @@ async function mouseEnterHandler(
|
|||||||
targetUrl.hash = ""
|
targetUrl.hash = ""
|
||||||
targetUrl.search = ""
|
targetUrl.search = ""
|
||||||
|
|
||||||
const response = await fetch(`${targetUrl}`).catch((err) => {
|
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import micromorph from "micromorph"
|
import micromorph from "micromorph"
|
||||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||||
|
import { fetchCanonical } from "./util"
|
||||||
|
|
||||||
// adapted from `micromorph`
|
// adapted from `micromorph`
|
||||||
// https://github.com/natemoo-re/micromorph
|
// https://github.com/natemoo-re/micromorph
|
||||||
@ -42,10 +43,24 @@ function notifyNav(url: FullSlug) {
|
|||||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||||
|
|
||||||
|
function startLoading() {
|
||||||
|
const loadingBar = document.createElement("div")
|
||||||
|
loadingBar.className = "navigation-progress"
|
||||||
|
loadingBar.style.width = "0"
|
||||||
|
if (!document.body.contains(loadingBar)) {
|
||||||
|
document.body.appendChild(loadingBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingBar.style.width = "80%"
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
let p: DOMParser
|
let p: DOMParser
|
||||||
async function navigate(url: URL, isBack: boolean = false) {
|
async function navigate(url: URL, isBack: boolean = false) {
|
||||||
|
startLoading()
|
||||||
p = p || new DOMParser()
|
p = p || new DOMParser()
|
||||||
const contents = await fetch(`${url}`)
|
const contents = await fetchCanonical(url)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const contentType = res.headers.get("content-type")
|
const contentType = res.headers.get("content-type")
|
||||||
if (contentType?.startsWith("text/html")) {
|
if (contentType?.startsWith("text/html")) {
|
||||||
@ -104,6 +119,7 @@ async function navigate(url: URL, isBack: boolean = false) {
|
|||||||
if (!isBack) {
|
if (!isBack) {
|
||||||
history.pushState({}, "", url)
|
history.pushState({}, "", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyNav(getFullSlug(window))
|
notifyNav(getFullSlug(window))
|
||||||
delete announcer.dataset.persist
|
delete announcer.dataset.persist
|
||||||
}
|
}
|
||||||
|
@ -24,3 +24,22 @@ export function removeAllChildren(node: HTMLElement) {
|
|||||||
node.removeChild(node.firstChild)
|
node.removeChild(node.firstChild)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||||
|
// containing the URL it's redirecting to.
|
||||||
|
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||||
|
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||||
|
// way less robust - we only care about our own generated redirects after all.
|
||||||
|
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||||
|
|
||||||
|
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||||
|
const res = await fetch(`${url}`)
|
||||||
|
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
// reading the body can only be done once, so we need to clone the response
|
||||||
|
// to allow the caller to read it if it's was not a redirect
|
||||||
|
const text = await res.clone().text()
|
||||||
|
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||||
|
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||||
|
}
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
height: auto;
|
height: auto;
|
||||||
@media all and ($desktop) {
|
@media all and not ($desktop) {
|
||||||
height: 250px;
|
height: 250px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
|
|
||||||
&[show-comma="true"] {
|
&[show-comma="true"] {
|
||||||
> span:not(:last-child) {
|
> *:not(:last-child) {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
@ -29,6 +29,9 @@
|
|||||||
|
|
||||||
.explorer {
|
.explorer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
order: -1;
|
order: -1;
|
||||||
@ -56,10 +59,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: hidden;
|
|
||||||
|
|
||||||
&.desktop-only {
|
&.desktop-only {
|
||||||
@media all and not ($mobile) {
|
@media all and not ($mobile) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -65,7 +65,7 @@
|
|||||||
height: 80vh;
|
height: 80vh;
|
||||||
width: 80vw;
|
width: 80vw;
|
||||||
|
|
||||||
@media all and ($desktop) {
|
@media all and not ($desktop) {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
163
quartz/components/styles/mermaid.inline.scss
Normal file
163
quartz/components/styles/mermaid.inline.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -64,7 +64,7 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
@media all and ($desktop) {
|
@media all and not ($desktop) {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +106,7 @@
|
|||||||
flex: 0 0 min(30%, 450px);
|
flex: 0 0 min(30%, 450px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and not ($tablet) {
|
@media all and not ($mobile) {
|
||||||
&[data-preview] {
|
&[data-preview] {
|
||||||
& .result-card > p.preview {
|
& .result-card > p.preview {
|
||||||
display: none;
|
display: none;
|
||||||
@ -132,7 +132,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and ($tablet) {
|
@media all and ($mobile) {
|
||||||
& > #preview-container {
|
& > #preview-container {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@ -151,6 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > #preview-container {
|
& > #preview-container {
|
||||||
|
flex-grow: 1;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
@ -1,12 +1,20 @@
|
|||||||
|
@use "../../styles/variables.scss" as *;
|
||||||
|
|
||||||
.toc {
|
.toc {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&.desktop-only {
|
&.desktop-only {
|
||||||
display: flex;
|
|
||||||
max-height: 40%;
|
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;
|
||||||
|
@ -14,12 +14,16 @@ import uk from "./locales/uk-UA"
|
|||||||
import ru from "./locales/ru-RU"
|
import ru from "./locales/ru-RU"
|
||||||
import ko from "./locales/ko-KR"
|
import ko from "./locales/ko-KR"
|
||||||
import zh from "./locales/zh-CN"
|
import zh from "./locales/zh-CN"
|
||||||
|
import zhTw from "./locales/zh-TW"
|
||||||
import vi from "./locales/vi-VN"
|
import vi from "./locales/vi-VN"
|
||||||
import pt from "./locales/pt-BR"
|
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 cs from "./locales/cs-CZ"
|
||||||
|
import tr from "./locales/tr-TR"
|
||||||
|
import th from "./locales/th-TH"
|
||||||
|
import lt from "./locales/lt-LT"
|
||||||
|
|
||||||
export const TRANSLATIONS = {
|
export const TRANSLATIONS = {
|
||||||
"en-US": enUs,
|
"en-US": enUs,
|
||||||
@ -58,12 +62,16 @@ export const TRANSLATIONS = {
|
|||||||
"ru-RU": ru,
|
"ru-RU": ru,
|
||||||
"ko-KR": ko,
|
"ko-KR": ko,
|
||||||
"zh-CN": zh,
|
"zh-CN": zh,
|
||||||
|
"zh-TW": zhTw,
|
||||||
"vi-VN": vi,
|
"vi-VN": vi,
|
||||||
"pt-BR": pt,
|
"pt-BR": pt,
|
||||||
"hu-HU": hu,
|
"hu-HU": hu,
|
||||||
"fa-IR": fa,
|
"fa-IR": fa,
|
||||||
"pl-PL": pl,
|
"pl-PL": pl,
|
||||||
"cs-CZ": cs,
|
"cs-CZ": cs,
|
||||||
|
"tr-TR": tr,
|
||||||
|
"th-TH": th,
|
||||||
|
"lt-LT": lt,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const defaultTranslation = "en-US"
|
export const defaultTranslation = "en-US"
|
||||||
|
104
quartz/i18n/locales/lt-LT.ts
Normal file
104
quartz/i18n/locales/lt-LT.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Translation } from "./definition"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
propertyDefaults: {
|
||||||
|
title: "Be Pavadinimo",
|
||||||
|
description: "Aprašymas Nepateiktas",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
callout: {
|
||||||
|
note: "Pastaba",
|
||||||
|
abstract: "Santrauka",
|
||||||
|
info: "Informacija",
|
||||||
|
todo: "Darbų sąrašas",
|
||||||
|
tip: "Patarimas",
|
||||||
|
success: "Sėkmingas",
|
||||||
|
question: "Klausimas",
|
||||||
|
warning: "Įspėjimas",
|
||||||
|
failure: "Nesėkmingas",
|
||||||
|
danger: "Pavojus",
|
||||||
|
bug: "Klaida",
|
||||||
|
example: "Pavyzdys",
|
||||||
|
quote: "Citata",
|
||||||
|
},
|
||||||
|
backlinks: {
|
||||||
|
title: "Atgalinės Nuorodos",
|
||||||
|
noBacklinksFound: "Atgalinių Nuorodų Nerasta",
|
||||||
|
},
|
||||||
|
themeToggle: {
|
||||||
|
lightMode: "Šviesus Režimas",
|
||||||
|
darkMode: "Tamsus Režimas",
|
||||||
|
},
|
||||||
|
explorer: {
|
||||||
|
title: "Naršyklė",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
createdWith: "Sukurta Su",
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
title: "Grafiko Vaizdas",
|
||||||
|
},
|
||||||
|
recentNotes: {
|
||||||
|
title: "Naujausi Užrašai",
|
||||||
|
seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining} →`,
|
||||||
|
},
|
||||||
|
transcludes: {
|
||||||
|
transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`,
|
||||||
|
linkToOriginal: "Nuoroda į originalą",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
title: "Paieška",
|
||||||
|
searchBarPlaceholder: "Ieškoti",
|
||||||
|
},
|
||||||
|
tableOfContents: {
|
||||||
|
title: "Turinys",
|
||||||
|
},
|
||||||
|
contentMeta: {
|
||||||
|
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
rss: {
|
||||||
|
recentNotes: "Naujausi užrašai",
|
||||||
|
lastFewNotes: ({ count }) =>
|
||||||
|
count === 1
|
||||||
|
? "Paskutinis 1 užrašas"
|
||||||
|
: count < 10
|
||||||
|
? `Paskutiniai ${count} užrašai`
|
||||||
|
: `Paskutiniai ${count} užrašų`,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "Nerasta",
|
||||||
|
notFound:
|
||||||
|
"Arba šis puslapis yra pasiekiamas tik tam tikriems vartotojams, arba tokio puslapio nėra.",
|
||||||
|
home: "Grįžti į pagrindinį puslapį",
|
||||||
|
},
|
||||||
|
folderContent: {
|
||||||
|
folder: "Aplankas",
|
||||||
|
itemsUnderFolder: ({ count }) =>
|
||||||
|
count === 1
|
||||||
|
? "1 elementas šiame aplanke."
|
||||||
|
: count < 10
|
||||||
|
? `${count} elementai šiame aplanke.`
|
||||||
|
: `${count} elementų šiame aplanke.`,
|
||||||
|
},
|
||||||
|
tagContent: {
|
||||||
|
tag: "Žyma",
|
||||||
|
tagIndex: "Žymų indeksas",
|
||||||
|
itemsUnderTag: ({ count }) =>
|
||||||
|
count === 1
|
||||||
|
? "1 elementas su šia žyma."
|
||||||
|
: count < 10
|
||||||
|
? `${count} elementai su šia žyma.`
|
||||||
|
: `${count} elementų su šia žyma.`,
|
||||||
|
showingFirst: ({ count }) =>
|
||||||
|
count < 10 ? `Rodomos pirmosios ${count} žymos.` : `Rodomos pirmosios ${count} žymų.`,
|
||||||
|
totalTags: ({ count }) =>
|
||||||
|
count === 1
|
||||||
|
? "Rasta iš viso 1 žyma."
|
||||||
|
: count < 10
|
||||||
|
? `Rasta iš viso ${count} žymos.`
|
||||||
|
: `Rasta iš viso ${count} žymų.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Translation
|
82
quartz/i18n/locales/th-TH.ts
Normal file
82
quartz/i18n/locales/th-TH.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Translation } from "./definition"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
propertyDefaults: {
|
||||||
|
title: "ไม่มีชื่อ",
|
||||||
|
description: "ไม่ได้ระบุคำอธิบายย่อ",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
callout: {
|
||||||
|
note: "หมายเหตุ",
|
||||||
|
abstract: "บทคัดย่อ",
|
||||||
|
info: "ข้อมูล",
|
||||||
|
todo: "ต้องทำเพิ่มเติม",
|
||||||
|
tip: "คำแนะนำ",
|
||||||
|
success: "เรียบร้อย",
|
||||||
|
question: "คำถาม",
|
||||||
|
warning: "คำเตือน",
|
||||||
|
failure: "ข้อผิดพลาด",
|
||||||
|
danger: "อันตราย",
|
||||||
|
bug: "บั๊ก",
|
||||||
|
example: "ตัวอย่าง",
|
||||||
|
quote: "คำพูกยกมา",
|
||||||
|
},
|
||||||
|
backlinks: {
|
||||||
|
title: "หน้าที่กล่าวถึง",
|
||||||
|
noBacklinksFound: "ไม่มีหน้าที่โยงมาหน้านี้",
|
||||||
|
},
|
||||||
|
themeToggle: {
|
||||||
|
lightMode: "โหมดสว่าง",
|
||||||
|
darkMode: "โหมดมืด",
|
||||||
|
},
|
||||||
|
explorer: {
|
||||||
|
title: "รายการหน้า",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
createdWith: "สร้างด้วย",
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
title: "มุมมองกราฟ",
|
||||||
|
},
|
||||||
|
recentNotes: {
|
||||||
|
title: "บันทึกล่าสุด",
|
||||||
|
seeRemainingMore: ({ remaining }) => `ดูเพิ่มอีก ${remaining} รายการ →`,
|
||||||
|
},
|
||||||
|
transcludes: {
|
||||||
|
transcludeOf: ({ targetSlug }) => `รวมข้ามเนื้อหาจาก ${targetSlug}`,
|
||||||
|
linkToOriginal: "ดูหน้าต้นทาง",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
title: "ค้นหา",
|
||||||
|
searchBarPlaceholder: "ค้นหาบางอย่าง",
|
||||||
|
},
|
||||||
|
tableOfContents: {
|
||||||
|
title: "สารบัญ",
|
||||||
|
},
|
||||||
|
contentMeta: {
|
||||||
|
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
rss: {
|
||||||
|
recentNotes: "บันทึกล่าสุด",
|
||||||
|
lastFewNotes: ({ count }) => `${count} บันทึกล่าสุด`,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "ไม่มีหน้านี้",
|
||||||
|
notFound: "หน้านี้อาจตั้งค่าเป็นส่วนตัวหรือยังไม่ถูกสร้าง",
|
||||||
|
home: "กลับหน้าหลัก",
|
||||||
|
},
|
||||||
|
folderContent: {
|
||||||
|
folder: "โฟลเดอร์",
|
||||||
|
itemsUnderFolder: ({ count }) => `มี ${count} รายการในโฟลเดอร์นี้`,
|
||||||
|
},
|
||||||
|
tagContent: {
|
||||||
|
tag: "แท็ก",
|
||||||
|
tagIndex: "แท็กทั้งหมด",
|
||||||
|
itemsUnderTag: ({ count }) => `มี ${count} รายการในแท็กนี้`,
|
||||||
|
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
|
||||||
|
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Translation
|
84
quartz/i18n/locales/tr-TR.ts
Normal file
84
quartz/i18n/locales/tr-TR.ts
Normal 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
|
82
quartz/i18n/locales/zh-TW.ts
Normal file
82
quartz/i18n/locales/zh-TW.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Translation } from "./definition"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
propertyDefaults: {
|
||||||
|
title: "無題",
|
||||||
|
description: "無描述",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
callout: {
|
||||||
|
note: "筆記",
|
||||||
|
abstract: "摘要",
|
||||||
|
info: "提示",
|
||||||
|
todo: "待辦",
|
||||||
|
tip: "提示",
|
||||||
|
success: "成功",
|
||||||
|
question: "問題",
|
||||||
|
warning: "警告",
|
||||||
|
failure: "失敗",
|
||||||
|
danger: "危險",
|
||||||
|
bug: "錯誤",
|
||||||
|
example: "範例",
|
||||||
|
quote: "引用",
|
||||||
|
},
|
||||||
|
backlinks: {
|
||||||
|
title: "反向連結",
|
||||||
|
noBacklinksFound: "無法找到反向連結",
|
||||||
|
},
|
||||||
|
themeToggle: {
|
||||||
|
lightMode: "亮色模式",
|
||||||
|
darkMode: "暗色模式",
|
||||||
|
},
|
||||||
|
explorer: {
|
||||||
|
title: "探索",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
createdWith: "Created with",
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
title: "關係圖譜",
|
||||||
|
},
|
||||||
|
recentNotes: {
|
||||||
|
title: "最近的筆記",
|
||||||
|
seeRemainingMore: ({ remaining }) => `查看更多 ${remaining} 篇筆記 →`,
|
||||||
|
},
|
||||||
|
transcludes: {
|
||||||
|
transcludeOf: ({ targetSlug }) => `包含 ${targetSlug}`,
|
||||||
|
linkToOriginal: "指向原始筆記的連結",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
title: "搜尋",
|
||||||
|
searchBarPlaceholder: "搜尋些什麼",
|
||||||
|
},
|
||||||
|
tableOfContents: {
|
||||||
|
title: "目錄",
|
||||||
|
},
|
||||||
|
contentMeta: {
|
||||||
|
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
rss: {
|
||||||
|
recentNotes: "最近的筆記",
|
||||||
|
lastFewNotes: ({ count }) => `最近的 ${count} 條筆記`,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "無法找到",
|
||||||
|
notFound: "私人筆記或筆記不存在。",
|
||||||
|
home: "返回首頁",
|
||||||
|
},
|
||||||
|
folderContent: {
|
||||||
|
folder: "資料夾",
|
||||||
|
itemsUnderFolder: ({ count }) => `此資料夾下有 ${count} 條筆記。`,
|
||||||
|
},
|
||||||
|
tagContent: {
|
||||||
|
tag: "標籤",
|
||||||
|
tagIndex: "標籤索引",
|
||||||
|
itemsUnderTag: ({ count }) => `此標籤下有 ${count} 條筆記。`,
|
||||||
|
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
|
||||||
|
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Translation
|
@ -37,7 +37,6 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
|
|
||||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||||
const path = url.pathname as FullSlug
|
const path = url.pathname as FullSlug
|
||||||
const externalResources = pageResources(path, resources)
|
|
||||||
const notFound = i18n(cfg.locale).pages.error.title
|
const notFound = i18n(cfg.locale).pages.error.title
|
||||||
const [tree, vfile] = defaultProcessedContent({
|
const [tree, vfile] = defaultProcessedContent({
|
||||||
slug,
|
slug,
|
||||||
@ -45,6 +44,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
description: notFound,
|
description: notFound,
|
||||||
frontmatter: { title: notFound, tags: [] },
|
frontmatter: { title: notFound, tags: [] },
|
||||||
})
|
})
|
||||||
|
const externalResources = pageResources(path, vfile.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
ctx,
|
ctx,
|
||||||
fileData: vfile.data,
|
fileData: vfile.data,
|
||||||
|
@ -106,7 +106,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
containsIndex = true
|
containsIndex = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
ctx,
|
ctx,
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
|
@ -76,12 +76,11 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
|||||||
|
|
||||||
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 []
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -107,8 +106,8 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
|||||||
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const slug = joinSegments(folder, "index") as FullSlug
|
const slug = joinSegments(folder, "index") as FullSlug
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
|
||||||
const [tree, file] = folderDescriptions[folder]
|
const [tree, file] = folderDescriptions[folder]
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
ctx,
|
ctx,
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
@ -133,3 +132,14 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
@ -105,14 +105,17 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
|
|||||||
const tag = slug.slice("tags/".length)
|
const tag = slug.slice("tags/".length)
|
||||||
if (tags.has(tag)) {
|
if (tags.has(tag)) {
|
||||||
tagDescriptions[tag] = [tree, file]
|
tagDescriptions[tag] = [tree, file]
|
||||||
|
if (file.data.frontmatter?.title === tag) {
|
||||||
|
file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
const slug = joinSegments("tags", tag) as FullSlug
|
const slug = joinSegments("tags", tag) as FullSlug
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
|
||||||
const [tree, file] = tagDescriptions[tag]
|
const [tree, file] = tagDescriptions[tag]
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
ctx,
|
ctx,
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -71,6 +71,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
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"])
|
||||||
|
|
||||||
|
const created = coalesceAliases(data, ["created", "date"])
|
||||||
|
if (created) data.created = created
|
||||||
|
const modified = coalesceAliases(data, [
|
||||||
|
"modified",
|
||||||
|
"lastmod",
|
||||||
|
"updated",
|
||||||
|
"last-modified",
|
||||||
|
])
|
||||||
|
if (modified) data.modified = modified
|
||||||
|
const published = coalesceAliases(data, ["published", "publishDate", "date"])
|
||||||
|
if (published) data.published = published
|
||||||
|
|
||||||
|
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"]
|
||||||
}
|
}
|
||||||
@ -87,12 +103,17 @@ declare module "vfile" {
|
|||||||
} & Partial<{
|
} & Partial<{
|
||||||
tags: string[]
|
tags: string[]
|
||||||
aliases: string[]
|
aliases: string[]
|
||||||
|
modified: string
|
||||||
|
created: string
|
||||||
|
published: 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
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,11 +48,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
created ||= st.birthtimeMs
|
created ||= st.birthtimeMs
|
||||||
modified ||= st.mtimeMs
|
modified ||= st.mtimeMs
|
||||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||||
created ||= file.data.frontmatter.date as MaybeDate
|
created ||= file.data.frontmatter.created as MaybeDate
|
||||||
modified ||= file.data.frontmatter.lastmod as MaybeDate
|
modified ||= file.data.frontmatter.modified as MaybeDate
|
||||||
modified ||= file.data.frontmatter.updated as MaybeDate
|
published ||= file.data.frontmatter.published as MaybeDate
|
||||||
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
|
|
||||||
published ||= file.data.frontmatter.publishDate as MaybeDate
|
|
||||||
} else if (source === "git") {
|
} else if (source === "git") {
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
// Get a reference to the main git repo.
|
// Get a reference to the main git repo.
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
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
|
customMacros: MacroType
|
||||||
|
katexOptions: Omit<KatexOptions, "macros" | "output">
|
||||||
|
mathJaxOptions: Omit<MathjaxOptions, "macros">
|
||||||
|
typstOptions: TypstOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MacroType {
|
interface MacroType {
|
||||||
@ -21,30 +30,37 @@ export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
|||||||
return [remarkMath]
|
return [remarkMath]
|
||||||
},
|
},
|
||||||
htmlPlugins() {
|
htmlPlugins() {
|
||||||
if (engine === "katex") {
|
switch (engine) {
|
||||||
return [[rehypeKatex, { output: "html", macros }]]
|
case "katex": {
|
||||||
} else {
|
return [[rehypeKatex, { output: "html", macros, ...(opts?.katexOptions ?? {}) }]]
|
||||||
return [[rehypeMathjax, { macros }]]
|
}
|
||||||
|
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: [] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,20 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
|
import {
|
||||||
|
Root,
|
||||||
|
Html,
|
||||||
|
BlockContent,
|
||||||
|
PhrasingContent,
|
||||||
|
DefinitionContent,
|
||||||
|
Paragraph,
|
||||||
|
Code,
|
||||||
|
} from "mdast"
|
||||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
import { Element, Literal, Root as HtmlRoot } from "hast"
|
||||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||||
import rehypeRaw from "rehype-raw"
|
import rehypeRaw from "rehype-raw"
|
||||||
import { SKIP, visit } from "unist-util-visit"
|
import { SKIP, visit } from "unist-util-visit"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { splitAnchor } from "../../util/path"
|
import { splitAnchor } from "../../util/path"
|
||||||
import { JSResource } from "../../util/resources"
|
import { JSResource, CSSResource } from "../../util/resources"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import calloutScript from "../../components/scripts/callout.inline.ts"
|
import calloutScript from "../../components/scripts/callout.inline.ts"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -14,7 +22,6 @@ import checkboxScript from "../../components/scripts/checkbox.inline.ts"
|
|||||||
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
||||||
import { toHast } from "mdast-util-to-hast"
|
import { toHast } from "mdast-util-to-hast"
|
||||||
import { toHtml } from "hast-util-to-html"
|
import { toHtml } from "hast-util-to-html"
|
||||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
|
||||||
import { capitalize } from "../../util/lang"
|
import { capitalize } from "../../util/lang"
|
||||||
import { PluggableList } from "unified"
|
import { PluggableList } from "unified"
|
||||||
|
|
||||||
@ -114,19 +121,19 @@ export const wikilinkRegex = new RegExp(
|
|||||||
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm)
|
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm)
|
||||||
|
|
||||||
// matches any wikilink, only used for escaping wikilinks inside tables
|
// matches any wikilink, only used for escaping wikilinks inside tables
|
||||||
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\])/g)
|
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g)
|
||||||
|
|
||||||
const highlightRegex = new RegExp(/==([^=]+)==/g)
|
const highlightRegex = new RegExp(/==([^=]+)==/g)
|
||||||
const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
|
const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
|
||||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||||
const calloutRegex = new RegExp(/^\[\!(\w+)\|?(.+?)?\]([+-]?)/)
|
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
|
||||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm)
|
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm)
|
||||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
|
||||||
// #(...) -> capturing group, tag itself must start with #
|
// #(...) -> capturing group, tag itself must start with #
|
||||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
||||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||||
const tagRegex = new RegExp(
|
const tagRegex = new RegExp(
|
||||||
/(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
||||||
)
|
)
|
||||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
|
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
|
||||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||||
@ -153,7 +160,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
src = src.toString()
|
src = src.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
src = src.replace(commentRegex, "")
|
src = (src as string).replace(commentRegex, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// pre-transform blockquotes
|
// pre-transform blockquotes
|
||||||
@ -162,7 +169,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
src = src.toString()
|
src = src.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
src = src.replace(calloutLineRegex, (value) => {
|
src = (src as string).replace(calloutLineRegex, (value) => {
|
||||||
// force newline after title of callout
|
// force newline after title of callout
|
||||||
return value + "\n> "
|
return value + "\n> "
|
||||||
})
|
})
|
||||||
@ -175,7 +182,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replace all wikilinks inside a table first
|
// replace all wikilinks inside a table first
|
||||||
src = src.replace(tableRegex, (value) => {
|
src = (src as string).replace(tableRegex, (value) => {
|
||||||
// escape all aliases and headers in wikilinks inside a table
|
// escape all aliases and headers in wikilinks inside a table
|
||||||
return value.replace(tableWikilinkRegex, (_value, raw) => {
|
return value.replace(tableWikilinkRegex, (_value, raw) => {
|
||||||
// const [raw]: (string | undefined)[] = capture
|
// const [raw]: (string | undefined)[] = capture
|
||||||
@ -189,7 +196,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
})
|
})
|
||||||
|
|
||||||
// replace all other wikilinks
|
// replace all other wikilinks
|
||||||
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
src = (src as string).replace(wikilinkRegex, (value, ...capture) => {
|
||||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||||
|
|
||||||
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
|
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
|
||||||
@ -279,6 +286,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
// internal link
|
// internal link
|
||||||
const url = fp + anchor
|
const url = fp + anchor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "link",
|
type: "link",
|
||||||
url,
|
url,
|
||||||
@ -430,7 +438,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
value: useDefaultTitle ? capitalize(typeString) : titleContent + " ",
|
value: useDefaultTitle
|
||||||
|
? capitalize(typeString).replace(/-/g, " ")
|
||||||
|
: titleContent + " ",
|
||||||
},
|
},
|
||||||
...restOfTitle,
|
...restOfTitle,
|
||||||
],
|
],
|
||||||
@ -507,12 +517,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
if (opts.mermaid) {
|
if (opts.mermaid) {
|
||||||
plugins.push(() => {
|
plugins.push(() => {
|
||||||
return (tree: Root, _file) => {
|
return (tree: Root, file) => {
|
||||||
visit(tree, "code", (node: Code) => {
|
visit(tree, "code", (node: Code) => {
|
||||||
if (node.lang === "mermaid") {
|
if (node.lang === "mermaid") {
|
||||||
|
file.data.hasMermaidDiagram = true
|
||||||
node.data = {
|
node.data = {
|
||||||
hProperties: {
|
hProperties: {
|
||||||
className: ["mermaid"],
|
className: ["mermaid"],
|
||||||
|
"data-clipboard": JSON.stringify(node.value),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -657,10 +669,138 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.mermaid) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: HtmlRoot, _file) => {
|
||||||
|
visit(tree, "element", (node: Element, _idx, parent) => {
|
||||||
|
if (
|
||||||
|
node.tagName === "code" &&
|
||||||
|
((node.properties?.className ?? []) as string[])?.includes("mermaid")
|
||||||
|
) {
|
||||||
|
parent!.children = [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "button",
|
||||||
|
properties: {
|
||||||
|
className: ["expand-button"],
|
||||||
|
"aria-label": "Expand mermaid diagram",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
"data-view-component": true,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "svg",
|
||||||
|
properties: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: "0 0 16 16",
|
||||||
|
fill: "currentColor",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "path",
|
||||||
|
properties: {
|
||||||
|
fillRule: "evenodd",
|
||||||
|
d: "M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
node,
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "div",
|
||||||
|
properties: { id: "mermaid-container" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "div",
|
||||||
|
properties: { id: "mermaid-space" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "div",
|
||||||
|
properties: { className: ["mermaid-header"] },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "button",
|
||||||
|
properties: {
|
||||||
|
className: ["close-button"],
|
||||||
|
"aria-label": "close button",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "svg",
|
||||||
|
properties: {
|
||||||
|
"aria-hidden": "true",
|
||||||
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "line",
|
||||||
|
properties: {
|
||||||
|
x1: 18,
|
||||||
|
y1: 6,
|
||||||
|
x2: 6,
|
||||||
|
y2: 18,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "line",
|
||||||
|
properties: {
|
||||||
|
x1: 6,
|
||||||
|
y1: 6,
|
||||||
|
x2: 18,
|
||||||
|
y2: 18,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "div",
|
||||||
|
properties: { className: ["mermaid-content"] },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
},
|
},
|
||||||
externalResources() {
|
externalResources() {
|
||||||
const js: JSResource[] = []
|
const js: JSResource[] = []
|
||||||
|
const css: CSSResource[] = []
|
||||||
|
|
||||||
if (opts.enableCheckbox) {
|
if (opts.enableCheckbox) {
|
||||||
js.push({
|
js.push({
|
||||||
@ -678,34 +818,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.mermaid) {
|
return { js, css }
|
||||||
js.push({
|
|
||||||
script: `
|
|
||||||
let mermaidImport = undefined
|
|
||||||
document.addEventListener('nav', async () => {
|
|
||||||
if (document.querySelector("code.mermaid")) {
|
|
||||||
mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs')
|
|
||||||
const mermaid = mermaidImport.default
|
|
||||||
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
securityLevel: 'loose',
|
|
||||||
theme: darkMode ? 'dark' : 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
await mermaid.run({
|
|
||||||
querySelector: '.mermaid'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
loadTime: "afterDOMReady",
|
|
||||||
moduleType: "module",
|
|
||||||
contentType: "inline",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { js }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -714,5 +827,6 @@ declare module "vfile" {
|
|||||||
interface DataMap {
|
interface DataMap {
|
||||||
blocks: Record<string, Element>
|
blocks: Record<string, Element>
|
||||||
htmlAst: HtmlRoot
|
htmlAst: HtmlRoot
|
||||||
|
hasMermaidDiagram: boolean | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Node, Parent } from "hast"
|
import { Root as HtmlRoot } from "hast"
|
||||||
|
import { Root as MdRoot } from "mdast"
|
||||||
import { Data, VFile } from "vfile"
|
import { Data, VFile } from "vfile"
|
||||||
|
|
||||||
export type QuartzPluginData = Data
|
export type QuartzPluginData = Data
|
||||||
export type ProcessedContent = [Node, VFile]
|
export type MarkdownContent = [MdRoot, VFile]
|
||||||
|
export type ProcessedContent = [HtmlRoot, VFile]
|
||||||
|
|
||||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
||||||
const root: Parent = { type: "root", children: [] }
|
const root: HtmlRoot = { type: "root", children: [] }
|
||||||
const vfile = new VFile("")
|
const vfile = new VFile("")
|
||||||
vfile.data = vfileData
|
vfile.data = vfileData
|
||||||
return [root, vfile]
|
return [root, vfile]
|
||||||
|
@ -4,18 +4,20 @@ import remarkRehype from "remark-rehype"
|
|||||||
import { Processor, unified } from "unified"
|
import { Processor, unified } from "unified"
|
||||||
import { Root as MDRoot } from "remark-parse/lib"
|
import { Root as MDRoot } from "remark-parse/lib"
|
||||||
import { Root as HTMLRoot } from "hast"
|
import { Root as HTMLRoot } from "hast"
|
||||||
import { ProcessedContent } from "../plugins/vfile"
|
import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
|
||||||
import { PerfTimer } from "../util/perf"
|
import { PerfTimer } from "../util/perf"
|
||||||
import { read } from "to-vfile"
|
import { read } from "to-vfile"
|
||||||
import { FilePath, QUARTZ, slugifyFilePath } from "../util/path"
|
import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import workerpool, { Promise as WorkerPromise } from "workerpool"
|
import workerpool, { Promise as WorkerPromise } from "workerpool"
|
||||||
import { QuartzLogger } from "../util/log"
|
import { QuartzLogger } from "../util/log"
|
||||||
import { trace } from "../util/trace"
|
import { trace } from "../util/trace"
|
||||||
import { BuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
|
|
||||||
export type QuartzProcessor = Processor<MDRoot, MDRoot, HTMLRoot>
|
export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
|
||||||
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
|
||||||
|
|
||||||
|
export function createMdProcessor(ctx: BuildCtx): QuartzMdProcessor {
|
||||||
const transformers = ctx.cfg.plugins.transformers
|
const transformers = ctx.cfg.plugins.transformers
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -24,14 +26,20 @@ export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
|||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
// MD AST -> MD AST transforms
|
// MD AST -> MD AST transforms
|
||||||
.use(
|
.use(
|
||||||
transformers
|
transformers.flatMap((plugin) => plugin.markdownPlugins?.(ctx) ?? []),
|
||||||
.filter((p) => p.markdownPlugins)
|
) as unknown as QuartzMdProcessor
|
||||||
.flatMap((plugin) => plugin.markdownPlugins!(ctx)),
|
// ^ sadly the typing of `use` is not smart enough to infer the correct type from our plugin list
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHtmlProcessor(ctx: BuildCtx): QuartzHtmlProcessor {
|
||||||
|
const transformers = ctx.cfg.plugins.transformers
|
||||||
|
return (
|
||||||
|
unified()
|
||||||
// MD AST -> HTML AST
|
// MD AST -> HTML AST
|
||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
// HTML AST -> HTML AST transforms
|
// HTML AST -> HTML AST transforms
|
||||||
.use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
|
.use(transformers.flatMap((plugin) => plugin.htmlPlugins?.(ctx) ?? []))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,8 +83,8 @@ async function transpileWorkerScript() {
|
|||||||
|
|
||||||
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
||||||
const { argv, cfg } = ctx
|
const { argv, cfg } = ctx
|
||||||
return async (processor: QuartzProcessor) => {
|
return async (processor: QuartzMdProcessor) => {
|
||||||
const res: ProcessedContent[] = []
|
const res: MarkdownContent[] = []
|
||||||
for (const fp of fps) {
|
for (const fp of fps) {
|
||||||
try {
|
try {
|
||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
@ -100,10 +108,32 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
|||||||
res.push([newAst, file])
|
res.push([newAst, file])
|
||||||
|
|
||||||
if (argv.verbose) {
|
if (argv.verbose) {
|
||||||
console.log(`[process] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
|
console.log(`[markdown] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
trace(`\nFailed to process \`${fp}\``, err as Error)
|
trace(`\nFailed to process markdown \`${fp}\``, err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownParser(ctx: BuildCtx, mdContent: MarkdownContent[]) {
|
||||||
|
return async (processor: QuartzHtmlProcessor) => {
|
||||||
|
const res: ProcessedContent[] = []
|
||||||
|
for (const [ast, file] of mdContent) {
|
||||||
|
try {
|
||||||
|
const perf = new PerfTimer()
|
||||||
|
|
||||||
|
const newAst = await processor.run(ast as MDRoot, file)
|
||||||
|
res.push([newAst, file])
|
||||||
|
|
||||||
|
if (ctx.argv.verbose) {
|
||||||
|
console.log(`[html] ${file.data.slug} (${perf.timeSince()})`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
trace(`\nFailed to process html \`${file.data.filePath}\``, err as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +143,7 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
|||||||
|
|
||||||
const clamp = (num: number, min: number, max: number) =>
|
const clamp = (num: number, min: number, max: number) =>
|
||||||
Math.min(Math.max(Math.round(num), min), max)
|
Math.min(Math.max(Math.round(num), min), max)
|
||||||
|
|
||||||
export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<ProcessedContent[]> {
|
export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<ProcessedContent[]> {
|
||||||
const { argv } = ctx
|
const { argv } = ctx
|
||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
@ -126,9 +157,8 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
|||||||
log.start(`Parsing input files using ${concurrency} threads`)
|
log.start(`Parsing input files using ${concurrency} threads`)
|
||||||
if (concurrency === 1) {
|
if (concurrency === 1) {
|
||||||
try {
|
try {
|
||||||
const processor = createProcessor(ctx)
|
const mdRes = await createFileParser(ctx, fps)(createMdProcessor(ctx))
|
||||||
const parse = createFileParser(ctx, fps)
|
res = await createMarkdownParser(ctx, mdRes)(createHtmlProcessor(ctx))
|
||||||
res = await parse(processor)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.end()
|
log.end()
|
||||||
throw error
|
throw error
|
||||||
@ -140,17 +170,27 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
|||||||
maxWorkers: concurrency,
|
maxWorkers: concurrency,
|
||||||
workerType: "thread",
|
workerType: "thread",
|
||||||
})
|
})
|
||||||
|
const errorHandler = (err: any) => {
|
||||||
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
|
console.error(`${err}`.replace(/^error:\s*/i, ""))
|
||||||
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
process.exit(1)
|
||||||
childPromises.push(pool.exec("parseFiles", [ctx.buildId, argv, chunk, ctx.allSlugs]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch((err) => {
|
const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
|
||||||
const errString = err.toString().slice("Error:".length)
|
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
||||||
console.error(errString)
|
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
|
||||||
process.exit(1)
|
}
|
||||||
})
|
const mdResults: [MarkdownContent[], FullSlug[]][] =
|
||||||
|
await WorkerPromise.all(mdPromises).catch(errorHandler)
|
||||||
|
|
||||||
|
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
|
||||||
|
for (const [_, extraSlugs] of mdResults) {
|
||||||
|
ctx.allSlugs.push(...extraSlugs)
|
||||||
|
}
|
||||||
|
for (const [mdChunk, _] of mdResults) {
|
||||||
|
childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
|
||||||
|
}
|
||||||
|
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)
|
||||||
|
|
||||||
res = results.flat()
|
res = results.flat()
|
||||||
await pool.terminate()
|
await pool.terminate()
|
||||||
}
|
}
|
||||||
|
99
quartz/static/giscus/dark.css
Normal file
99
quartz/static/giscus/dark.css
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/*! MIT License
|
||||||
|
* Copyright (c) 2018 GitHub Inc.
|
||||||
|
* https://github.com/primer/primitives/blob/main/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
main {
|
||||||
|
--color-prettylights-syntax-comment: #8b949e;
|
||||||
|
--color-prettylights-syntax-constant: #79c0ff;
|
||||||
|
--color-prettylights-syntax-entity: #d2a8ff;
|
||||||
|
--color-prettylights-syntax-storage-modifier-import: #c9d1d9;
|
||||||
|
--color-prettylights-syntax-entity-tag: #7ee787;
|
||||||
|
--color-prettylights-syntax-keyword: #ff7b72;
|
||||||
|
--color-prettylights-syntax-string: #a5d6ff;
|
||||||
|
--color-prettylights-syntax-variable: #ffa657;
|
||||||
|
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
|
||||||
|
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
|
||||||
|
--color-prettylights-syntax-invalid-illegal-bg: #8e1519;
|
||||||
|
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
|
||||||
|
--color-prettylights-syntax-carriage-return-bg: #b62324;
|
||||||
|
--color-prettylights-syntax-string-regexp: #7ee787;
|
||||||
|
--color-prettylights-syntax-markup-list: #f2cc60;
|
||||||
|
--color-prettylights-syntax-markup-heading: #1f6feb;
|
||||||
|
--color-prettylights-syntax-markup-italic: #c9d1d9;
|
||||||
|
--color-prettylights-syntax-markup-bold: #c9d1d9;
|
||||||
|
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
|
||||||
|
--color-prettylights-syntax-markup-deleted-bg: #67060c;
|
||||||
|
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
|
||||||
|
--color-prettylights-syntax-markup-inserted-bg: #033a16;
|
||||||
|
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
|
||||||
|
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
|
||||||
|
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
|
||||||
|
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
|
||||||
|
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
|
||||||
|
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
|
||||||
|
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
|
||||||
|
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
|
||||||
|
--color-btn-text: #d4d4d4; /* --darkgray */
|
||||||
|
--color-btn-bg: #161618; /* --light */
|
||||||
|
--color-btn-border: rgb(240, 246, 252 / 10%); /* --dark */
|
||||||
|
--color-btn-shadow: 0 0 transparent;
|
||||||
|
--color-btn-inset-shadow: 0 0 transparent;
|
||||||
|
--color-btn-hover-bg: #30363d;
|
||||||
|
--color-btn-hover-border: #8b949e;
|
||||||
|
--color-btn-active-bg: hsl(212deg 12% 18% / 100%);
|
||||||
|
--color-btn-active-border: #6e7681;
|
||||||
|
--color-btn-selected-bg: #161b22;
|
||||||
|
--color-btn-primary-text: #fff;
|
||||||
|
--color-btn-primary-bg: #84a59d; /* --tertiary */
|
||||||
|
--color-btn-primary-border: rgb(240, 246, 252 / 10%); /* --dark */
|
||||||
|
--color-btn-primary-shadow: 0 0 transparent;
|
||||||
|
--color-btn-primary-inset-shadow: 0 0 transparent;
|
||||||
|
--color-btn-primary-hover-bg: #7b97aa; /* --secondary */
|
||||||
|
--color-btn-primary-hover-border: rgb(240, 246, 252 / 10%); /* --dark */
|
||||||
|
--color-btn-primary-selected-bg: #7b97aa; /* --secondary */
|
||||||
|
--color-btn-primary-selected-shadow: 0 0 transparent;
|
||||||
|
--color-btn-primary-disabled-text: rgba(33, 32, 32, 0.5);
|
||||||
|
--color-btn-primary-disabled-bg: rgb(35 134 54 / 60%);
|
||||||
|
--color-btn-primary-disabled-border: rgb(240 246 252 / 10%);
|
||||||
|
--color-action-list-item-default-hover-bg: rgb(177 186 196 / 12%);
|
||||||
|
--color-segmented-control-bg: rgb(110 118 129 / 10%);
|
||||||
|
--color-segmented-control-button-bg: #0d1117;
|
||||||
|
--color-segmented-control-button-selected-border: #6e7681;
|
||||||
|
--color-fg-default: #ebebec; /* --dark */
|
||||||
|
--color-fg-muted: #d4d4d4; /* --darkgray */
|
||||||
|
--color-fg-subtle: #d4d4d4; /* --darkgray */
|
||||||
|
--color-canvas-default: #0d1117;
|
||||||
|
--color-canvas-overlay: #161b22;
|
||||||
|
--color-canvas-inset: #010409;
|
||||||
|
--color-canvas-subtle: #161b22;
|
||||||
|
--color-border-default: #30363d;
|
||||||
|
--color-border-muted: #21262d;
|
||||||
|
--color-neutral-muted: rgb(110 118 129 / 40%);
|
||||||
|
--color-accent-fg: #2f81f7;
|
||||||
|
--color-accent-emphasis: #1f6feb;
|
||||||
|
--color-accent-muted: rgb(56 139 253 / 40%);
|
||||||
|
--color-accent-subtle: rgb(56 139 253 / 10%);
|
||||||
|
--color-success-fg: #3fb950;
|
||||||
|
--color-attention-fg: #d29922;
|
||||||
|
--color-attention-muted: rgb(187 128 9 / 40%);
|
||||||
|
--color-attention-subtle: rgb(187 128 9 / 15%);
|
||||||
|
--color-danger-fg: #f85149;
|
||||||
|
--color-danger-muted: rgb(248 81 73 / 40%);
|
||||||
|
--color-danger-subtle: rgb(248 81 73 / 10%);
|
||||||
|
--color-primer-shadow-inset: 0 0 transparent;
|
||||||
|
--color-scale-gray-7: #21262d;
|
||||||
|
--color-scale-blue-8: #0c2d6b;
|
||||||
|
|
||||||
|
/*! Extensions from @primer/css/alerts/flash.scss */
|
||||||
|
--color-social-reaction-bg-hover: var(--color-scale-gray-7);
|
||||||
|
--color-social-reaction-bg-reacted-hover: var(--color-scale-blue-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
main .pagination-loader-container {
|
||||||
|
background-image: url("https://github.com/images/modules/pulls/progressive-disclosure-line-dark.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
main .gsc-loading-image {
|
||||||
|
background-image: url("https://github.githubassets.com/images/mona-loading-dark.gif");
|
||||||
|
}
|
99
quartz/static/giscus/light.css
Normal file
99
quartz/static/giscus/light.css
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/*! MIT License
|
||||||
|
* Copyright (c) 2018 GitHub Inc.
|
||||||
|
* https://github.com/primer/primitives/blob/main/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
main {
|
||||||
|
--color-prettylights-syntax-comment: #6e7781;
|
||||||
|
--color-prettylights-syntax-constant: #0550ae;
|
||||||
|
--color-prettylights-syntax-entity: #8250df;
|
||||||
|
--color-prettylights-syntax-storage-modifier-import: #24292f;
|
||||||
|
--color-prettylights-syntax-entity-tag: #116329;
|
||||||
|
--color-prettylights-syntax-keyword: #cf222e;
|
||||||
|
--color-prettylights-syntax-string: #0a3069;
|
||||||
|
--color-prettylights-syntax-variable: #953800;
|
||||||
|
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
|
||||||
|
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
|
||||||
|
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
|
||||||
|
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
|
||||||
|
--color-prettylights-syntax-carriage-return-bg: #cf222e;
|
||||||
|
--color-prettylights-syntax-string-regexp: #116329;
|
||||||
|
--color-prettylights-syntax-markup-list: #3b2300;
|
||||||
|
--color-prettylights-syntax-markup-heading: #0550ae;
|
||||||
|
--color-prettylights-syntax-markup-italic: #24292f;
|
||||||
|
--color-prettylights-syntax-markup-bold: #24292f;
|
||||||
|
--color-prettylights-syntax-markup-deleted-text: #82071e;
|
||||||
|
--color-prettylights-syntax-markup-deleted-bg: #ffebe9;
|
||||||
|
--color-prettylights-syntax-markup-inserted-text: #116329;
|
||||||
|
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
|
||||||
|
--color-prettylights-syntax-markup-changed-text: #953800;
|
||||||
|
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
|
||||||
|
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
|
||||||
|
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
|
||||||
|
--color-prettylights-syntax-meta-diff-range: #8250df;
|
||||||
|
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
|
||||||
|
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
|
||||||
|
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
|
||||||
|
--color-btn-text: #4e4e4e; /* --darkgray */
|
||||||
|
--color-btn-bg: #faf8f8; /* --light */
|
||||||
|
--color-btn-border: rgb(43, 43, 43 / 15%); /* --dark */
|
||||||
|
--color-btn-shadow: 0 1px 0 rgb(31 35 40 / 4%);
|
||||||
|
--color-btn-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 25%);
|
||||||
|
--color-btn-hover-bg: #f3f4f6;
|
||||||
|
--color-btn-hover-border: rgb(43, 43, 43 / 15%); /* --dark */
|
||||||
|
--color-btn-active-bg: hsl(220deg 14% 93% / 100%);
|
||||||
|
--color-btn-active-border: rgb(31 35 40 / 15%);
|
||||||
|
--color-btn-selected-bg: hsl(220deg 14% 94% / 100%);
|
||||||
|
--color-btn-primary-text: #fff;
|
||||||
|
--color-btn-primary-bg: #84a59d; /* --tertiary */
|
||||||
|
--color-btn-primary-border: rgb(43, 43, 43 / 15%); /* --dark */
|
||||||
|
--color-btn-primary-shadow: 0 1px 0 rgb(31 35 40 / 10%);
|
||||||
|
--color-btn-primary-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
|
||||||
|
--color-btn-primary-hover-bg: #284b63; /* --secondary */
|
||||||
|
--color-btn-primary-hover-border: rgb(43, 43, 43 / 15%); /* --dark */
|
||||||
|
--color-btn-primary-selected-bg: #284b63; /* --secondary */
|
||||||
|
--color-btn-primary-selected-shadow: inset 0 1px 0 rgb(0 45 17 / 20%);
|
||||||
|
--color-btn-primary-disabled-text: rgb(255 255 255 / 80%);
|
||||||
|
--color-btn-primary-disabled-bg: #94d3a2;
|
||||||
|
--color-btn-primary-disabled-border: rgb(31 35 40 / 15%);
|
||||||
|
--color-action-list-item-default-hover-bg: rgb(208 215 222 / 32%);
|
||||||
|
--color-segmented-control-bg: #eaeef2;
|
||||||
|
--color-segmented-control-button-bg: #fff;
|
||||||
|
--color-segmented-control-button-selected-border: #8c959f;
|
||||||
|
--color-fg-default: #2b2b2b; /* --dark */
|
||||||
|
--color-fg-muted: #4e4e4e; /* --darkgray */
|
||||||
|
--color-fg-subtle: #4e4e4e; /* --darkgray */
|
||||||
|
--color-canvas-default: #fff;
|
||||||
|
--color-canvas-overlay: #fff;
|
||||||
|
--color-canvas-inset: #f6f8fa;
|
||||||
|
--color-canvas-subtle: #f6f8fa;
|
||||||
|
--color-border-default: #d0d7de;
|
||||||
|
--color-border-muted: hsl(210deg 18% 87% / 100%);
|
||||||
|
--color-neutral-muted: rgb(175 184 193 / 20%);
|
||||||
|
--color-accent-fg: #0969da;
|
||||||
|
--color-accent-emphasis: #0969da;
|
||||||
|
--color-accent-muted: rgb(84 174 255 / 40%);
|
||||||
|
--color-accent-subtle: #ddf4ff;
|
||||||
|
--color-success-fg: #1a7f37;
|
||||||
|
--color-attention-fg: #9a6700;
|
||||||
|
--color-attention-muted: rgb(212 167 44 / 40%);
|
||||||
|
--color-attention-subtle: #fff8c5;
|
||||||
|
--color-danger-fg: #d1242f;
|
||||||
|
--color-danger-muted: rgb(255 129 130 / 40%);
|
||||||
|
--color-danger-subtle: #ffebe9;
|
||||||
|
--color-primer-shadow-inset: inset 0 1px 0 rgb(208 215 222 / 20%);
|
||||||
|
--color-scale-gray-1: #eaeef2;
|
||||||
|
--color-scale-blue-1: #b6e3ff;
|
||||||
|
|
||||||
|
/*! Extensions from @primer/css/alerts/flash.scss */
|
||||||
|
--color-social-reaction-bg-hover: var(--color-scale-gray-1);
|
||||||
|
--color-social-reaction-bg-reacted-hover: var(--color-scale-blue-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main .pagination-loader-container {
|
||||||
|
background-image: url("https://github.com/images/modules/pulls/progressive-disclosure-line.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
main .gsc-loading-image {
|
||||||
|
background-image: url("https://github.githubassets.com/images/mona-loading-default.gif");
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
@use "sass:map";
|
||||||
|
|
||||||
@use "./variables.scss" as *;
|
@use "./variables.scss" as *;
|
||||||
@use "./syntax.scss";
|
@use "./syntax.scss";
|
||||||
@use "./callouts.scss";
|
@use "./callouts.scss";
|
||||||
@ -85,7 +87,7 @@ a {
|
|||||||
line-height: 1.4rem;
|
line-height: 1.4rem;
|
||||||
|
|
||||||
&:has(> img) {
|
&:has(> img) {
|
||||||
background-color: none;
|
background-color: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@ -121,7 +123,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: calc(#{map-get($breakpoints, desktop)} + 300px);
|
max-width: calc(#{map.get($breakpoints, desktop)} + 300px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
& article {
|
& article {
|
||||||
& > h1 {
|
& > h1 {
|
||||||
@ -151,27 +153,28 @@ a {
|
|||||||
|
|
||||||
& > #quartz-body {
|
& > #quartz-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: #{map-get($desktopGrid, templateColumns)};
|
grid-template-columns: #{map.get($desktopGrid, templateColumns)};
|
||||||
grid-template-rows: #{map-get($desktopGrid, templateRows)};
|
grid-template-rows: #{map.get($desktopGrid, templateRows)};
|
||||||
column-gap: #{map-get($desktopGrid, columnGap)};
|
column-gap: #{map.get($desktopGrid, columnGap)};
|
||||||
row-gap: #{map-get($desktopGrid, rowGap)};
|
row-gap: #{map.get($desktopGrid, rowGap)};
|
||||||
grid-template-areas: #{map-get($desktopGrid, templateAreas)};
|
grid-template-areas: #{map.get($desktopGrid, templateAreas)};
|
||||||
@media all and ($desktop) {
|
|
||||||
grid-template-columns: #{map-get($tabletGrid, templateColumns)};
|
@media all and ($tablet) {
|
||||||
grid-template-rows: #{map-get($tabletGrid, templateRows)};
|
grid-template-columns: #{map.get($tabletGrid, templateColumns)};
|
||||||
column-gap: #{map-get($tabletGrid, columnGap)};
|
grid-template-rows: #{map.get($tabletGrid, templateRows)};
|
||||||
row-gap: #{map-get($tabletGrid, rowGap)};
|
column-gap: #{map.get($tabletGrid, columnGap)};
|
||||||
grid-template-areas: #{map-get($tabletGrid, templateAreas)};
|
row-gap: #{map.get($tabletGrid, rowGap)};
|
||||||
|
grid-template-areas: #{map.get($tabletGrid, templateAreas)};
|
||||||
}
|
}
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
grid-template-columns: #{map-get($mobileGrid, templateColumns)};
|
grid-template-columns: #{map.get($mobileGrid, templateColumns)};
|
||||||
grid-template-rows: #{map-get($mobileGrid, templateRows)};
|
grid-template-rows: #{map.get($mobileGrid, templateRows)};
|
||||||
column-gap: #{map-get($mobileGrid, columnGap)};
|
column-gap: #{map.get($mobileGrid, columnGap)};
|
||||||
row-gap: #{map-get($mobileGrid, rowGap)};
|
row-gap: #{map.get($mobileGrid, rowGap)};
|
||||||
grid-template-areas: #{map-get($mobileGrid, templateAreas)};
|
grid-template-areas: #{map.get($mobileGrid, templateAreas)};
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and ($desktop) {
|
@media all and not ($desktop) {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
@ -212,7 +215,7 @@ a {
|
|||||||
margin-left: inherit;
|
margin-left: inherit;
|
||||||
margin-right: inherit;
|
margin-right: inherit;
|
||||||
}
|
}
|
||||||
@media all and ($desktop) {
|
@media all and not ($desktop) {
|
||||||
position: initial;
|
position: initial;
|
||||||
height: unset;
|
height: unset;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -254,10 +257,11 @@ a {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@media all and ($desktop) {
|
@media all and ($tablet) {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
|
margin-right: 0;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -387,7 +391,7 @@ figure[data-rehype-pretty-code-figure] {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border: 1px solid var(--lightgray);
|
border: 1px solid var(--lightgray);
|
||||||
width: max-content;
|
width: fit-content;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin-bottom: -0.5rem;
|
margin-bottom: -0.5rem;
|
||||||
color: var(--darkgray);
|
color: var(--darkgray);
|
||||||
@ -511,6 +515,7 @@ img {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
content-visibility: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
p > img + em {
|
p > img + em {
|
||||||
@ -586,3 +591,14 @@ iframe.pdf {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigation-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--secondary);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use "sass:map";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout breakpoints
|
* Layout breakpoints
|
||||||
* $mobile: screen width below this value will use mobile styles
|
* $mobile: screen width below this value will use mobile styles
|
||||||
@ -10,11 +12,11 @@ $breakpoints: (
|
|||||||
desktop: 1200px,
|
desktop: 1200px,
|
||||||
);
|
);
|
||||||
|
|
||||||
$mobile: "(max-width: #{map-get($breakpoints, mobile)})";
|
$mobile: "(max-width: #{map.get($breakpoints, mobile)})";
|
||||||
$tablet: "(min-width: #{map-get($breakpoints, mobile)}) and (max-width: #{map-get($breakpoints, desktop)})";
|
$tablet: "(min-width: #{map.get($breakpoints, mobile)}) and (max-width: #{map.get($breakpoints, desktop)})";
|
||||||
$desktop: "(max-width: #{map-get($breakpoints, desktop)})";
|
$desktop: "(min-width: #{map.get($breakpoints, desktop)})";
|
||||||
|
|
||||||
$pageWidth: #{map-get($breakpoints, mobile)};
|
$pageWidth: #{map.get($breakpoints, mobile)};
|
||||||
$sidePanelWidth: 320px; //380px;
|
$sidePanelWidth: 320px; //380px;
|
||||||
$topSpacing: 6rem;
|
$topSpacing: 6rem;
|
||||||
$boldWeight: 700;
|
$boldWeight: 700;
|
||||||
|
@ -6,3 +6,12 @@ export const escapeHTML = (unsafe: string) => {
|
|||||||
.replaceAll('"', """)
|
.replaceAll('"', """)
|
||||||
.replaceAll("'", "'")
|
.replaceAll("'", "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const unescapeHTML = (html: string) => {
|
||||||
|
return html
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll(""", '"')
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
202
quartz/util/og.tsx
Normal file
202
quartz/util/og.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { FontWeight, SatoriOptions } from "satori/wasm"
|
||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { JSXInternal } from "preact/src/jsx"
|
||||||
|
import { ThemeKey } from "./theme"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of `FontOptions` (for satori) given google font names
|
||||||
|
* @param headerFontName name of google font used for header
|
||||||
|
* @param bodyFontName name of google font used for body
|
||||||
|
* @returns FontOptions for header and body
|
||||||
|
*/
|
||||||
|
export async function getSatoriFont(headerFontName: string, bodyFontName: string) {
|
||||||
|
const headerWeight = 700 as FontWeight
|
||||||
|
const bodyWeight = 400 as FontWeight
|
||||||
|
|
||||||
|
// Fetch fonts
|
||||||
|
const headerFont = await fetchTtf(headerFontName, headerWeight)
|
||||||
|
const bodyFont = await fetchTtf(bodyFontName, bodyWeight)
|
||||||
|
|
||||||
|
// Convert fonts to satori font format and return
|
||||||
|
const fonts: SatoriOptions["fonts"] = [
|
||||||
|
{ name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
|
||||||
|
{ name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
|
||||||
|
]
|
||||||
|
return fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the `.ttf` file of a google font
|
||||||
|
* @param fontName name of google font
|
||||||
|
* @param weight what font weight to fetch font
|
||||||
|
* @returns `.ttf` file of google font
|
||||||
|
*/
|
||||||
|
async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
// Get css file from google fonts
|
||||||
|
const cssResponse = await fetch(
|
||||||
|
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
|
||||||
|
)
|
||||||
|
const css = await cssResponse.text()
|
||||||
|
|
||||||
|
// Extract .ttf url from css file
|
||||||
|
const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
|
||||||
|
const match = urlRegex.exec(css)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Could not fetch font")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve font data as ArrayBuffer
|
||||||
|
const fontResponse = await fetch(match[1])
|
||||||
|
|
||||||
|
// fontData is an ArrayBuffer containing the .ttf file data (get match[1] due to google fonts response format, always contains link twice, but second entry is the "raw" link)
|
||||||
|
const fontData = await fontResponse.arrayBuffer()
|
||||||
|
|
||||||
|
return fontData
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Error fetching font: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialImageOptions = {
|
||||||
|
/**
|
||||||
|
* What color scheme to use for image generation (uses colors from config theme)
|
||||||
|
*/
|
||||||
|
colorScheme: ThemeKey
|
||||||
|
/**
|
||||||
|
* Height to generate image with in pixels (should be around 630px)
|
||||||
|
*/
|
||||||
|
height: number
|
||||||
|
/**
|
||||||
|
* Width to generate image with in pixels (should be around 1200px)
|
||||||
|
*/
|
||||||
|
width: number
|
||||||
|
/**
|
||||||
|
* Whether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true).
|
||||||
|
*/
|
||||||
|
excludeRoot: boolean
|
||||||
|
/**
|
||||||
|
* JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
|
||||||
|
* @param cfg global quartz config
|
||||||
|
* @param userOpts options that can be set by user
|
||||||
|
* @param title title of current page
|
||||||
|
* @param description description of current page
|
||||||
|
* @param fonts global font that can be used for styling
|
||||||
|
* @param fileData full fileData of current page
|
||||||
|
* @returns prepared jsx to be used for generating image
|
||||||
|
*/
|
||||||
|
imageStructure: (
|
||||||
|
cfg: GlobalConfiguration,
|
||||||
|
userOpts: UserOpts,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
fonts: SatoriOptions["fonts"],
|
||||||
|
fileData: QuartzPluginData,
|
||||||
|
) => JSXInternal.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserOpts = Omit<SocialImageOptions, "imageStructure">
|
||||||
|
|
||||||
|
export type ImageOptions = {
|
||||||
|
/**
|
||||||
|
* what title to use as header in image
|
||||||
|
*/
|
||||||
|
title: string
|
||||||
|
/**
|
||||||
|
* what description to use as body in image
|
||||||
|
*/
|
||||||
|
description: string
|
||||||
|
/**
|
||||||
|
* what fileName to use when writing to disk
|
||||||
|
*/
|
||||||
|
fileName: string
|
||||||
|
/**
|
||||||
|
* what directory to store image in
|
||||||
|
*/
|
||||||
|
fileDir: string
|
||||||
|
/**
|
||||||
|
* what file extension to use (should be `webp` unless you also change sharp conversion)
|
||||||
|
*/
|
||||||
|
fileExt: string
|
||||||
|
/**
|
||||||
|
* header + body font to be used when generating satori image (as promise to work around sync in component)
|
||||||
|
*/
|
||||||
|
fontsPromise: Promise<SatoriOptions["fonts"]>
|
||||||
|
/**
|
||||||
|
* `GlobalConfiguration` of quartz (used for theme/typography)
|
||||||
|
*/
|
||||||
|
cfg: GlobalConfiguration
|
||||||
|
/**
|
||||||
|
* full file data of current page
|
||||||
|
*/
|
||||||
|
fileData: QuartzPluginData
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the default template for generated social image.
|
||||||
|
export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||||
|
cfg: GlobalConfiguration,
|
||||||
|
{ colorScheme }: 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
|
||||||
|
|
||||||
|
// Setup to access image
|
||||||
|
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: cfg.theme.colors[colorScheme].light,
|
||||||
|
gap: "2rem",
|
||||||
|
paddingTop: "1.5rem",
|
||||||
|
paddingBottom: "1.5rem",
|
||||||
|
paddingLeft: "5rem",
|
||||||
|
paddingRight: "5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: "2.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={iconPath} width={135} height={135} />
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: cfg.theme.colors[colorScheme].dark,
|
||||||
|
fontSize: useSmallerFont ? 70 : 82,
|
||||||
|
fontFamily: fonts[0].name,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: cfg.theme.colors[colorScheme].dark,
|
||||||
|
fontSize: 44,
|
||||||
|
lineClamp: 3,
|
||||||
|
fontFamily: fonts[1].name,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -158,6 +158,29 @@ describe("transforms", () => {
|
|||||||
path.isRelativeURL,
|
path.isRelativeURL,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("joinSegments", () => {
|
||||||
|
assert.strictEqual(path.joinSegments("a", "b"), "a/b")
|
||||||
|
assert.strictEqual(path.joinSegments("a/", "b"), "a/b")
|
||||||
|
assert.strictEqual(path.joinSegments("a", "b/"), "a/b/")
|
||||||
|
assert.strictEqual(path.joinSegments("a/", "b/"), "a/b/")
|
||||||
|
|
||||||
|
// preserve leading and trailing slashes
|
||||||
|
assert.strictEqual(path.joinSegments("/a", "b"), "/a/b")
|
||||||
|
assert.strictEqual(path.joinSegments("/a/", "b"), "/a/b")
|
||||||
|
assert.strictEqual(path.joinSegments("/a", "b/"), "/a/b/")
|
||||||
|
assert.strictEqual(path.joinSegments("/a/", "b/"), "/a/b/")
|
||||||
|
|
||||||
|
// lone slash
|
||||||
|
assert.strictEqual(path.joinSegments("/a/", "b", "/"), "/a/b/")
|
||||||
|
assert.strictEqual(path.joinSegments("a/", "b" + "/"), "a/b/")
|
||||||
|
|
||||||
|
// works with protocol specifiers
|
||||||
|
assert.strictEqual(path.joinSegments("https://example.com", "a"), "https://example.com/a")
|
||||||
|
assert.strictEqual(path.joinSegments("https://example.com/", "a"), "https://example.com/a")
|
||||||
|
assert.strictEqual(path.joinSegments("https://example.com", "a/"), "https://example.com/a/")
|
||||||
|
assert.strictEqual(path.joinSegments("https://example.com/", "a/"), "https://example.com/a/")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("link strategies", () => {
|
describe("link strategies", () => {
|
||||||
|
@ -108,10 +108,10 @@ const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) =>
|
|||||||
el.setAttribute(attr, rebased.pathname + rebased.hash)
|
el.setAttribute(attr, rebased.pathname + rebased.hash)
|
||||||
}
|
}
|
||||||
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
|
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
|
||||||
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
|
el.querySelectorAll('[href=""], [href^="./"], [href^="../"]').forEach((item) =>
|
||||||
_rebaseHtmlElement(item, "href", destination),
|
_rebaseHtmlElement(item, "href", destination),
|
||||||
)
|
)
|
||||||
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
|
el.querySelectorAll('[src=""], [src^="./"], [src^="../"]').forEach((item) =>
|
||||||
_rebaseHtmlElement(item, "src", destination),
|
_rebaseHtmlElement(item, "src", destination),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -183,10 +183,26 @@ export function slugTag(tag: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function joinSegments(...args: string[]): string {
|
export function joinSegments(...args: string[]): string {
|
||||||
return args
|
if (args.length === 0) {
|
||||||
.filter((segment) => segment !== "")
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let joined = args
|
||||||
|
.filter((segment) => segment !== "" && segment !== "/")
|
||||||
|
.map((segment) => stripSlashes(segment))
|
||||||
.join("/")
|
.join("/")
|
||||||
.replace(/\/\/+/g, "/")
|
|
||||||
|
// if the first segment starts with a slash, add it back
|
||||||
|
if (args[0].startsWith("/")) {
|
||||||
|
joined = "/" + joined
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the last segment is a folder, add a trailing slash
|
||||||
|
if (args[args.length - 1].endsWith("/")) {
|
||||||
|
joined = joined + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return joined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllSegmentPrefixes(tags: string): string[] {
|
export function getAllSegmentPrefixes(tags: string): string[] {
|
||||||
|
@ -16,6 +16,12 @@ export type JSResource = {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export type CSSResource = {
|
||||||
|
content: string
|
||||||
|
inline?: boolean
|
||||||
|
spaPreserve?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
|
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
|
||||||
const scriptType = resource.moduleType ?? "application/javascript"
|
const scriptType = resource.moduleType ?? "application/javascript"
|
||||||
const spaPreserve = preserve ?? resource.spaPreserve
|
const spaPreserve = preserve ?? resource.spaPreserve
|
||||||
@ -36,7 +42,24 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CSSResourceToStyleElement(resource: CSSResource, preserve?: boolean): JSX.Element {
|
||||||
|
const spaPreserve = preserve ?? resource.spaPreserve
|
||||||
|
if (resource.inline ?? false) {
|
||||||
|
return <style>{resource.content}</style>
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<link
|
||||||
|
key={resource.content}
|
||||||
|
href={resource.content}
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
spa-preserve={spaPreserve}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface StaticResources {
|
export interface StaticResources {
|
||||||
css: string[]
|
css: CSSResource[]
|
||||||
js: JSResource[]
|
js: JSResource[]
|
||||||
}
|
}
|
||||||
|
@ -3,23 +3,46 @@ sourceMapSupport.install(options)
|
|||||||
import cfg from "../quartz.config"
|
import cfg from "../quartz.config"
|
||||||
import { Argv, BuildCtx } from "./util/ctx"
|
import { Argv, BuildCtx } from "./util/ctx"
|
||||||
import { FilePath, FullSlug } from "./util/path"
|
import { FilePath, FullSlug } from "./util/path"
|
||||||
import { createFileParser, createProcessor } from "./processors/parse"
|
import {
|
||||||
|
createFileParser,
|
||||||
|
createHtmlProcessor,
|
||||||
|
createMarkdownParser,
|
||||||
|
createMdProcessor,
|
||||||
|
} from "./processors/parse"
|
||||||
import { options } from "./util/sourcemap"
|
import { options } from "./util/sourcemap"
|
||||||
|
import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
|
||||||
|
|
||||||
// only called from worker thread
|
// only called from worker thread
|
||||||
export async function parseFiles(
|
export async function parseMarkdown(
|
||||||
buildId: string,
|
buildId: string,
|
||||||
argv: Argv,
|
argv: Argv,
|
||||||
fps: FilePath[],
|
fps: FilePath[],
|
||||||
allSlugs: FullSlug[],
|
): Promise<[MarkdownContent[], FullSlug[]]> {
|
||||||
) {
|
// this is a hack
|
||||||
|
// we assume markdown parsers can add to `allSlugs`,
|
||||||
|
// but don't actually use them
|
||||||
|
const allSlugs: FullSlug[] = []
|
||||||
const ctx: BuildCtx = {
|
const ctx: BuildCtx = {
|
||||||
buildId,
|
buildId,
|
||||||
cfg,
|
cfg,
|
||||||
argv,
|
argv,
|
||||||
allSlugs,
|
allSlugs,
|
||||||
}
|
}
|
||||||
const processor = createProcessor(ctx)
|
return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
|
||||||
const parse = createFileParser(ctx, fps)
|
}
|
||||||
return parse(processor)
|
|
||||||
|
// only called from worker thread
|
||||||
|
export function processHtml(
|
||||||
|
buildId: string,
|
||||||
|
argv: Argv,
|
||||||
|
mds: MarkdownContent[],
|
||||||
|
allSlugs: FullSlug[],
|
||||||
|
): Promise<ProcessedContent[]> {
|
||||||
|
const ctx: BuildCtx = {
|
||||||
|
buildId,
|
||||||
|
cfg,
|
||||||
|
argv,
|
||||||
|
allSlugs,
|
||||||
|
}
|
||||||
|
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user