mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-01 10:17:57 +01:00
Compare commits
7 Commits
translatio
...
jackyzha0/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3173d185ed | ||
|
|
de727b4686 | ||
|
|
07ffc8681e | ||
|
|
f301eca9a7 | ||
|
|
1fb7756c49 | ||
|
|
c5a8b199ae | ||
|
|
5d50282124 |
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@v5.1.0
|
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
|
||||||
|
|||||||
@@ -221,26 +221,12 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
|||||||
|
|
||||||
export type QuartzEmitterPluginInstance = {
|
export type QuartzEmitterPluginInstance = {
|
||||||
name: string
|
name: string
|
||||||
emit(
|
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||||
ctx: BuildCtx,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
resources: StaticResources,
|
|
||||||
): Promise<FilePath[]> | AsyncGenerator<FilePath>
|
|
||||||
partialEmit?(
|
|
||||||
ctx: BuildCtx,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
resources: StaticResources,
|
|
||||||
changeEvents: ChangeEvent[],
|
|
||||||
): Promise<FilePath[]> | AsyncGenerator<FilePath> | null
|
|
||||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. It can optionally implement a `partialEmit` function for incremental builds.
|
An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
|
||||||
|
|
||||||
- `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
|
|
||||||
- `partialEmit` is an optional function that enables incremental builds. It receives information about which files have changed (`changeEvents`) and can selectively rebuild only the necessary files. This is useful for optimizing build times in development mode. If `partialEmit` is undefined, it will default to the `emit` function.
|
|
||||||
- `getQuartzComponents` declares which Quartz components the emitter uses to construct its pages.
|
|
||||||
|
|
||||||
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
|
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,11 @@ This part of the configuration concerns anything that can affect the whole site.
|
|||||||
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details.
|
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details.
|
||||||
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
|
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
|
||||||
- `theme`: configure how the site looks.
|
- `theme`: configure how the site looks.
|
||||||
- `cdnCaching`: if `true` (default), use Google CDN to cache the fonts. This will generally be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained.
|
- `cdnCaching`: If `true` (default), use Google CDN to cache the fonts. This will generally will be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained.
|
||||||
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
|
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
|
||||||
- `title`: font for the title of the site (optional, same as `header` by default)
|
- `header`: Font to use for headers
|
||||||
- `header`: font to use for headers
|
- `code`: Font for inline and block quotes.
|
||||||
- `code`: font for inline and block quotes
|
- `body`: Font for everything
|
||||||
- `body`: font for everything
|
|
||||||
- `colors`: controls the theming of the site.
|
- `colors`: controls the theming of the site.
|
||||||
- `light`: page background
|
- `light`: page background
|
||||||
- `lightgray`: borders
|
- `lightgray`: borders
|
||||||
@@ -109,25 +108,3 @@ Some plugins are included by default in the [`quartz.config.ts`](https://github.
|
|||||||
You can see a list of all plugins and their configuration options [[tags/plugin|here]].
|
You can see a list of all plugins and their configuration options [[tags/plugin|here]].
|
||||||
|
|
||||||
If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide.
|
If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide.
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
Fonts can be specified as a `string` or a `FontSpecification`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// string
|
|
||||||
typography: {
|
|
||||||
header: "Schibsted Grotesk",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// FontSpecification
|
|
||||||
typography: {
|
|
||||||
header: {
|
|
||||||
name: "Schibsted Grotesk",
|
|
||||||
weights: [400, 700],
|
|
||||||
includeItalic: true,
|
|
||||||
},
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Component.Breadcrumbs({
|
|||||||
spacerSymbol: "❯", // symbol between crumbs
|
spacerSymbol: "❯", // symbol between crumbs
|
||||||
rootName: "Home", // name of first/root element
|
rootName: "Home", // name of first/root element
|
||||||
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
||||||
|
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
|
||||||
showCurrentPage: true, // whether to display the current page in the breadcrumbs
|
showCurrentPage: true, // whether to display the current page in the breadcrumbs
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -131,8 +131,7 @@ Using this example, the display names of all `FileNodes` (folders + files) will
|
|||||||
```ts title="quartz.layout.ts"
|
```ts title="quartz.layout.ts"
|
||||||
Component.Explorer({
|
Component.Explorer({
|
||||||
mapFn: (node) => {
|
mapFn: (node) => {
|
||||||
node.displayName = node.displayName.toUpperCase()
|
return (node.displayName = node.displayName.toUpperCase())
|
||||||
return node
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -146,12 +145,8 @@ Note that this example filters on the title but you can also do it via slug or a
|
|||||||
Component.Explorer({
|
Component.Explorer({
|
||||||
filterFn: (node) => {
|
filterFn: (node) => {
|
||||||
// set containing names of everything you want to filter out
|
// set containing names of everything you want to filter out
|
||||||
const omit = new Set(["authoring content", "tags", "advanced"])
|
const omit = new Set(["authoring content", "tags", "hosting"])
|
||||||
|
return !omit.has(node.data.title.toLowerCase())
|
||||||
// can also use node.slug or by anything on node.data
|
|
||||||
// note that node.data is only present for files that exist on disk
|
|
||||||
// (e.g. implicit folder nodes that have no associated index.md)
|
|
||||||
return !omit.has(node.displayName.toLowerCase())
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -164,7 +159,7 @@ You can access the tags of a file by `node.data.tags`.
|
|||||||
Component.Explorer({
|
Component.Explorer({
|
||||||
filterFn: (node) => {
|
filterFn: (node) => {
|
||||||
// exclude files with the tag "explorerexclude"
|
// exclude files with the tag "explorerexclude"
|
||||||
return node.data.tags?.includes("explorerexclude") !== true
|
return node.data.tags.includes("explorerexclude") !== true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
title: Reader Mode
|
|
||||||
tags:
|
|
||||||
- component
|
|
||||||
---
|
|
||||||
|
|
||||||
Reader Mode is a feature that allows users to focus on the content by hiding the sidebars and other UI elements. When enabled, it provides a clean, distraction-free reading experience.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Reader Mode is enabled by default. To disable it, you can remove the component from your layout configuration in `quartz.layout.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Remove or comment out this line
|
|
||||||
Component.ReaderMode(),
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
The Reader Mode toggle appears as a button with a book icon. When clicked:
|
|
||||||
|
|
||||||
- Sidebars are hidden
|
|
||||||
- Hovering over the content area reveals the sidebars temporarily
|
|
||||||
|
|
||||||
Unlike Dark Mode, Reader Mode state is not persisted between page reloads but is maintained during SPA navigation within the site.
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
You can customize the appearance of Reader Mode through CSS variables and styles. The component uses the following classes:
|
|
||||||
|
|
||||||
- `.readermode`: The toggle button
|
|
||||||
- `.readerIcon`: The book icon
|
|
||||||
- `[reader-mode="on"]`: Applied to the root element when Reader Mode is active
|
|
||||||
|
|
||||||
Example customization in your custom CSS:
|
|
||||||
|
|
||||||
```scss
|
|
||||||
.readermode {
|
|
||||||
// Customize the button
|
|
||||||
svg {
|
|
||||||
stroke: var(--custom-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -31,8 +31,8 @@ If you prefer instructions in a video format you can try following Nicole van de
|
|||||||
|
|
||||||
## 🔧 Features
|
## 🔧 Features
|
||||||
|
|
||||||
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks|wikilinks, transclusions]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
|
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
|
||||||
- Hot-reload on configuration edits and incremental rebuilds for content edits
|
- Hot-reload for both configuration and content
|
||||||
- Simple JSX layouts and [[creating components|page components]]
|
- Simple JSX layouts and [[creating components|page components]]
|
||||||
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
|
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
|
||||||
- Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]]
|
- Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]]
|
||||||
|
|||||||
@@ -60,34 +60,3 @@ The `DesktopOnly` component is the counterpart to `MobileOnly`. It makes its chi
|
|||||||
```typescript
|
```typescript
|
||||||
Component.DesktopOnly(Component.TableOfContents())
|
Component.DesktopOnly(Component.TableOfContents())
|
||||||
```
|
```
|
||||||
|
|
||||||
## `ConditionalRender` Component
|
|
||||||
|
|
||||||
The `ConditionalRender` component is a wrapper that conditionally renders its child component based on a provided condition function. This is useful for creating dynamic layouts where components should only appear under certain conditions.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ConditionalRenderConfig = {
|
|
||||||
component: QuartzComponent
|
|
||||||
condition: (props: QuartzComponentProps) => boolean
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
Component.ConditionalRender({
|
|
||||||
component: Component.Search(),
|
|
||||||
condition: (props) => props.displayClass !== "fullpage",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The example above would only render the Search component when the page is not in fullpage mode.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
Component.ConditionalRender({
|
|
||||||
component: Component.Breadcrumbs(),
|
|
||||||
condition: (page) => page.fileData.slug !== "index",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The example above would hide breadcrumbs on the root `index.md` page.
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ The following properties can be used to customize your link previews:
|
|||||||
| `socialDescription` | `description` | Description to be used for preview. |
|
| `socialDescription` | `description` | Description to be used for preview. |
|
||||||
| `socialImage` | `image`, `cover` | Link to preview image. |
|
| `socialImage` | `image`, `cover` | Link to preview image. |
|
||||||
|
|
||||||
The `socialImage` property should contain a link to an image either relative to `quartz/static`, or a full URL. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. Alternatively, you can use a fully qualified URL like `"https://example.com/cover.png"`.
|
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
|
> [!info] Info
|
||||||
>
|
>
|
||||||
|
|||||||
1
index.d.ts
vendored
1
index.d.ts
vendored
@@ -8,7 +8,6 @@ interface CustomEventMap {
|
|||||||
prenav: CustomEvent<{}>
|
prenav: CustomEvent<{}>
|
||||||
nav: CustomEvent<{ url: FullSlug }>
|
nav: CustomEvent<{ url: FullSlug }>
|
||||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
type ContentIndex = Record<FullSlug, ContentDetails>
|
||||||
|
|||||||
639
package-lock.json
generated
639
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "@jackyzha0/quartz",
|
"name": "@jackyzha0/quartz",
|
||||||
"description": "🌱 publish your digital garden and notes as a website",
|
"description": "🌱 publish your digital garden and notes as a website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.5.0",
|
"version": "4.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -35,12 +35,11 @@
|
|||||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^0.10.1",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@myriaddreamin/rehype-typst": "^0.5.4",
|
"@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",
|
||||||
"ansi-truncate": "^1.2.0",
|
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -56,23 +55,22 @@
|
|||||||
"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.29.3",
|
"lightningcss": "^1.29.2",
|
||||||
"mdast-util-find-and-replace": "^3.0.2",
|
"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",
|
||||||
"minimatch": "^10.0.1",
|
"pixi.js": "^8.8.1",
|
||||||
"pixi.js": "^8.9.1",
|
"preact": "^10.26.4",
|
||||||
"preact": "^10.26.5",
|
|
||||||
"preact-render-to-string": "^6.5.13",
|
"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.3.1",
|
"rehype-citation": "^2.2.2",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-mathjax": "^7.1.0",
|
"rehype-mathjax": "^7.1.0",
|
||||||
"rehype-pretty-code": "^0.14.1",
|
"rehype-pretty-code": "^0.14.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
@@ -81,13 +79,13 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.1",
|
||||||
"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",
|
||||||
"satori": "^0.12.2",
|
"satori": "^0.12.1",
|
||||||
"serve-handler": "^6.1.6",
|
"serve-handler": "^6.1.6",
|
||||||
"sharp": "^0.34.1",
|
"sharp": "^0.33.5",
|
||||||
"shiki": "^1.26.2",
|
"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",
|
||||||
@@ -103,14 +101,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.14.1",
|
"@types/node": "^22.13.10",
|
||||||
"@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.18.1",
|
"@types/ws": "^8.18.0",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"esbuild": "^0.25.2",
|
"esbuild": "^0.25.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const config: QuartzConfig = {
|
|||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
baseUrl: "quartz.jzhao.xyz",
|
baseUrl: "quartz.jzhao.xyz",
|
||||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||||
defaultDateType: "modified",
|
defaultDateType: "created",
|
||||||
theme: {
|
theme: {
|
||||||
fontOrigin: "googleFonts",
|
fontOrigin: "googleFonts",
|
||||||
cdnCaching: true,
|
cdnCaching: true,
|
||||||
@@ -57,7 +57,7 @@ const config: QuartzConfig = {
|
|||||||
transformers: [
|
transformers: [
|
||||||
Plugin.FrontMatter(),
|
Plugin.FrontMatter(),
|
||||||
Plugin.CreatedModifiedDate({
|
Plugin.CreatedModifiedDate({
|
||||||
priority: ["frontmatter", "git", "filesystem"],
|
priority: ["frontmatter", "filesystem"],
|
||||||
}),
|
}),
|
||||||
Plugin.SyntaxHighlighting({
|
Plugin.SyntaxHighlighting({
|
||||||
theme: {
|
theme: {
|
||||||
@@ -87,7 +87,6 @@ const config: QuartzConfig = {
|
|||||||
Plugin.Assets(),
|
Plugin.Assets(),
|
||||||
Plugin.Static(),
|
Plugin.Static(),
|
||||||
Plugin.NotFoundPage(),
|
Plugin.NotFoundPage(),
|
||||||
// Comment out CustomOgImages to speed up build time
|
|
||||||
Plugin.CustomOgImages(),
|
Plugin.CustomOgImages(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ export const sharedPageComponents: SharedLayout = {
|
|||||||
// components for pages that display a single page (e.g. a single note)
|
// components for pages that display a single page (e.g. a single note)
|
||||||
export const defaultContentPageLayout: PageLayout = {
|
export const defaultContentPageLayout: PageLayout = {
|
||||||
beforeBody: [
|
beforeBody: [
|
||||||
Component.ConditionalRender({
|
Component.Breadcrumbs(),
|
||||||
component: Component.Breadcrumbs(),
|
|
||||||
condition: (page) => page.fileData.slug !== "index",
|
|
||||||
}),
|
|
||||||
Component.ArticleTitle(),
|
Component.ArticleTitle(),
|
||||||
Component.ContentMeta(),
|
Component.ContentMeta(),
|
||||||
Component.TagList(),
|
Component.TagList(),
|
||||||
@@ -35,7 +32,6 @@ export const defaultContentPageLayout: PageLayout = {
|
|||||||
grow: true,
|
grow: true,
|
||||||
},
|
},
|
||||||
{ Component: Component.Darkmode() },
|
{ Component: Component.Darkmode() },
|
||||||
{ Component: Component.ReaderMode() },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
Component.Explorer(),
|
Component.Explorer(),
|
||||||
@@ -53,15 +49,8 @@ export const defaultListPageLayout: PageLayout = {
|
|||||||
left: [
|
left: [
|
||||||
Component.PageTitle(),
|
Component.PageTitle(),
|
||||||
Component.MobileOnly(Component.Spacer()),
|
Component.MobileOnly(Component.Spacer()),
|
||||||
Component.Flex({
|
Component.Search(),
|
||||||
components: [
|
Component.Darkmode(),
|
||||||
{
|
|
||||||
Component: Component.Search(),
|
|
||||||
grow: true,
|
|
||||||
},
|
|
||||||
{ Component: Component.Darkmode() },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
Component.Explorer(),
|
Component.Explorer(),
|
||||||
],
|
],
|
||||||
right: [],
|
right: [],
|
||||||
|
|||||||
432
quartz/build.ts
432
quartz/build.ts
@@ -9,7 +9,7 @@ import { parseMarkdown } from "./processors/parse"
|
|||||||
import { filterContent } from "./processors/filter"
|
import { filterContent } from "./processors/filter"
|
||||||
import { emitContent } from "./processors/emit"
|
import { emitContent } from "./processors/emit"
|
||||||
import cfg from "../quartz.config"
|
import cfg from "../quartz.config"
|
||||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
|
||||||
import chokidar from "chokidar"
|
import chokidar from "chokidar"
|
||||||
import { ProcessedContent } from "./plugins/vfile"
|
import { ProcessedContent } from "./plugins/vfile"
|
||||||
import { Argv, BuildCtx } from "./util/ctx"
|
import { Argv, BuildCtx } from "./util/ctx"
|
||||||
@@ -17,39 +17,34 @@ import { glob, toPosixPath } from "./util/glob"
|
|||||||
import { trace } from "./util/trace"
|
import { trace } from "./util/trace"
|
||||||
import { options } from "./util/sourcemap"
|
import { options } from "./util/sourcemap"
|
||||||
import { Mutex } from "async-mutex"
|
import { Mutex } from "async-mutex"
|
||||||
|
import DepGraph from "./depgraph"
|
||||||
import { getStaticResourcesFromPlugins } from "./plugins"
|
import { getStaticResourcesFromPlugins } from "./plugins"
|
||||||
import { randomIdNonSecure } from "./util/random"
|
import { randomIdNonSecure } from "./util/random"
|
||||||
import { ChangeEvent } from "./plugins/types"
|
|
||||||
import { minimatch } from "minimatch"
|
|
||||||
|
|
||||||
type ContentMap = Map<
|
type Dependencies = Record<string, DepGraph<FilePath> | null>
|
||||||
FilePath,
|
|
||||||
| {
|
|
||||||
type: "markdown"
|
|
||||||
content: ProcessedContent
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "other"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
|
|
||||||
type BuildData = {
|
type BuildData = {
|
||||||
ctx: BuildCtx
|
ctx: BuildCtx
|
||||||
ignored: GlobbyFilterFunction
|
ignored: GlobbyFilterFunction
|
||||||
mut: Mutex
|
mut: Mutex
|
||||||
contentMap: ContentMap
|
initialSlugs: FullSlug[]
|
||||||
changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]>
|
// TODO merge contentMap and trackedAssets
|
||||||
|
contentMap: Map<FilePath, ProcessedContent>
|
||||||
|
trackedAssets: Set<FilePath>
|
||||||
|
toRebuild: Set<FilePath>
|
||||||
|
toRemove: Set<FilePath>
|
||||||
lastBuildMs: number
|
lastBuildMs: number
|
||||||
|
dependencies: Dependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileEvent = "add" | "change" | "delete"
|
||||||
|
|
||||||
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||||
const ctx: BuildCtx = {
|
const ctx: BuildCtx = {
|
||||||
buildId: randomIdNonSecure(),
|
buildId: randomIdNonSecure(),
|
||||||
argv,
|
argv,
|
||||||
cfg,
|
cfg,
|
||||||
allSlugs: [],
|
allSlugs: [],
|
||||||
allFiles: [],
|
|
||||||
incremental: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
@@ -72,70 +67,64 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
|||||||
|
|
||||||
perf.addEvent("glob")
|
perf.addEvent("glob")
|
||||||
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
|
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
|
||||||
const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
|
const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort()
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
|
const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath)
|
||||||
ctx.allFiles = allFiles
|
|
||||||
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
|
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
|
||||||
|
|
||||||
const parsedFiles = await parseMarkdown(ctx, filePaths)
|
const parsedFiles = await parseMarkdown(ctx, filePaths)
|
||||||
const filteredContent = filterContent(ctx, parsedFiles)
|
const filteredContent = filterContent(ctx, parsedFiles)
|
||||||
|
|
||||||
|
const dependencies: Record<string, DepGraph<FilePath> | null> = {}
|
||||||
|
|
||||||
|
// Only build dependency graphs if we're doing a fast rebuild
|
||||||
|
if (argv.fastRebuild) {
|
||||||
|
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||||
|
for (const emitter of cfg.plugins.emitters) {
|
||||||
|
dependencies[emitter.name] =
|
||||||
|
(await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emitContent(ctx, filteredContent)
|
await emitContent(ctx, filteredContent)
|
||||||
console.log(chalk.green(`Done processing ${markdownPaths.length} files in ${perf.timeSince()}`))
|
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
|
||||||
release()
|
release()
|
||||||
|
|
||||||
if (argv.watch) {
|
if (argv.serve) {
|
||||||
ctx.incremental = true
|
return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies)
|
||||||
return startWatching(ctx, mut, parsedFiles, clientRefresh)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup watcher for rebuilds
|
// setup watcher for rebuilds
|
||||||
async function startWatching(
|
async function startServing(
|
||||||
ctx: BuildCtx,
|
ctx: BuildCtx,
|
||||||
mut: Mutex,
|
mut: Mutex,
|
||||||
initialContent: ProcessedContent[],
|
initialContent: ProcessedContent[],
|
||||||
clientRefresh: () => void,
|
clientRefresh: () => void,
|
||||||
|
dependencies: Dependencies, // emitter name: dep graph
|
||||||
) {
|
) {
|
||||||
const { argv, allFiles } = ctx
|
const { argv } = ctx
|
||||||
|
|
||||||
const contentMap: ContentMap = new Map()
|
|
||||||
for (const filePath of allFiles) {
|
|
||||||
contentMap.set(filePath, {
|
|
||||||
type: "other",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// cache file parse results
|
||||||
|
const contentMap = new Map<FilePath, ProcessedContent>()
|
||||||
for (const content of initialContent) {
|
for (const content of initialContent) {
|
||||||
const [_tree, vfile] = content
|
const [_tree, vfile] = content
|
||||||
contentMap.set(vfile.data.relativePath!, {
|
contentMap.set(vfile.data.filePath!, content)
|
||||||
type: "markdown",
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitIgnoredMatcher = await isGitIgnored()
|
|
||||||
const buildData: BuildData = {
|
const buildData: BuildData = {
|
||||||
ctx,
|
ctx,
|
||||||
mut,
|
mut,
|
||||||
|
dependencies,
|
||||||
contentMap,
|
contentMap,
|
||||||
ignored: (path) => {
|
ignored: await isGitIgnored(),
|
||||||
if (gitIgnoredMatcher(path)) return true
|
initialSlugs: ctx.allSlugs,
|
||||||
const pathStr = path.toString()
|
toRebuild: new Set<FilePath>(),
|
||||||
for (const pattern of cfg.configuration.ignorePatterns) {
|
toRemove: new Set<FilePath>(),
|
||||||
if (minimatch(pathStr, pattern)) {
|
trackedAssets: new Set<FilePath>(),
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
|
|
||||||
changesSinceLastBuild: {},
|
|
||||||
lastBuildMs: 0,
|
lastBuildMs: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,37 +134,34 @@ async function startWatching(
|
|||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changes: ChangeEvent[] = []
|
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
|
||||||
watcher
|
watcher
|
||||||
.on("add", (fp) => {
|
.on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
|
||||||
if (buildData.ignored(fp)) return
|
.on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
|
||||||
changes.push({ path: fp as FilePath, type: "add" })
|
.on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
|
||||||
void rebuild(changes, clientRefresh, buildData)
|
|
||||||
})
|
|
||||||
.on("change", (fp) => {
|
|
||||||
if (buildData.ignored(fp)) return
|
|
||||||
changes.push({ path: fp as FilePath, type: "change" })
|
|
||||||
void rebuild(changes, clientRefresh, buildData)
|
|
||||||
})
|
|
||||||
.on("unlink", (fp) => {
|
|
||||||
if (buildData.ignored(fp)) return
|
|
||||||
changes.push({ path: fp as FilePath, type: "delete" })
|
|
||||||
void rebuild(changes, clientRefresh, buildData)
|
|
||||||
})
|
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await watcher.close()
|
await watcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) {
|
async function partialRebuildFromEntrypoint(
|
||||||
const { ctx, contentMap, mut, changesSinceLastBuild } = buildData
|
filepath: string,
|
||||||
|
action: FileEvent,
|
||||||
|
clientRefresh: () => void,
|
||||||
|
buildData: BuildData, // note: this function mutates buildData
|
||||||
|
) {
|
||||||
|
const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
|
||||||
const { argv, cfg } = ctx
|
const { argv, cfg } = ctx
|
||||||
|
|
||||||
|
// don't do anything for gitignored files
|
||||||
|
if (ignored(filepath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const buildId = randomIdNonSecure()
|
const buildId = randomIdNonSecure()
|
||||||
ctx.buildId = buildId
|
ctx.buildId = buildId
|
||||||
buildData.lastBuildMs = new Date().getTime()
|
buildData.lastBuildMs = new Date().getTime()
|
||||||
const numChangesInBuild = changes.length
|
|
||||||
const release = await mut.acquire()
|
const release = await mut.acquire()
|
||||||
|
|
||||||
// if there's another build after us, release and let them do it
|
// if there's another build after us, release and let them do it
|
||||||
@@ -185,105 +171,261 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
|
|||||||
}
|
}
|
||||||
|
|
||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
perf.addEvent("rebuild")
|
|
||||||
console.log(chalk.yellow("Detected change, rebuilding..."))
|
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||||
|
|
||||||
// update changesSinceLastBuild
|
// UPDATE DEP GRAPH
|
||||||
for (const change of changes) {
|
const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath
|
||||||
changesSinceLastBuild[change.path] = change.type
|
|
||||||
}
|
|
||||||
|
|
||||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||||
const pathsToParse: FilePath[] = []
|
let processedFiles: ProcessedContent[] = []
|
||||||
for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
|
|
||||||
if (type === "delete" || path.extname(fp) !== ".md") continue
|
|
||||||
const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
|
|
||||||
pathsToParse.push(fullPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = await parseMarkdown(ctx, pathsToParse)
|
switch (action) {
|
||||||
for (const content of parsed) {
|
case "add":
|
||||||
contentMap.set(content[1].data.relativePath!, {
|
// add to cache when new file is added
|
||||||
type: "markdown",
|
processedFiles = await parseMarkdown(ctx, [fp])
|
||||||
content,
|
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// update state using changesSinceLastBuild
|
// update the dep graph by asking all emitters whether they depend on this file
|
||||||
// we do this weird play of add => compute change events => remove
|
for (const emitter of cfg.plugins.emitters) {
|
||||||
// so that partialEmitters can do appropriate cleanup based on the content of deleted files
|
const emitterGraph =
|
||||||
for (const [file, change] of Object.entries(changesSinceLastBuild)) {
|
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
|
||||||
if (change === "delete") {
|
|
||||||
// universal delete case
|
|
||||||
contentMap.delete(file as FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// manually track non-markdown files as processed files only
|
if (emitterGraph) {
|
||||||
// contains markdown files
|
const existingGraph = dependencies[emitter.name]
|
||||||
if (change === "add" && path.extname(file) !== ".md") {
|
if (existingGraph !== null) {
|
||||||
contentMap.set(file as FilePath, {
|
existingGraph.mergeGraph(emitterGraph)
|
||||||
type: "other",
|
} else {
|
||||||
})
|
// might be the first time we're adding a mardown file
|
||||||
}
|
dependencies[emitter.name] = emitterGraph
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => {
|
|
||||||
const path = fp as FilePath
|
|
||||||
const processedContent = contentMap.get(path)
|
|
||||||
if (processedContent?.type === "markdown") {
|
|
||||||
const [_tree, file] = processedContent.content
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
path,
|
|
||||||
file,
|
|
||||||
}
|
}
|
||||||
}
|
break
|
||||||
|
case "change":
|
||||||
|
// invalidate cache when file is changed
|
||||||
|
processedFiles = await parseMarkdown(ctx, [fp])
|
||||||
|
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
|
||||||
|
|
||||||
return {
|
// only content files can have added/removed dependencies because of transclusions
|
||||||
type,
|
if (path.extname(fp) === ".md") {
|
||||||
path,
|
for (const emitter of cfg.plugins.emitters) {
|
||||||
}
|
// get new dependencies from all emitters for this file
|
||||||
})
|
const emitterGraph =
|
||||||
|
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
|
||||||
|
|
||||||
// update allFiles and then allSlugs with the consistent view of content map
|
// only update the graph if the emitter plugin uses the changed file
|
||||||
ctx.allFiles = Array.from(contentMap.keys())
|
// eg. Assets plugin ignores md files, so we skip updating the graph
|
||||||
ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
|
if (emitterGraph?.hasNode(fp)) {
|
||||||
const processedFiles = Array.from(contentMap.values())
|
// merge the new dependencies into the dep graph
|
||||||
.filter((file) => file.type === "markdown")
|
dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
|
||||||
.map((file) => file.content)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "delete":
|
||||||
|
toRemove.add(fp)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argv.verbose) {
|
||||||
|
console.log(`Updated dependency graphs in ${perf.timeSince()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EMIT
|
||||||
|
perf.addEvent("rebuild")
|
||||||
let emittedFiles = 0
|
let emittedFiles = 0
|
||||||
|
|
||||||
for (const emitter of cfg.plugins.emitters) {
|
for (const emitter of cfg.plugins.emitters) {
|
||||||
// Try to use partialEmit if available, otherwise assume the output is static
|
const depGraph = dependencies[emitter.name]
|
||||||
const emitFn = emitter.partialEmit ?? emitter.emit
|
|
||||||
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
|
// emitter hasn't defined a dependency graph. call it with all processed files
|
||||||
if (emitted === null) {
|
if (depGraph === null) {
|
||||||
|
if (argv.verbose) {
|
||||||
|
console.log(
|
||||||
|
`Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [...contentMap.values()].filter(
|
||||||
|
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
|
||||||
|
)
|
||||||
|
|
||||||
|
const emitted = await emitter.emit(ctx, files, staticResources)
|
||||||
|
if (Symbol.asyncIterator in emitted) {
|
||||||
|
// Async generator case
|
||||||
|
for await (const file of emitted) {
|
||||||
|
emittedFiles++
|
||||||
|
if (ctx.argv.verbose) {
|
||||||
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Array case
|
||||||
|
emittedFiles += emitted.length
|
||||||
|
if (ctx.argv.verbose) {
|
||||||
|
for (const file of emitted) {
|
||||||
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Symbol.asyncIterator in emitted) {
|
// only call the emitter if it uses this file
|
||||||
// Async generator case
|
if (depGraph.hasNode(fp)) {
|
||||||
for await (const file of emitted) {
|
// re-emit using all files that are needed for the downstream of this file
|
||||||
emittedFiles++
|
// eg. for ContentIndex, the dep graph could be:
|
||||||
if (ctx.argv.verbose) {
|
// a.md --> contentIndex.json
|
||||||
console.log(`[emit:${emitter.name}] ${file}`)
|
// b.md ------^
|
||||||
|
//
|
||||||
|
// if a.md changes, we need to re-emit contentIndex.json,
|
||||||
|
// and supply [a.md, b.md] to the emitter
|
||||||
|
const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]
|
||||||
|
|
||||||
|
const upstreamContent = upstreams
|
||||||
|
// filter out non-markdown files
|
||||||
|
.filter((file) => contentMap.has(file))
|
||||||
|
// if file was deleted, don't give it to the emitter
|
||||||
|
.filter((file) => !toRemove.has(file))
|
||||||
|
.map((file) => contentMap.get(file)!)
|
||||||
|
|
||||||
|
const emitted = await emitter.emit(ctx, upstreamContent, staticResources)
|
||||||
|
if (Symbol.asyncIterator in emitted) {
|
||||||
|
// Async generator case
|
||||||
|
for await (const file of emitted) {
|
||||||
|
emittedFiles++
|
||||||
|
if (ctx.argv.verbose) {
|
||||||
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// Array case
|
||||||
// Array case
|
emittedFiles += emitted.length
|
||||||
emittedFiles += emitted.length
|
if (ctx.argv.verbose) {
|
||||||
if (ctx.argv.verbose) {
|
for (const file of emitted) {
|
||||||
for (const file of emitted) {
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
console.log(`[emit:${emitter.name}] ${file}`)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
|
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
|
||||||
|
|
||||||
|
// CLEANUP
|
||||||
|
const destinationsToDelete = new Set<FilePath>()
|
||||||
|
for (const file of toRemove) {
|
||||||
|
// remove from cache
|
||||||
|
contentMap.delete(file)
|
||||||
|
Object.values(dependencies).forEach((depGraph) => {
|
||||||
|
// remove the node from dependency graphs
|
||||||
|
depGraph?.removeNode(file)
|
||||||
|
// remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed
|
||||||
|
const orphanNodes = depGraph?.removeOrphanNodes()
|
||||||
|
orphanNodes?.forEach((node) => {
|
||||||
|
// only delete files that are in the output directory
|
||||||
|
if (node.startsWith(argv.output)) {
|
||||||
|
destinationsToDelete.add(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await rimraf([...destinationsToDelete])
|
||||||
|
|
||||||
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||||
changes.splice(0, numChangesInBuild)
|
|
||||||
|
toRemove.clear()
|
||||||
|
release()
|
||||||
clientRefresh()
|
clientRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildFromEntrypoint(
|
||||||
|
fp: string,
|
||||||
|
action: FileEvent,
|
||||||
|
clientRefresh: () => void,
|
||||||
|
buildData: BuildData, // note: this function mutates buildData
|
||||||
|
) {
|
||||||
|
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
|
||||||
|
buildData
|
||||||
|
|
||||||
|
const { argv } = ctx
|
||||||
|
|
||||||
|
// don't do anything for gitignored files
|
||||||
|
if (ignored(fp)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dont bother rebuilding for non-content files, just track and refresh
|
||||||
|
fp = toPosixPath(fp)
|
||||||
|
const filePath = joinSegments(argv.directory, fp) as FilePath
|
||||||
|
if (path.extname(fp) !== ".md") {
|
||||||
|
if (action === "add" || action === "change") {
|
||||||
|
trackedAssets.add(filePath)
|
||||||
|
} else if (action === "delete") {
|
||||||
|
trackedAssets.delete(filePath)
|
||||||
|
}
|
||||||
|
clientRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "add" || action === "change") {
|
||||||
|
toRebuild.add(filePath)
|
||||||
|
} else if (action === "delete") {
|
||||||
|
toRemove.add(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildId = randomIdNonSecure()
|
||||||
|
ctx.buildId = buildId
|
||||||
|
buildData.lastBuildMs = new Date().getTime()
|
||||||
|
const release = await mut.acquire()
|
||||||
|
|
||||||
|
// there's another build after us, release and let them do it
|
||||||
|
if (ctx.buildId !== buildId) {
|
||||||
|
release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const perf = new PerfTimer()
|
||||||
|
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
||||||
|
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
|
||||||
|
for (const content of parsedContent) {
|
||||||
|
const [_tree, vfile] = content
|
||||||
|
contentMap.set(vfile.data.filePath!, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fp of toRemove) {
|
||||||
|
contentMap.delete(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedFiles = [...contentMap.values()]
|
||||||
|
const filteredContent = filterContent(ctx, parsedFiles)
|
||||||
|
|
||||||
|
// re-update slugs
|
||||||
|
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
|
||||||
|
.filter((fp) => !toRemove.has(fp))
|
||||||
|
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
|
||||||
|
|
||||||
|
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
|
||||||
|
|
||||||
|
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||||
|
// instead of just deleting everything
|
||||||
|
await rimraf(path.join(argv.output, ".*"), { glob: true })
|
||||||
|
await emitContent(ctx, filteredContent)
|
||||||
|
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||||
|
} catch (err) {
|
||||||
|
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||||
|
if (argv.verbose) {
|
||||||
|
console.log(chalk.red(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientRefresh()
|
||||||
|
toRebuild.clear()
|
||||||
|
toRemove.clear()
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ export const BuildArgv = {
|
|||||||
default: false,
|
default: false,
|
||||||
describe: "run a local server to live-preview your Quartz",
|
describe: "run a local server to live-preview your Quartz",
|
||||||
},
|
},
|
||||||
watch: {
|
fastRebuild: {
|
||||||
boolean: true,
|
boolean: true,
|
||||||
default: false,
|
default: false,
|
||||||
describe: "watch for changes and rebuild automatically",
|
describe: "[experimental] rebuild only the changed files",
|
||||||
},
|
},
|
||||||
baseDir: {
|
baseDir: {
|
||||||
string: true,
|
string: true,
|
||||||
|
|||||||
@@ -225,10 +225,6 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
|||||||
* @param {*} argv arguments for `build`
|
* @param {*} argv arguments for `build`
|
||||||
*/
|
*/
|
||||||
export async function handleBuild(argv) {
|
export async function handleBuild(argv) {
|
||||||
if (argv.serve) {
|
|
||||||
argv.watch = true
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
|
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
|
||||||
const ctx = await esbuild.context({
|
const ctx = await esbuild.context({
|
||||||
entryPoints: [fp],
|
entryPoints: [fp],
|
||||||
@@ -335,10 +331,9 @@ export async function handleBuild(argv) {
|
|||||||
clientRefresh()
|
clientRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
let clientRefresh = () => {}
|
|
||||||
if (argv.serve) {
|
if (argv.serve) {
|
||||||
const connections = []
|
const connections = []
|
||||||
clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
|
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
|
||||||
|
|
||||||
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
|
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
|
||||||
argv.baseDir = "/" + argv.baseDir
|
argv.baseDir = "/" + argv.baseDir
|
||||||
@@ -438,7 +433,6 @@ export async function handleBuild(argv) {
|
|||||||
|
|
||||||
return serve()
|
return serve()
|
||||||
})
|
})
|
||||||
|
|
||||||
server.listen(argv.port)
|
server.listen(argv.port)
|
||||||
const wss = new WebSocketServer({ port: argv.wsPort })
|
const wss = new WebSocketServer({ port: argv.wsPort })
|
||||||
wss.on("connection", (ws) => connections.push(ws))
|
wss.on("connection", (ws) => connections.push(ws))
|
||||||
@@ -447,27 +441,16 @@ export async function handleBuild(argv) {
|
|||||||
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
|
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
console.log("hint: exit with ctrl+c")
|
||||||
await build(clientRefresh)
|
const paths = await globby(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"])
|
||||||
ctx.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (argv.watch) {
|
|
||||||
const paths = await globby([
|
|
||||||
"**/*.ts",
|
|
||||||
"quartz/cli/*.js",
|
|
||||||
"quartz/static/**/*",
|
|
||||||
"**/*.tsx",
|
|
||||||
"**/*.scss",
|
|
||||||
"package.json",
|
|
||||||
])
|
|
||||||
chokidar
|
chokidar
|
||||||
.watch(paths, { ignoreInitial: true })
|
.watch(paths, { ignoreInitial: true })
|
||||||
.on("add", () => build(clientRefresh))
|
.on("add", () => build(clientRefresh))
|
||||||
.on("change", () => build(clientRefresh))
|
.on("change", () => build(clientRefresh))
|
||||||
.on("unlink", () => build(clientRefresh))
|
.on("unlink", () => build(clientRefresh))
|
||||||
|
} else {
|
||||||
console.log(chalk.grey("hint: exit with ctrl+c"))
|
await build(() => {})
|
||||||
|
ctx.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||||
import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
|
import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
import { trieFromAllFiles } from "../util/ctx"
|
|
||||||
|
|
||||||
type CrumbData = {
|
type CrumbData = {
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -22,6 +22,10 @@ interface BreadcrumbOptions {
|
|||||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||||
*/
|
*/
|
||||||
resolveFrontmatterTitle: boolean
|
resolveFrontmatterTitle: boolean
|
||||||
|
/**
|
||||||
|
* Whether to display breadcrumbs on root `index.md`
|
||||||
|
*/
|
||||||
|
hideOnRoot: boolean
|
||||||
/**
|
/**
|
||||||
* Whether to display the current page in the breadcrumbs.
|
* Whether to display the current page in the breadcrumbs.
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +36,7 @@ const defaultOptions: BreadcrumbOptions = {
|
|||||||
spacerSymbol: "❯",
|
spacerSymbol: "❯",
|
||||||
rootName: "Home",
|
rootName: "Home",
|
||||||
resolveFrontmatterTitle: true,
|
resolveFrontmatterTitle: true,
|
||||||
|
hideOnRoot: true,
|
||||||
showCurrentPage: true,
|
showCurrentPage: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,37 +48,78 @@ function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: Simpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||||
|
// Merge options with defaults
|
||||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||||
|
|
||||||
|
// computed index of folder name to its associated file data
|
||||||
|
let folderIndex: Map<string, QuartzPluginData> | undefined
|
||||||
|
|
||||||
const Breadcrumbs: QuartzComponent = ({
|
const Breadcrumbs: QuartzComponent = ({
|
||||||
fileData,
|
fileData,
|
||||||
allFiles,
|
allFiles,
|
||||||
displayClass,
|
displayClass,
|
||||||
ctx,
|
|
||||||
}: QuartzComponentProps) => {
|
}: QuartzComponentProps) => {
|
||||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
// Hide crumbs on root if enabled
|
||||||
const slugParts = fileData.slug!.split("/")
|
if (options.hideOnRoot && fileData.slug === "index") {
|
||||||
const pathNodes = trie.ancestryChain(slugParts)
|
return <></>
|
||||||
|
|
||||||
if (!pathNodes) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
|
// Format entry for root element
|
||||||
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
|
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
|
||||||
if (idx === 0) {
|
const crumbs: CrumbData[] = [firstEntry]
|
||||||
crumb.displayName = options.rootName
|
|
||||||
|
if (!folderIndex && options.resolveFrontmatterTitle) {
|
||||||
|
folderIndex = new Map()
|
||||||
|
// construct the index for the first time
|
||||||
|
for (const file of allFiles) {
|
||||||
|
const folderParts = file.slug?.split("/")
|
||||||
|
if (folderParts?.at(-1) === "index") {
|
||||||
|
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split slug into hierarchy/parts
|
||||||
|
const slugParts = fileData.slug?.split("/")
|
||||||
|
if (slugParts) {
|
||||||
|
// is tag breadcrumb?
|
||||||
|
const isTagPath = slugParts[0] === "tags"
|
||||||
|
|
||||||
|
// full path until current part
|
||||||
|
let currentPath = ""
|
||||||
|
|
||||||
|
for (let i = 0; i < slugParts.length - 1; i++) {
|
||||||
|
let curPathSegment = slugParts[i]
|
||||||
|
|
||||||
|
// Try to resolve frontmatter folder title
|
||||||
|
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
|
||||||
|
if (currentFile) {
|
||||||
|
const title = currentFile.frontmatter!.title
|
||||||
|
if (title !== "index") {
|
||||||
|
curPathSegment = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current slug to full path
|
||||||
|
currentPath = joinSegments(currentPath, slugParts[i])
|
||||||
|
const includeTrailingSlash = !isTagPath || i < slugParts.length - 1
|
||||||
|
|
||||||
|
// Format and add current crumb
|
||||||
|
const crumb = formatCrumb(
|
||||||
|
curPathSegment,
|
||||||
|
fileData.slug!,
|
||||||
|
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
|
||||||
|
)
|
||||||
|
crumbs.push(crumb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For last node (current page), set empty path
|
// Add current file to crumb (can directly use frontmatter title)
|
||||||
if (idx === pathNodes.length - 1) {
|
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||||
crumb.path = ""
|
crumbs.push({
|
||||||
|
displayName: fileData.frontmatter!.title,
|
||||||
|
path: "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return crumb
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!options.showCurrentPage) {
|
|
||||||
crumbs.pop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|
||||||
|
|
||||||
type ConditionalRenderConfig = {
|
|
||||||
component: QuartzComponent
|
|
||||||
condition: (props: QuartzComponentProps) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ((config: ConditionalRenderConfig) => {
|
|
||||||
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
|
|
||||||
if (config.condition(props)) {
|
|
||||||
return <config.component {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
|
|
||||||
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
|
|
||||||
ConditionalRender.css = config.component.css
|
|
||||||
|
|
||||||
return ConditionalRender
|
|
||||||
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
||||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||||
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
|
import { googleFontHref } from "../util/theme"
|
||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import { unescapeHTML } from "../util/escape"
|
import { unescapeHTML } from "../util/escape"
|
||||||
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
||||||
@@ -45,9 +45,6 @@ export default (() => {
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||||
{cfg.theme.typography.title && (
|
|
||||||
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { JSX } from "preact"
|
import { JSX } from "preact"
|
||||||
|
import { randomIdNonSecure } from "../util/random"
|
||||||
|
|
||||||
const OverflowList = ({
|
const OverflowList = ({
|
||||||
children,
|
children,
|
||||||
@@ -12,9 +13,8 @@ const OverflowList = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let numExplorers = 0
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const id = `list-${numExplorers++}`
|
const id = randomIdNonSecure()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
|
import { FullSlug, resolveRelative } from "../util/path"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
import { Date, getDate } from "./Date"
|
import { Date, getDate } from "./Date"
|
||||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||||
@@ -8,33 +8,6 @@ export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
|||||||
|
|
||||||
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
||||||
return (f1, f2) => {
|
return (f1, f2) => {
|
||||||
// Sort by date/alphabetical
|
|
||||||
if (f1.dates && f2.dates) {
|
|
||||||
// sort descending
|
|
||||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
|
||||||
} else if (f1.dates && !f2.dates) {
|
|
||||||
// prioritize files with dates
|
|
||||||
return -1
|
|
||||||
} else if (!f1.dates && f2.dates) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, sort lexographically by title
|
|
||||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
|
||||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
|
||||||
return f1Title.localeCompare(f2Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function byDateAndAlphabeticalFolderFirst(cfg: GlobalConfiguration): SortFn {
|
|
||||||
return (f1, f2) => {
|
|
||||||
// Sort folders first
|
|
||||||
const f1IsFolder = isFolderPath(f1.slug ?? "")
|
|
||||||
const f2IsFolder = isFolderPath(f2.slug ?? "")
|
|
||||||
if (f1IsFolder && !f2IsFolder) return -1
|
|
||||||
if (!f1IsFolder && f2IsFolder) return 1
|
|
||||||
|
|
||||||
// If both are folders or both are files, sort by date/alphabetical
|
|
||||||
if (f1.dates && f2.dates) {
|
if (f1.dates && f2.dates) {
|
||||||
// sort descending
|
// sort descending
|
||||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||||
@@ -58,7 +31,7 @@ type Props = {
|
|||||||
} & QuartzComponentProps
|
} & QuartzComponentProps
|
||||||
|
|
||||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||||
const sorter = sort ?? byDateAndAlphabeticalFolderFirst(cfg)
|
const sorter = sort ?? byDateAndAlphabetical(cfg)
|
||||||
let list = allFiles.sort(sorter)
|
let list = allFiles.sort(sorter)
|
||||||
if (limit) {
|
if (limit) {
|
||||||
list = list.slice(0, limit)
|
list = list.slice(0, limit)
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ PageTitle.css = `
|
|||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--titleFont);
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// @ts-ignore
|
|
||||||
import readerModeScript from "./scripts/readermode.inline"
|
|
||||||
import styles from "./styles/readermode.scss"
|
|
||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|
||||||
import { classNames } from "../util/lang"
|
|
||||||
|
|
||||||
const ReaderMode: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
|
|
||||||
return (
|
|
||||||
<button class={classNames(displayClass, "readermode")}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="readerIcon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="6" y="4" width="12" height="16" rx="1"></rect>
|
|
||||||
<line x1="9" y1="8" x2="15" y2="8"></line>
|
|
||||||
<line x1="9" y1="12" x2="15" y2="12"></line>
|
|
||||||
<line x1="9" y1="16" x2="13" y2="16"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ReaderMode.beforeDOMLoaded = readerModeScript
|
|
||||||
ReaderMode.css = styles
|
|
||||||
|
|
||||||
export default (() => ReaderMode) satisfies QuartzComponentConstructor
|
|
||||||
@@ -53,15 +53,17 @@ export default ((opts?: Partial<Options>) => {
|
|||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
|
<div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
|
||||||
{fileData.toc.map((tocEntry) => (
|
<OverflowList>
|
||||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
{fileData.toc.map((tocEntry) => (
|
||||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||||
{tocEntry.text}
|
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||||
</a>
|
{tocEntry.text}
|
||||||
</li>
|
</a>
|
||||||
))}
|
</li>
|
||||||
</OverflowList>
|
))}
|
||||||
|
</OverflowList>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { FullSlug, resolveRelative } from "../util/path"
|
import { pathToRoot, slugTag } from "../util/path"
|
||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||||
const tags = fileData.frontmatter?.tags
|
const tags = fileData.frontmatter?.tags
|
||||||
|
const baseDir = pathToRoot(fileData.slug!)
|
||||||
if (tags && tags.length > 0) {
|
if (tags && tags.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ul class={classNames(displayClass, "tags")}>
|
<ul class={classNames(displayClass, "tags")}>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
|
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<a href={linkDest} class="internal tag-link">
|
<a href={linkDest} class="internal tag-link">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import FolderContent from "./pages/FolderContent"
|
|||||||
import NotFound from "./pages/404"
|
import NotFound from "./pages/404"
|
||||||
import ArticleTitle from "./ArticleTitle"
|
import ArticleTitle from "./ArticleTitle"
|
||||||
import Darkmode from "./Darkmode"
|
import Darkmode from "./Darkmode"
|
||||||
import ReaderMode from "./ReaderMode"
|
|
||||||
import Head from "./Head"
|
import Head from "./Head"
|
||||||
import PageTitle from "./PageTitle"
|
import PageTitle from "./PageTitle"
|
||||||
import ContentMeta from "./ContentMeta"
|
import ContentMeta from "./ContentMeta"
|
||||||
@@ -22,7 +21,6 @@ import RecentNotes from "./RecentNotes"
|
|||||||
import Breadcrumbs from "./Breadcrumbs"
|
import Breadcrumbs from "./Breadcrumbs"
|
||||||
import Comments from "./Comments"
|
import Comments from "./Comments"
|
||||||
import Flex from "./Flex"
|
import Flex from "./Flex"
|
||||||
import ConditionalRender from "./ConditionalRender"
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ArticleTitle,
|
ArticleTitle,
|
||||||
@@ -30,7 +28,6 @@ export {
|
|||||||
TagContent,
|
TagContent,
|
||||||
FolderContent,
|
FolderContent,
|
||||||
Darkmode,
|
Darkmode,
|
||||||
ReaderMode,
|
|
||||||
Head,
|
Head,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
ContentMeta,
|
ContentMeta,
|
||||||
@@ -49,5 +46,4 @@ export {
|
|||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Comments,
|
Comments,
|
||||||
Flex,
|
Flex,
|
||||||
ConditionalRender,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
|
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, 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"
|
import { QuartzPluginData } from "../../plugins/vfile"
|
||||||
import { ComponentChildren } from "preact"
|
import { ComponentChildren } from "preact"
|
||||||
import { concatenateResources } from "../../util/resources"
|
import { concatenateResources } from "../../util/resources"
|
||||||
import { trieFromAllFiles } from "../../util/ctx"
|
|
||||||
|
|
||||||
interface FolderContentOptions {
|
interface FolderContentOptions {
|
||||||
/**
|
/**
|
||||||
@@ -29,65 +30,48 @@ 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 folderParts = folderSlug.split(path.posix.sep)
|
||||||
|
|
||||||
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
|
const allPagesInFolder: QuartzPluginData[] = []
|
||||||
const folder = trie.findNode(fileData.slug!.split("/"))
|
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
|
||||||
if (!folder) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const allPagesInFolder: QuartzPluginData[] =
|
allFiles.forEach((file) => {
|
||||||
folder.children
|
const fileSlug = stripSlashes(simplifySlug(file.slug!))
|
||||||
.map((node) => {
|
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||||
// regular file, proceed
|
const fileParts = fileSlug.split(path.posix.sep)
|
||||||
if (node.data) {
|
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||||
return node.data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.isFolder && options.showSubfolders) {
|
if (!prefixed) {
|
||||||
// folders that dont have data need synthetic files
|
return
|
||||||
const getMostRecentDates = (): QuartzPluginData["dates"] => {
|
}
|
||||||
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
|
|
||||||
for (const child of node.children) {
|
|
||||||
if (child.data?.dates) {
|
|
||||||
// compare all dates and assign to maybeDates if its more recent or its not set
|
|
||||||
if (!maybeDates) {
|
|
||||||
maybeDates = { ...child.data.dates }
|
|
||||||
} else {
|
|
||||||
if (child.data.dates.created > maybeDates.created) {
|
|
||||||
maybeDates.created = child.data.dates.created
|
|
||||||
}
|
|
||||||
|
|
||||||
if (child.data.dates.modified > maybeDates.modified) {
|
if (isDirectChild) {
|
||||||
maybeDates.modified = child.data.dates.modified
|
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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (child.data.dates.published > maybeDates.published) {
|
allPagesInSubfolders.forEach((files, subfolderSlug) => {
|
||||||
maybeDates.published = child.data.dates.published
|
const hasIndex = allPagesInFolder.some(
|
||||||
}
|
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
|
||||||
}
|
)
|
||||||
}
|
if (!hasIndex) {
|
||||||
}
|
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
|
||||||
return (
|
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
|
||||||
maybeDates ?? {
|
allPagesInFolder.push({
|
||||||
created: new Date(),
|
slug: subfolderSlug,
|
||||||
modified: new Date(),
|
dates: subfolderDates,
|
||||||
published: new Date(),
|
frontmatter: { title: subfolderTitle, tags: ["folder"] },
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug: node.slug,
|
|
||||||
dates: getMostRecentDates(),
|
|
||||||
frontmatter: {
|
|
||||||
title: node.displayName,
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.filter((page) => page !== undefined) ?? []
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||||
const classes = cssClasses.join(" ")
|
const classes = cssClasses.join(" ")
|
||||||
const listProps = {
|
const listProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
import style from "../styles/listPage.scss"
|
import style from "../styles/listPage.scss"
|
||||||
import { PageList, SortFn } from "../PageList"
|
import { PageList, SortFn } from "../PageList"
|
||||||
import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
|
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||||
import { QuartzPluginData } from "../../plugins/vfile"
|
import { QuartzPluginData } from "../../plugins/vfile"
|
||||||
import { Root } from "hast"
|
import { Root } from "hast"
|
||||||
import { htmlToJsx } from "../../util/jsx"
|
import { htmlToJsx } from "../../util/jsx"
|
||||||
@@ -74,13 +74,10 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
? contentPage?.description
|
? contentPage?.description
|
||||||
: htmlToJsx(contentPage.filePath!, root)
|
: htmlToJsx(contentPage.filePath!, root)
|
||||||
|
|
||||||
const tagListingPage = `/tags/${tag}` as FullSlug
|
|
||||||
const href = resolveRelative(fileData.slug!, tagListingPage)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>
|
<h2>
|
||||||
<a class="internal tag-link" href={href}>
|
<a class="internal tag-link" href={`../tags/${tag}`}>
|
||||||
{tag}
|
{tag}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -115,8 +112,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="popover-hint">
|
<div class={classes}>
|
||||||
<article class={classes}>{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>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
interface RenderComponents {
|
interface RenderComponents {
|
||||||
head: QuartzComponent
|
head: QuartzComponent
|
||||||
@@ -24,6 +25,7 @@ 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")
|
||||||
@@ -63,12 +65,17 @@ export function pageResources(
|
|||||||
return resources
|
return resources
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTranscludes(
|
export function renderPage(
|
||||||
root: Root,
|
|
||||||
cfg: GlobalConfiguration,
|
cfg: GlobalConfiguration,
|
||||||
slug: FullSlug,
|
slug: FullSlug,
|
||||||
componentData: QuartzComponentProps,
|
componentData: QuartzComponentProps,
|
||||||
) {
|
components: RenderComponents,
|
||||||
|
pageResources: StaticResources,
|
||||||
|
): string {
|
||||||
|
// make a deep copy of the tree so we don't remove the transclusion references
|
||||||
|
// for the file cached in contentMap in build.ts
|
||||||
|
const root = clone(componentData.tree) as Root
|
||||||
|
|
||||||
// process transcludes in componentData
|
// process transcludes in componentData
|
||||||
visit(root, "element", (node, _index, _parent) => {
|
visit(root, "element", (node, _index, _parent) => {
|
||||||
if (node.tagName === "blockquote") {
|
if (node.tagName === "blockquote") {
|
||||||
@@ -184,19 +191,6 @@ function renderTranscludes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export function renderPage(
|
|
||||||
cfg: GlobalConfiguration,
|
|
||||||
slug: FullSlug,
|
|
||||||
componentData: QuartzComponentProps,
|
|
||||||
components: RenderComponents,
|
|
||||||
pageResources: StaticResources,
|
|
||||||
): string {
|
|
||||||
// make a deep copy of the tree so we don't remove the transclusion references
|
|
||||||
// for the file cached in contentMap in build.ts
|
|
||||||
const root = clone(componentData.tree) as Root
|
|
||||||
renderTranscludes(root, cfg, slug, componentData)
|
|
||||||
|
|
||||||
// set componentData.tree to the edited html that has transclusions rendered
|
// set componentData.tree to the edited html that has transclusions rendered
|
||||||
componentData.tree = root
|
componentData.tree = root
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", () => {
|
||||||
const switchTheme = () => {
|
const switchTheme = (e: Event) => {
|
||||||
const newTheme =
|
const newTheme =
|
||||||
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
||||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||||
|
|||||||
@@ -134,9 +134,9 @@ function createFolderNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
const childNode = child.isFolder
|
const childNode = child.data
|
||||||
? createFolderNode(currentSlug, child, opts)
|
? createFileNode(currentSlug, child)
|
||||||
: createFileNode(currentSlug, child)
|
: createFolderNode(currentSlug, child, opts)
|
||||||
ul.appendChild(childNode)
|
ul.appendChild(childNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ async function setupExplorer(currentSlug: FullSlug) {
|
|||||||
// Get folder state from local storage
|
// Get folder state from local storage
|
||||||
const storageTree = localStorage.getItem("fileTree")
|
const storageTree = localStorage.getItem("fileTree")
|
||||||
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||||
const oldIndex = new Map<string, boolean>(
|
const oldIndex = new Map(
|
||||||
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,14 +186,10 @@ async function setupExplorer(currentSlug: FullSlug) {
|
|||||||
|
|
||||||
// Get folder paths for state management
|
// Get folder paths for state management
|
||||||
const folderPaths = trie.getFolderPaths()
|
const folderPaths = trie.getFolderPaths()
|
||||||
currentExplorerState = folderPaths.map((path) => {
|
currentExplorerState = folderPaths.map((path) => ({
|
||||||
const previousState = oldIndex.get(path)
|
path,
|
||||||
return {
|
collapsed: oldIndex.get(path) === true,
|
||||||
path,
|
}))
|
||||||
collapsed:
|
|
||||||
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const explorerUl = explorer.querySelector(".explorer-ul")
|
const explorerUl = explorer.querySelector(".explorer-ul")
|
||||||
if (!explorerUl) continue
|
if (!explorerUl) continue
|
||||||
@@ -263,17 +259,15 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
|||||||
await setupExplorer(currentSlug)
|
await setupExplorer(currentSlug)
|
||||||
|
|
||||||
// if mobile hamburger is visible, collapse by default
|
// if mobile hamburger is visible, collapse by default
|
||||||
for (const explorer of document.getElementsByClassName("explorer")) {
|
for (const explorer of document.getElementsByClassName("mobile-explorer")) {
|
||||||
const mobileExplorer = explorer.querySelector(".mobile-explorer")
|
if (explorer.checkVisibility()) {
|
||||||
if (!mobileExplorer) return
|
|
||||||
|
|
||||||
if (mobileExplorer.checkVisibility()) {
|
|
||||||
explorer.classList.add("collapsed")
|
explorer.classList.add("collapsed")
|
||||||
explorer.setAttribute("aria-expanded", "false")
|
explorer.setAttribute("aria-expanded", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
mobileExplorer.classList.remove("hide-until-loaded")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
|
||||||
|
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
|
||||||
})
|
})
|
||||||
|
|
||||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
|||||||
})
|
})
|
||||||
.circle(0, 0, nodeRadius(n))
|
.circle(0, 0, nodeRadius(n))
|
||||||
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
||||||
|
.stroke({ width: isTagNode ? 2 : 0, color: color(n) })
|
||||||
.on("pointerover", (e) => {
|
.on("pointerover", (e) => {
|
||||||
updateHoverInfo(e.target.label)
|
updateHoverInfo(e.target.label)
|
||||||
oldLabelOpacity = label.alpha
|
oldLabelOpacity = label.alpha
|
||||||
@@ -415,10 +416,6 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isTagNode) {
|
|
||||||
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
|
||||||
}
|
|
||||||
|
|
||||||
nodesContainer.addChild(gfx)
|
nodesContainer.addChild(gfx)
|
||||||
labelsContainer.addChild(label)
|
labelsContainer.addChild(label)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { normalizeRelativeURLs } from "../../util/path"
|
|||||||
import { fetchCanonical } from "./util"
|
import { fetchCanonical } from "./util"
|
||||||
|
|
||||||
const p = new DOMParser()
|
const p = new DOMParser()
|
||||||
|
|
||||||
async function mouseEnterHandler(
|
async function mouseEnterHandler(
|
||||||
this: HTMLAnchorElement,
|
this: HTMLAnchorElement,
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
@@ -15,42 +14,29 @@ async function mouseEnterHandler(
|
|||||||
|
|
||||||
async function setPosition(popoverElement: HTMLElement) {
|
async function setPosition(popoverElement: HTMLElement) {
|
||||||
const { x, y } = await computePosition(link, popoverElement, {
|
const { x, y } = await computePosition(link, popoverElement, {
|
||||||
strategy: "fixed",
|
|
||||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||||
})
|
})
|
||||||
Object.assign(popoverElement.style, {
|
Object.assign(popoverElement.style, {
|
||||||
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPopover(popoverElement: HTMLElement) {
|
const hasAlreadyBeenFetched = () =>
|
||||||
clearActivePopover()
|
[...link.children].some((child) => child.classList.contains("popover"))
|
||||||
popoverElement.classList.add("active-popover")
|
|
||||||
setPosition(popoverElement as HTMLElement)
|
|
||||||
|
|
||||||
if (hash !== "") {
|
// dont refetch if there's already a popover
|
||||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
if (hasAlreadyBeenFetched()) {
|
||||||
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
|
return setPosition(link.lastChild as HTMLElement)
|
||||||
if (heading) {
|
|
||||||
// leave ~12px of buffer when scrolling to a heading
|
|
||||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thisUrl = new URL(document.location.href)
|
||||||
|
thisUrl.hash = ""
|
||||||
|
thisUrl.search = ""
|
||||||
const targetUrl = new URL(link.href)
|
const targetUrl = new URL(link.href)
|
||||||
const hash = decodeURIComponent(targetUrl.hash)
|
const hash = decodeURIComponent(targetUrl.hash)
|
||||||
targetUrl.hash = ""
|
targetUrl.hash = ""
|
||||||
targetUrl.search = ""
|
targetUrl.search = ""
|
||||||
const popoverId = `popover-${link.pathname}`
|
|
||||||
const prevPopoverElement = document.getElementById(popoverId)
|
|
||||||
const hasAlreadyBeenFetched = () => !!document.getElementById(popoverId)
|
|
||||||
|
|
||||||
// dont refetch if there's already a popover
|
|
||||||
if (hasAlreadyBeenFetched()) {
|
|
||||||
showPopover(prevPopoverElement as HTMLElement)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -66,13 +52,13 @@ async function mouseEnterHandler(
|
|||||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||||
|
|
||||||
const popoverElement = document.createElement("div")
|
const popoverElement = document.createElement("div")
|
||||||
popoverElement.id = popoverId
|
|
||||||
popoverElement.classList.add("popover")
|
popoverElement.classList.add("popover")
|
||||||
const popoverInner = document.createElement("div")
|
const popoverInner = document.createElement("div")
|
||||||
popoverInner.classList.add("popover-inner")
|
popoverInner.classList.add("popover-inner")
|
||||||
popoverInner.dataset.contentType = contentType ?? undefined
|
|
||||||
popoverElement.appendChild(popoverInner)
|
popoverElement.appendChild(popoverInner)
|
||||||
|
|
||||||
|
popoverInner.dataset.contentType = contentType ?? undefined
|
||||||
|
|
||||||
switch (contentTypeCategory) {
|
switch (contentTypeCategory) {
|
||||||
case "image":
|
case "image":
|
||||||
const img = document.createElement("img")
|
const img = document.createElement("img")
|
||||||
@@ -96,34 +82,30 @@ async function mouseEnterHandler(
|
|||||||
const contents = await response.text()
|
const contents = await response.text()
|
||||||
const html = p.parseFromString(contents, "text/html")
|
const html = p.parseFromString(contents, "text/html")
|
||||||
normalizeRelativeURLs(html, targetUrl)
|
normalizeRelativeURLs(html, targetUrl)
|
||||||
// prepend all IDs inside popovers to prevent duplicates
|
// strip all IDs from elements to prevent duplicates
|
||||||
html.querySelectorAll("[id]").forEach((el) => {
|
html.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"))
|
||||||
const targetID = `popover-internal-${el.id}`
|
|
||||||
el.id = targetID
|
|
||||||
})
|
|
||||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||||
if (elts.length === 0) return
|
if (elts.length === 0) return
|
||||||
|
|
||||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.appendChild(popoverElement)
|
setPosition(popoverElement)
|
||||||
showPopover(popoverElement)
|
link.appendChild(popoverElement)
|
||||||
}
|
|
||||||
|
|
||||||
function clearActivePopover() {
|
if (hash !== "") {
|
||||||
const allPopoverElements = document.querySelectorAll(".popover")
|
const heading = popoverInner.querySelector(hash) as HTMLElement | null
|
||||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
if (heading) {
|
||||||
|
// leave ~12px of buffer when scrolling to a heading
|
||||||
|
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", () => {
|
||||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||||
link.addEventListener("mouseleave", clearActivePopover)
|
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
|
||||||
window.addCleanup(() => {
|
|
||||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
|
||||||
link.removeEventListener("mouseleave", clearActivePopover)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
let isReaderMode = false
|
|
||||||
|
|
||||||
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
|
|
||||||
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
|
|
||||||
detail: { mode },
|
|
||||||
})
|
|
||||||
document.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
|
||||||
const switchReaderMode = () => {
|
|
||||||
isReaderMode = !isReaderMode
|
|
||||||
const newMode = isReaderMode ? "on" : "off"
|
|
||||||
document.documentElement.setAttribute("reader-mode", newMode)
|
|
||||||
emitReaderModeChangeEvent(newMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const readerModeButton of document.getElementsByClassName("readermode")) {
|
|
||||||
readerModeButton.addEventListener("click", switchReaderMode)
|
|
||||||
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial state
|
|
||||||
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
|
|
||||||
})
|
|
||||||
@@ -147,7 +147,8 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
|||||||
const container = searchElement.querySelector(".search-container") as HTMLElement
|
const container = searchElement.querySelector(".search-container") as HTMLElement
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const sidebar = container.closest(".sidebar") as HTMLElement | null
|
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||||
|
if (!sidebar) return
|
||||||
|
|
||||||
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
||||||
if (!searchButton) return
|
if (!searchButton) return
|
||||||
@@ -179,7 +180,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
|||||||
function hideSearch() {
|
function hideSearch() {
|
||||||
container.classList.remove("active")
|
container.classList.remove("active")
|
||||||
searchBar.value = "" // clear the input when we dismiss the search
|
searchBar.value = "" // clear the input when we dismiss the search
|
||||||
if (sidebar) sidebar.style.zIndex = ""
|
sidebar.style.zIndex = ""
|
||||||
removeAllChildren(results)
|
removeAllChildren(results)
|
||||||
if (preview) {
|
if (preview) {
|
||||||
removeAllChildren(preview)
|
removeAllChildren(preview)
|
||||||
@@ -191,7 +192,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
|||||||
|
|
||||||
function showSearch(searchTypeNew: SearchType) {
|
function showSearch(searchTypeNew: SearchType) {
|
||||||
searchType = searchTypeNew
|
searchType = searchTypeNew
|
||||||
if (sidebar) sidebar.style.zIndex = "1"
|
sidebar.style.zIndex = "1"
|
||||||
container.classList.add("active")
|
container.classList.add("active")
|
||||||
searchBar.focus()
|
searchBar.focus()
|
||||||
}
|
}
|
||||||
@@ -300,11 +301,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
|||||||
itemTile.classList.add("result-card")
|
itemTile.classList.add("result-card")
|
||||||
itemTile.id = slug
|
itemTile.id = slug
|
||||||
itemTile.href = resolveUrl(slug).toString()
|
itemTile.href = resolveUrl(slug).toString()
|
||||||
itemTile.innerHTML = `
|
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${
|
||||||
<h3 class="card-title">${title}</h3>
|
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`
|
||||||
${htmlTags}
|
}`
|
||||||
<p class="card-description">${content}</p>
|
|
||||||
`
|
|
||||||
itemTile.addEventListener("click", (event) => {
|
itemTile.addEventListener("click", (event) => {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
hideSearch()
|
hideSearch()
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const slug = entry.target.id
|
const slug = entry.target.id
|
||||||
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
|
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
|
||||||
const windowHeight = entry.rootBounds?.height
|
const windowHeight = entry.rootBounds?.height
|
||||||
if (windowHeight && tocEntryElements.length > 0) {
|
if (windowHeight && tocEntryElement) {
|
||||||
if (entry.boundingClientRect.y < windowHeight) {
|
if (entry.boundingClientRect.y < windowHeight) {
|
||||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
|
tocEntryElement.classList.add("in-view")
|
||||||
} else {
|
} else {
|
||||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
|
tocEntryElement.classList.remove("in-view")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,10 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > ul.overflow {
|
& > ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
max-height: calc(100% - 2rem);
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
|
|
||||||
& > li {
|
& > li {
|
||||||
& > a {
|
& > a {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0;
|
margin: 0 10px;
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: auto;
|
|
||||||
margin-bottom: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.mobile-explorer {
|
button.mobile-explorer {
|
||||||
@@ -118,7 +116,6 @@ button.desktop-explorer {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overscroll-behavior: contain;
|
|
||||||
|
|
||||||
& li > a {
|
& li > a {
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
@@ -199,7 +196,6 @@ button.desktop-explorer {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
backface-visibility: visible;
|
backface-visibility: visible;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||||
|
|||||||
@@ -16,12 +16,9 @@
|
|||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
position: fixed;
|
position: absolute;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
will-change: transform;
|
|
||||||
|
|
||||||
& > .popover-inner {
|
& > .popover-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -38,10 +35,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
user-select: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .popover-inner[data-content-type] {
|
& > .popover-inner[data-content-type] {
|
||||||
@@ -81,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-popover,
|
a:hover .popover,
|
||||||
.popover:hover {
|
.popover:hover {
|
||||||
animation: dropin 0.3s ease;
|
animation: dropin 0.3s ease;
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: forwards;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
.readermode {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 0;
|
|
||||||
text-align: inherit;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
& svg {
|
|
||||||
position: absolute;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
top: calc(50% - 10px);
|
|
||||||
stroke: var(--darkgray);
|
|
||||||
transition: opacity 0.1s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[reader-mode="on"] {
|
|
||||||
& .sidebar.left,
|
|
||||||
& .sidebar.right {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -133,13 +133,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
flex-direction: column;
|
& > #preview-container {
|
||||||
|
|
||||||
& > .preview-container {
|
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-preview] > .results-container {
|
&[data-preview] > #results-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
@@ -206,12 +204,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and not ($mobile) {
|
|
||||||
& > p.card-description {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul.tags {
|
& > ul.tags {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
@@ -3,11 +3,18 @@
|
|||||||
.toc {
|
.toc {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
min-height: 1.4rem;
|
min-height: 4rem;
|
||||||
flex: 0 0.5 auto;
|
flex: 0 1 auto;
|
||||||
&:has(button.toc-header.collapsed) {
|
&:has(button.toc-header.collapsed) {
|
||||||
flex: 0 1 1.4rem;
|
flex: 0 1 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and not ($mobile) {
|
||||||
|
.toc-header {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,23 +45,23 @@ button.toc-header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.toc-content.overflow {
|
.toc-content {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0.5rem 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: calc(100% - 2rem);
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
& > li > a {
|
& ul {
|
||||||
color: var(--dark);
|
list-style: none;
|
||||||
opacity: 0.35;
|
margin: 0.5rem 0;
|
||||||
transition:
|
padding: 0;
|
||||||
0.5s ease opacity,
|
& > li > a {
|
||||||
0.3s ease color;
|
color: var(--dark);
|
||||||
&.in-view {
|
opacity: 0.35;
|
||||||
opacity: 0.75;
|
transition:
|
||||||
|
0.5s ease opacity,
|
||||||
|
0.3s ease color;
|
||||||
|
&.in-view {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
quartz/depgraph.test.ts
Normal file
118
quartz/depgraph.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import test, { describe } from "node:test"
|
||||||
|
import DepGraph from "./depgraph"
|
||||||
|
import assert from "node:assert"
|
||||||
|
|
||||||
|
describe("DepGraph", () => {
|
||||||
|
test("getLeafNodes", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("D", "C")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getLeafNodeAncestors", () => {
|
||||||
|
test("gets correct ancestors in a graph without cycles", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("D", "B")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gets correct ancestors in a graph with cycles", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("C", "A")
|
||||||
|
graph.addEdge("C", "D")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mergeGraph", () => {
|
||||||
|
test("merges two graphs", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "A.html")
|
||||||
|
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
graph.mergeGraph(other)
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "A.html", "B.md", "B.html"],
|
||||||
|
edges: [
|
||||||
|
["A.md", "A.html"],
|
||||||
|
["B.md", "B.html"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("updateIncomingEdgesForNode", () => {
|
||||||
|
test("merges when node exists", () => {
|
||||||
|
// A.md -> B.md -> B.html
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "B.md")
|
||||||
|
graph.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
// B.md is edited so it removes the A.md transclusion
|
||||||
|
// and adds C.md transclusion
|
||||||
|
// C.md -> B.md
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("C.md", "B.md")
|
||||||
|
other.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
// A.md -> B.md removed, C.md -> B.md added
|
||||||
|
// C.md -> B.md -> B.html
|
||||||
|
graph.updateIncomingEdgesForNode(other, "B.md")
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "B.md", "B.html", "C.md"],
|
||||||
|
edges: [
|
||||||
|
["B.md", "B.html"],
|
||||||
|
["C.md", "B.md"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds node if it does not exist", () => {
|
||||||
|
// A.md -> B.md
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "B.md")
|
||||||
|
|
||||||
|
// Add a new file C.md that transcludes B.md
|
||||||
|
// B.md -> C.md
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("B.md", "C.md")
|
||||||
|
|
||||||
|
// B.md -> C.md added
|
||||||
|
// A.md -> B.md -> C.md
|
||||||
|
graph.updateIncomingEdgesForNode(other, "C.md")
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "B.md", "C.md"],
|
||||||
|
edges: [
|
||||||
|
["A.md", "B.md"],
|
||||||
|
["B.md", "C.md"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
228
quartz/depgraph.ts
Normal file
228
quartz/depgraph.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
export default class DepGraph<T> {
|
||||||
|
// node: incoming and outgoing edges
|
||||||
|
_graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._graph = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
export(): Object {
|
||||||
|
return {
|
||||||
|
nodes: this.nodes,
|
||||||
|
edges: this.edges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return JSON.stringify(this.export(), null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASIC GRAPH OPERATIONS
|
||||||
|
|
||||||
|
get nodes(): T[] {
|
||||||
|
return Array.from(this._graph.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
get edges(): [T, T][] {
|
||||||
|
let edges: [T, T][] = []
|
||||||
|
this.forEachEdge((edge) => edges.push(edge))
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNode(node: T): boolean {
|
||||||
|
return this._graph.has(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(node: T): void {
|
||||||
|
if (!this._graph.has(node)) {
|
||||||
|
this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove node and all edges connected to it
|
||||||
|
removeNode(node: T): void {
|
||||||
|
if (this._graph.has(node)) {
|
||||||
|
// first remove all edges so other nodes don't have references to this node
|
||||||
|
for (const target of this._graph.get(node)!.outgoing) {
|
||||||
|
this.removeEdge(node, target)
|
||||||
|
}
|
||||||
|
for (const source of this._graph.get(node)!.incoming) {
|
||||||
|
this.removeEdge(source, node)
|
||||||
|
}
|
||||||
|
this._graph.delete(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachNode(callback: (node: T) => void): void {
|
||||||
|
for (const node of this._graph.keys()) {
|
||||||
|
callback(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEdge(from: T, to: T): boolean {
|
||||||
|
return Boolean(this._graph.get(from)?.outgoing.has(to))
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdge(from: T, to: T): void {
|
||||||
|
this.addNode(from)
|
||||||
|
this.addNode(to)
|
||||||
|
|
||||||
|
this._graph.get(from)!.outgoing.add(to)
|
||||||
|
this._graph.get(to)!.incoming.add(from)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEdge(from: T, to: T): void {
|
||||||
|
if (this._graph.has(from) && this._graph.has(to)) {
|
||||||
|
this._graph.get(from)!.outgoing.delete(to)
|
||||||
|
this._graph.get(to)!.incoming.delete(from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns -1 if node does not exist
|
||||||
|
outDegree(node: T): number {
|
||||||
|
return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns -1 if node does not exist
|
||||||
|
inDegree(node: T): number {
|
||||||
|
return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
|
||||||
|
this._graph.get(node)?.outgoing.forEach(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
|
||||||
|
this._graph.get(node)?.incoming.forEach(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachEdge(callback: (edge: [T, T]) => void): void {
|
||||||
|
for (const [source, { outgoing }] of this._graph.entries()) {
|
||||||
|
for (const target of outgoing) {
|
||||||
|
callback([source, target])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPENDENCY ALGORITHMS
|
||||||
|
|
||||||
|
// Add all nodes and edges from other graph to this graph
|
||||||
|
mergeGraph(other: DepGraph<T>): void {
|
||||||
|
other.forEachEdge(([source, target]) => {
|
||||||
|
this.addNode(source)
|
||||||
|
this.addNode(target)
|
||||||
|
this.addEdge(source, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the node provided:
|
||||||
|
// If node does not exist, add it
|
||||||
|
// If an incoming edge was added in other, it is added in this graph
|
||||||
|
// If an incoming edge was deleted in other, it is deleted in this graph
|
||||||
|
updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
|
||||||
|
this.addNode(node)
|
||||||
|
|
||||||
|
// Add edge if it is present in other
|
||||||
|
other.forEachInNeighbor(node, (neighbor) => {
|
||||||
|
this.addEdge(neighbor, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
// For node provided, remove incoming edge if it is absent in other
|
||||||
|
this.forEachEdge(([source, target]) => {
|
||||||
|
if (target === node && !other.hasEdge(source, target)) {
|
||||||
|
this.removeEdge(source, target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all nodes that do not have any incoming or outgoing edges
|
||||||
|
// A node may be orphaned if the only node pointing to it was removed
|
||||||
|
removeOrphanNodes(): Set<T> {
|
||||||
|
let orphanNodes = new Set<T>()
|
||||||
|
|
||||||
|
this.forEachNode((node) => {
|
||||||
|
if (this.inDegree(node) === 0 && this.outDegree(node) === 0) {
|
||||||
|
orphanNodes.add(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
orphanNodes.forEach((node) => {
|
||||||
|
this.removeNode(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
return orphanNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all leaf nodes (i.e. destination paths) reachable from the node provided
|
||||||
|
// Eg. if the graph is A -> B -> C
|
||||||
|
// D ---^
|
||||||
|
// and the node is B, this function returns [C]
|
||||||
|
getLeafNodes(node: T): Set<T> {
|
||||||
|
let stack: T[] = [node]
|
||||||
|
let visited = new Set<T>()
|
||||||
|
let leafNodes = new Set<T>()
|
||||||
|
|
||||||
|
// DFS
|
||||||
|
while (stack.length > 0) {
|
||||||
|
let node = stack.pop()!
|
||||||
|
|
||||||
|
// If the node is already visited, skip it
|
||||||
|
if (visited.has(node)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited.add(node)
|
||||||
|
|
||||||
|
// Check if the node is a leaf node (i.e. destination path)
|
||||||
|
if (this.outDegree(node) === 0) {
|
||||||
|
leafNodes.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all unvisited neighbors to the stack
|
||||||
|
this.forEachOutNeighbor(node, (neighbor) => {
|
||||||
|
if (!visited.has(neighbor)) {
|
||||||
|
stack.push(neighbor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return leafNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all ancestors of the leaf nodes reachable from the node provided
|
||||||
|
// Eg. if the graph is A -> B -> C
|
||||||
|
// D ---^
|
||||||
|
// and the node is B, this function returns [A, B, D]
|
||||||
|
getLeafNodeAncestors(node: T): Set<T> {
|
||||||
|
const leafNodes = this.getLeafNodes(node)
|
||||||
|
let visited = new Set<T>()
|
||||||
|
let upstreamNodes = new Set<T>()
|
||||||
|
|
||||||
|
// Backwards DFS for each leaf node
|
||||||
|
leafNodes.forEach((leafNode) => {
|
||||||
|
let stack: T[] = [leafNode]
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
let node = stack.pop()!
|
||||||
|
|
||||||
|
if (visited.has(node)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited.add(node)
|
||||||
|
// Add node if it's not a leaf node (i.e. destination path)
|
||||||
|
// Assumes destination file cannot depend on another destination file
|
||||||
|
if (this.outDegree(node) !== 0) {
|
||||||
|
upstreamNodes.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all unvisited parents to the stack
|
||||||
|
this.forEachInNeighbor(node, (parentNode) => {
|
||||||
|
if (!visited.has(parentNode)) {
|
||||||
|
stack.push(parentNode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return upstreamNodes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import { QuartzComponentProps } from "../../components/types"
|
|||||||
import BodyConstructor from "../../components/Body"
|
import BodyConstructor from "../../components/Body"
|
||||||
import { pageResources, renderPage } from "../../components/renderPage"
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
import { FullPageLayout } from "../../cfg"
|
import { FullPageLayout } from "../../cfg"
|
||||||
import { FullSlug } from "../../util/path"
|
import { FilePath, FullSlug } from "../../util/path"
|
||||||
import { sharedPageComponents } from "../../../quartz.layout"
|
import { sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { NotFound } from "../../components"
|
import { NotFound } from "../../components"
|
||||||
import { defaultProcessedContent } from "../vfile"
|
import { defaultProcessedContent } from "../vfile"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
@@ -27,6 +28,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [Head, Body, pageBody, Footer]
|
return [Head, Body, pageBody, Footer]
|
||||||
},
|
},
|
||||||
|
async getDependencyGraph(_ctx, _content, _resources) {
|
||||||
|
return new DepGraph<FilePath>()
|
||||||
|
},
|
||||||
async *emit(ctx, _content, resources) {
|
async *emit(ctx, _content, resources) {
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
const slug = "404" as FullSlug
|
const slug = "404" as FullSlug
|
||||||
@@ -40,7 +44,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
description: notFound,
|
description: notFound,
|
||||||
frontmatter: { title: notFound, tags: [] },
|
frontmatter: { title: notFound, tags: [] },
|
||||||
})
|
})
|
||||||
const externalResources = pageResources(path, resources)
|
const externalResources = pageResources(path, vfile.data, resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
ctx,
|
ctx,
|
||||||
fileData: vfile.data,
|
fileData: vfile.data,
|
||||||
@@ -58,6 +62,5 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
ext: ".html",
|
ext: ".html",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async *partialEmit() {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,46 @@
|
|||||||
import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path"
|
import { FilePath, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
import DepGraph from "../../depgraph"
|
||||||
import { VFile } from "vfile"
|
import { getAliasSlugs } from "../transformers/frontmatter"
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
async function* processFile(ctx: BuildCtx, file: VFile) {
|
|
||||||
const ogSlug = simplifySlug(file.data.slug!)
|
|
||||||
|
|
||||||
for (const aliasTarget of file.data.aliases ?? []) {
|
|
||||||
const aliasTargetSlug = (
|
|
||||||
isRelativeURL(aliasTarget)
|
|
||||||
? path.normalize(path.join(ogSlug, "..", aliasTarget))
|
|
||||||
: aliasTarget
|
|
||||||
) as FullSlug
|
|
||||||
|
|
||||||
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug)
|
|
||||||
yield write({
|
|
||||||
ctx,
|
|
||||||
content: `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en-us">
|
|
||||||
<head>
|
|
||||||
<title>${ogSlug}</title>
|
|
||||||
<link rel="canonical" href="${redirUrl}">
|
|
||||||
<meta name="robots" content="noindex">
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="refresh" content="0; url=${redirUrl}">
|
|
||||||
</head>
|
|
||||||
</html>
|
|
||||||
`,
|
|
||||||
slug: aliasTargetSlug,
|
|
||||||
ext: ".html",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||||
name: "AliasRedirects",
|
name: "AliasRedirects",
|
||||||
async *emit(ctx, content) {
|
async getDependencyGraph(ctx, content, _resources) {
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
const { argv } = ctx
|
||||||
for (const [_tree, file] of content) {
|
for (const [_tree, file] of content) {
|
||||||
yield* processFile(ctx, file)
|
for (const slug of getAliasSlugs(file.data.frontmatter?.aliases ?? [], argv, file)) {
|
||||||
|
graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
},
|
},
|
||||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
async *emit(ctx, content, _resources) {
|
||||||
for (const changeEvent of changeEvents) {
|
for (const [_tree, file] of content) {
|
||||||
if (!changeEvent.file) continue
|
const ogSlug = simplifySlug(file.data.slug!)
|
||||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
|
||||||
// add new ones if this file still exists
|
for (const slug of file.data.aliases ?? []) {
|
||||||
yield* processFile(ctx, changeEvent.file)
|
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||||
|
yield write({
|
||||||
|
ctx,
|
||||||
|
content: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en-us">
|
||||||
|
<head>
|
||||||
|
<title>${ogSlug}</title>
|
||||||
|
<link rel="canonical" href="${redirUrl}">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="0; url=${redirUrl}">
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { QuartzEmitterPlugin } from "../types"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { glob } from "../../util/glob"
|
import { glob } from "../../util/glob"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
import { Argv } from "../../util/ctx"
|
import { Argv } from "../../util/ctx"
|
||||||
import { QuartzConfig } from "../../cfg"
|
import { QuartzConfig } from "../../cfg"
|
||||||
|
|
||||||
@@ -11,41 +12,40 @@ const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
|
|||||||
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyFile = async (argv: Argv, fp: FilePath) => {
|
|
||||||
const src = joinSegments(argv.directory, fp) as FilePath
|
|
||||||
|
|
||||||
const name = slugifyFilePath(fp)
|
|
||||||
const dest = joinSegments(argv.output, name) as FilePath
|
|
||||||
|
|
||||||
// ensure dir exists
|
|
||||||
const dir = path.dirname(dest) as FilePath
|
|
||||||
await fs.promises.mkdir(dir, { recursive: true })
|
|
||||||
|
|
||||||
await fs.promises.copyFile(src, dest)
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Assets: QuartzEmitterPlugin = () => {
|
export const Assets: QuartzEmitterPlugin = () => {
|
||||||
return {
|
return {
|
||||||
name: "Assets",
|
name: "Assets",
|
||||||
async *emit({ argv, cfg }) {
|
async getDependencyGraph(ctx, _content, _resources) {
|
||||||
|
const { argv, cfg } = ctx
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
const fps = await filesToCopy(argv, cfg)
|
||||||
|
|
||||||
|
for (const fp of fps) {
|
||||||
|
const ext = path.extname(fp)
|
||||||
|
const src = joinSegments(argv.directory, fp) as FilePath
|
||||||
|
const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
|
||||||
|
|
||||||
|
const dest = joinSegments(argv.output, name) as FilePath
|
||||||
|
|
||||||
|
graph.addEdge(src, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
|
async *emit({ argv, cfg }, _content, _resources) {
|
||||||
|
const assetsPath = argv.output
|
||||||
const fps = await filesToCopy(argv, cfg)
|
const fps = await filesToCopy(argv, cfg)
|
||||||
for (const fp of fps) {
|
for (const fp of fps) {
|
||||||
yield copyFile(argv, fp)
|
const ext = path.extname(fp)
|
||||||
}
|
const src = joinSegments(argv.directory, fp) as FilePath
|
||||||
},
|
const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
|
||||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
|
||||||
for (const changeEvent of changeEvents) {
|
|
||||||
const ext = path.extname(changeEvent.path)
|
|
||||||
if (ext === ".md") continue
|
|
||||||
|
|
||||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
const dest = joinSegments(assetsPath, name) as FilePath
|
||||||
yield copyFile(ctx.argv, changeEvent.path)
|
const dir = path.dirname(dest) as FilePath
|
||||||
} else if (changeEvent.type === "delete") {
|
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
|
||||||
const name = slugifyFilePath(changeEvent.path)
|
await fs.promises.copyFile(src, dest)
|
||||||
const dest = joinSegments(ctx.argv.output, name) as FilePath
|
yield dest
|
||||||
await fs.promises.unlink(dest)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path"
|
|||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
export function extractDomainFromBaseUrl(baseUrl: string) {
|
export function extractDomainFromBaseUrl(baseUrl: string) {
|
||||||
const url = new URL(`https://${baseUrl}`)
|
const url = new URL(`https://${baseUrl}`)
|
||||||
@@ -10,7 +11,10 @@ export function extractDomainFromBaseUrl(baseUrl: string) {
|
|||||||
|
|
||||||
export const CNAME: QuartzEmitterPlugin = () => ({
|
export const CNAME: QuartzEmitterPlugin = () => ({
|
||||||
name: "CNAME",
|
name: "CNAME",
|
||||||
async emit({ argv, cfg }) {
|
async getDependencyGraph(_ctx, _content, _resources) {
|
||||||
|
return new DepGraph<FilePath>()
|
||||||
|
},
|
||||||
|
async emit({ argv, cfg }, _content, _resources) {
|
||||||
if (!cfg.configuration.baseUrl) {
|
if (!cfg.configuration.baseUrl) {
|
||||||
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
||||||
return []
|
return []
|
||||||
@@ -23,5 +27,4 @@ export const CNAME: QuartzEmitterPlugin = () => ({
|
|||||||
await fs.promises.writeFile(path, content)
|
await fs.promises.writeFile(path, content)
|
||||||
return [path] as FilePath[]
|
return [path] as FilePath[]
|
||||||
},
|
},
|
||||||
async *partialEmit() {},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FullSlug, joinSegments } from "../../util/path"
|
import { FilePath, FullSlug, joinSegments } from "../../util/path"
|
||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -9,15 +9,11 @@ import styles from "../../styles/custom.scss"
|
|||||||
import popoverStyle from "../../components/styles/popover.scss"
|
import popoverStyle from "../../components/styles/popover.scss"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
import { BuildCtx } from "../../util/ctx"
|
||||||
import { QuartzComponent } from "../../components/types"
|
import { QuartzComponent } from "../../components/types"
|
||||||
import {
|
import { googleFontHref, joinStyles, processGoogleFonts } from "../../util/theme"
|
||||||
googleFontHref,
|
|
||||||
googleFontSubsetHref,
|
|
||||||
joinStyles,
|
|
||||||
processGoogleFonts,
|
|
||||||
} from "../../util/theme"
|
|
||||||
import { Features, transform } from "lightningcss"
|
import { Features, transform } from "lightningcss"
|
||||||
import { transform as transpile } from "esbuild"
|
import { transform as transpile } from "esbuild"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
type ComponentResources = {
|
type ComponentResources = {
|
||||||
css: string[]
|
css: string[]
|
||||||
@@ -88,121 +84,103 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
|||||||
if (cfg.analytics?.provider === "google") {
|
if (cfg.analytics?.provider === "google") {
|
||||||
const tagId = cfg.analytics.tagId
|
const tagId = cfg.analytics.tagId
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const gtagScript = document.createElement('script');
|
const gtagScript = document.createElement("script")
|
||||||
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
|
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}"
|
||||||
gtagScript.defer = true;
|
gtagScript.async = true
|
||||||
gtagScript.onload = () => {
|
document.head.appendChild(gtagScript)
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag() {
|
window.dataLayer = window.dataLayer || [];
|
||||||
dataLayer.push(arguments);
|
function gtag() { dataLayer.push(arguments); }
|
||||||
}
|
gtag("js", new Date());
|
||||||
gtag('js', new Date());
|
gtag("config", "${tagId}", { send_page_view: false });
|
||||||
gtag('config', '${tagId}', { send_page_view: false });
|
|
||||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
document.addEventListener("nav", () => {
|
||||||
document.addEventListener('nav', () => {
|
gtag("event", "page_view", {
|
||||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
page_title: document.title,
|
||||||
|
page_location: location.href,
|
||||||
});
|
});
|
||||||
};
|
});`)
|
||||||
|
|
||||||
document.head.appendChild(gtagScript);
|
|
||||||
`)
|
|
||||||
} else if (cfg.analytics?.provider === "plausible") {
|
} else if (cfg.analytics?.provider === "plausible") {
|
||||||
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
|
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const plausibleScript = document.createElement('script');
|
const plausibleScript = document.createElement("script")
|
||||||
plausibleScript.src = '${plausibleHost}/js/script.manual.js';
|
plausibleScript.src = "${plausibleHost}/js/script.manual.js"
|
||||||
plausibleScript.setAttribute('data-domain', location.hostname);
|
plausibleScript.setAttribute("data-domain", location.hostname)
|
||||||
plausibleScript.defer = true;
|
plausibleScript.defer = true
|
||||||
plausibleScript.onload = () => {
|
document.head.appendChild(plausibleScript)
|
||||||
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
|
|
||||||
plausible('pageview');
|
|
||||||
document.addEventListener('nav', () => {
|
|
||||||
plausible('pageview');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(plausibleScript);
|
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
plausible("pageview")
|
||||||
|
})
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "umami") {
|
} else if (cfg.analytics?.provider === "umami") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const umamiScript = document.createElement("script");
|
const umamiScript = document.createElement("script")
|
||||||
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js";
|
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"
|
||||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
|
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||||
umamiScript.setAttribute("data-auto-track", "false");
|
umamiScript.setAttribute("data-auto-track", "false")
|
||||||
umamiScript.defer = true;
|
umamiScript.async = true
|
||||||
umamiScript.onload = () => {
|
document.head.appendChild(umamiScript)
|
||||||
umami.track();
|
|
||||||
document.addEventListener("nav", () => {
|
|
||||||
umami.track();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(umamiScript);
|
document.addEventListener("nav", () => {
|
||||||
|
umami.track();
|
||||||
|
})
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "goatcounter") {
|
} else if (cfg.analytics?.provider === "goatcounter") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const goatcounterScript = document.createElement('script');
|
const goatcounterScript = document.createElement("script")
|
||||||
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
|
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
|
||||||
goatcounterScript.defer = true;
|
goatcounterScript.async = true
|
||||||
goatcounterScript.setAttribute(
|
goatcounterScript.setAttribute("data-goatcounter",
|
||||||
'data-goatcounter',
|
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
|
||||||
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count"
|
document.head.appendChild(goatcounterScript)
|
||||||
);
|
|
||||||
goatcounterScript.onload = () => {
|
|
||||||
window.goatcounter = { no_onload: true };
|
|
||||||
goatcounter.count({ path: location.pathname });
|
|
||||||
document.addEventListener('nav', () => {
|
|
||||||
goatcounter.count({ path: location.pathname });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(goatcounterScript);
|
window.goatcounter = { no_onload: true }
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
goatcounter.count({ path: location.pathname })
|
||||||
|
})
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "posthog") {
|
} else if (cfg.analytics?.provider === "posthog") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const posthogScript = document.createElement("script");
|
const posthogScript = document.createElement("script")
|
||||||
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||||
posthog.init('${cfg.analytics.apiKey}', {
|
posthog.init('${cfg.analytics.apiKey}', {
|
||||||
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
|
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
|
||||||
capture_pageview: false,
|
capture_pageview: false,
|
||||||
})\`
|
})\`
|
||||||
posthogScript.onload = () => {
|
document.head.appendChild(posthogScript)
|
||||||
posthog.capture('$pageview', { path: location.pathname });
|
|
||||||
|
|
||||||
document.addEventListener('nav', () => {
|
|
||||||
posthog.capture('$pageview', { path: location.pathname });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(posthogScript);
|
document.addEventListener("nav", () => {
|
||||||
|
posthog.capture('$pageview', { path: location.pathname })
|
||||||
|
})
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "tinylytics") {
|
} else if (cfg.analytics?.provider === "tinylytics") {
|
||||||
const siteId = cfg.analytics.siteId
|
const siteId = cfg.analytics.siteId
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const tinylyticsScript = document.createElement('script');
|
const tinylyticsScript = document.createElement("script")
|
||||||
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
|
tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js?spa"
|
||||||
tinylyticsScript.defer = true;
|
tinylyticsScript.defer = true
|
||||||
tinylyticsScript.onload = () => {
|
document.head.appendChild(tinylyticsScript)
|
||||||
window.tinylytics.triggerUpdate();
|
|
||||||
document.addEventListener('nav', () => {
|
document.addEventListener("nav", () => {
|
||||||
window.tinylytics.triggerUpdate();
|
window.tinylytics.triggerUpdate()
|
||||||
});
|
})
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(tinylyticsScript);
|
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "cabin") {
|
} else if (cfg.analytics?.provider === "cabin") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const cabinScript = document.createElement("script")
|
const cabinScript = document.createElement("script")
|
||||||
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
|
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
|
||||||
cabinScript.defer = true
|
cabinScript.defer = true
|
||||||
|
cabinScript.async = true
|
||||||
document.head.appendChild(cabinScript)
|
document.head.appendChild(cabinScript)
|
||||||
`)
|
`)
|
||||||
} else if (cfg.analytics?.provider === "clarity") {
|
} else if (cfg.analytics?.provider === "clarity") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const clarityScript = document.createElement("script")
|
const clarityScript = document.createElement("script")
|
||||||
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||||
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i;
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||||
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
|
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
|
||||||
document.head.appendChild(clarityScript)
|
document.head.appendChild(clarityScript)
|
||||||
@@ -226,6 +204,9 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
|||||||
export const ComponentResources: QuartzEmitterPlugin = () => {
|
export const ComponentResources: QuartzEmitterPlugin = () => {
|
||||||
return {
|
return {
|
||||||
name: "ComponentResources",
|
name: "ComponentResources",
|
||||||
|
async getDependencyGraph(_ctx, _content, _resources) {
|
||||||
|
return new DepGraph<FilePath>()
|
||||||
|
},
|
||||||
async *emit(ctx, _content, _resources) {
|
async *emit(ctx, _content, _resources) {
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
// component specific scripts and styles
|
// component specific scripts and styles
|
||||||
@@ -235,16 +216,9 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
|
|||||||
// let the user do it themselves in css
|
// let the user do it themselves in css
|
||||||
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
|
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
|
||||||
// when cdnCaching is true, we link to google fonts in Head.tsx
|
// when cdnCaching is true, we link to google fonts in Head.tsx
|
||||||
const theme = ctx.cfg.configuration.theme
|
const response = await fetch(googleFontHref(ctx.cfg.configuration.theme))
|
||||||
const response = await fetch(googleFontHref(theme))
|
|
||||||
googleFontsStyleSheet = await response.text()
|
googleFontsStyleSheet = await response.text()
|
||||||
|
|
||||||
if (theme.typography.title) {
|
|
||||||
const title = ctx.cfg.configuration.pageTitle
|
|
||||||
const response = await fetch(googleFontSubsetHref(theme, title))
|
|
||||||
googleFontsStyleSheet += `\n${await response.text()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cfg.baseUrl) {
|
if (!cfg.baseUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
|
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
|
||||||
@@ -261,7 +235,7 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
|
|||||||
for (const fontFile of fontFiles) {
|
for (const fontFile of fontFiles) {
|
||||||
const res = await fetch(fontFile.url)
|
const res = await fetch(fontFile.url)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch font ${fontFile.filename}`)
|
throw new Error(`failed to fetch font ${fontFile.filename}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buf = await res.arrayBuffer()
|
const buf = await res.arrayBuffer()
|
||||||
@@ -308,22 +282,19 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
|
|||||||
},
|
},
|
||||||
include: Features.MediaQueries,
|
include: Features.MediaQueries,
|
||||||
}).code.toString(),
|
}).code.toString(),
|
||||||
})
|
}),
|
||||||
|
yield write({
|
||||||
yield write({
|
ctx,
|
||||||
ctx,
|
slug: "prescript" as FullSlug,
|
||||||
slug: "prescript" as FullSlug,
|
ext: ".js",
|
||||||
ext: ".js",
|
content: prescript,
|
||||||
content: prescript,
|
}),
|
||||||
})
|
yield write({
|
||||||
|
ctx,
|
||||||
yield write({
|
slug: "postscript" as FullSlug,
|
||||||
ctx,
|
ext: ".js",
|
||||||
slug: "postscript" as FullSlug,
|
content: postscript,
|
||||||
ext: ".js",
|
})
|
||||||
content: postscript,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async *partialEmit() {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { QuartzEmitterPlugin } from "../types"
|
|||||||
import { toHtml } from "hast-util-to-html"
|
import { toHtml } from "hast-util-to-html"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||||
export type ContentDetails = {
|
export type ContentDetails = {
|
||||||
slug: FullSlug
|
slug: FullSlug
|
||||||
filePath: FilePath
|
|
||||||
title: string
|
title: string
|
||||||
links: SimpleSlug[]
|
links: SimpleSlug[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
@@ -96,7 +96,27 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
opts = { ...defaultOptions, ...opts }
|
opts = { ...defaultOptions, ...opts }
|
||||||
return {
|
return {
|
||||||
name: "ContentIndex",
|
name: "ContentIndex",
|
||||||
async *emit(ctx, content) {
|
async getDependencyGraph(ctx, content, _resources) {
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
for (const [_tree, file] of content) {
|
||||||
|
const sourcePath = file.data.filePath!
|
||||||
|
|
||||||
|
graph.addEdge(
|
||||||
|
sourcePath,
|
||||||
|
joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
|
||||||
|
)
|
||||||
|
if (opts?.enableSiteMap) {
|
||||||
|
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
|
||||||
|
}
|
||||||
|
if (opts?.enableRSS) {
|
||||||
|
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
|
async *emit(ctx, content, _resources) {
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
const linkIndex: ContentIndexMap = new Map()
|
const linkIndex: ContentIndexMap = new Map()
|
||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
@@ -105,7 +125,6 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
linkIndex.set(slug, {
|
linkIndex.set(slug, {
|
||||||
slug,
|
slug,
|
||||||
filePath: file.data.relativePath!,
|
|
||||||
title: file.data.frontmatter?.title!,
|
title: file.data.frontmatter?.title!,
|
||||||
links: file.data.links ?? [],
|
links: file.data.links ?? [],
|
||||||
tags: file.data.frontmatter?.tags ?? [],
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
|
|||||||
@@ -1,48 +1,54 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { visit } from "unist-util-visit"
|
||||||
|
import { Root } from "hast"
|
||||||
|
import { VFile } from "vfile"
|
||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import { QuartzComponentProps } from "../../components/types"
|
import { QuartzComponentProps } from "../../components/types"
|
||||||
import HeaderConstructor from "../../components/Header"
|
import HeaderConstructor from "../../components/Header"
|
||||||
import BodyConstructor from "../../components/Body"
|
import BodyConstructor from "../../components/Body"
|
||||||
import { pageResources, renderPage } from "../../components/renderPage"
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
import { FullPageLayout } from "../../cfg"
|
import { FullPageLayout } from "../../cfg"
|
||||||
import { pathToRoot } from "../../util/path"
|
import { Argv } from "../../util/ctx"
|
||||||
|
import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path"
|
||||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { Content } from "../../components"
|
import { Content } from "../../components"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
import DepGraph from "../../depgraph"
|
||||||
import { Node } from "unist"
|
|
||||||
import { StaticResources } from "../../util/resources"
|
|
||||||
import { QuartzPluginData } from "../vfile"
|
|
||||||
|
|
||||||
async function processContent(
|
// get all the dependencies for the markdown file
|
||||||
ctx: BuildCtx,
|
// eg. images, scripts, stylesheets, transclusions
|
||||||
tree: Node,
|
const parseDependencies = (argv: Argv, hast: Root, file: VFile): string[] => {
|
||||||
fileData: QuartzPluginData,
|
const dependencies: string[] = []
|
||||||
allFiles: QuartzPluginData[],
|
|
||||||
opts: FullPageLayout,
|
|
||||||
resources: StaticResources,
|
|
||||||
) {
|
|
||||||
const slug = fileData.slug!
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
|
||||||
const componentData: QuartzComponentProps = {
|
|
||||||
ctx,
|
|
||||||
fileData,
|
|
||||||
externalResources,
|
|
||||||
cfg,
|
|
||||||
children: [],
|
|
||||||
tree,
|
|
||||||
allFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
visit(hast, "element", (elem): void => {
|
||||||
return write({
|
let ref: string | null = null
|
||||||
ctx,
|
|
||||||
content,
|
if (
|
||||||
slug,
|
["script", "img", "audio", "video", "source", "iframe"].includes(elem.tagName) &&
|
||||||
ext: ".html",
|
elem?.properties?.src
|
||||||
|
) {
|
||||||
|
ref = elem.properties.src.toString()
|
||||||
|
} else if (["a", "link"].includes(elem.tagName) && elem?.properties?.href) {
|
||||||
|
// transclusions will create a tags with relative hrefs
|
||||||
|
ref = elem.properties.href.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it is a relative url, its a local file and we need to add
|
||||||
|
// it to the dependency graph. otherwise, ignore
|
||||||
|
if (ref === null || !isRelativeURL(ref)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fp = path.join(file.data.filePath!, path.relative(argv.directory, ref)).replace(/\\/g, "/")
|
||||||
|
// markdown files have the .md extension stripped in hrefs, add it back here
|
||||||
|
if (!fp.split("/").pop()?.includes(".")) {
|
||||||
|
fp += ".md"
|
||||||
|
}
|
||||||
|
dependencies.push(fp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return dependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||||
@@ -73,22 +79,53 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
Footer,
|
Footer,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
async *emit(ctx, content, resources) {
|
async getDependencyGraph(ctx, content, _resources) {
|
||||||
const allFiles = content.map((c) => c[1].data)
|
const graph = new DepGraph<FilePath>()
|
||||||
let containsIndex = false
|
|
||||||
|
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const sourcePath = file.data.filePath!
|
||||||
|
const slug = file.data.slug!
|
||||||
|
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
|
||||||
|
|
||||||
|
parseDependencies(ctx.argv, tree as Root, file).forEach((dep) => {
|
||||||
|
graph.addEdge(dep as FilePath, sourcePath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
|
async *emit(ctx, content, resources) {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const allFiles = content.map((c) => c[1].data)
|
||||||
|
|
||||||
|
let containsIndex = false
|
||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
if (slug === "index") {
|
if (slug === "index") {
|
||||||
containsIndex = true
|
containsIndex = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// only process home page, non-tag pages, and non-index pages
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
const componentData: QuartzComponentProps = {
|
||||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
ctx,
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||||
|
yield write({
|
||||||
|
ctx,
|
||||||
|
content,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!containsIndex) {
|
if (!containsIndex && !ctx.argv.fastRebuild) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder (\`${path.join(ctx.argv.directory, "index.md")} does not exist\`). This may cause errors when deploying.`,
|
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder (\`${path.join(ctx.argv.directory, "index.md")} does not exist\`). This may cause errors when deploying.`,
|
||||||
@@ -96,25 +133,5 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
|
||||||
const allFiles = content.map((c) => c[1].data)
|
|
||||||
|
|
||||||
// find all slugs that changed or were added
|
|
||||||
const changedSlugs = new Set<string>()
|
|
||||||
for (const changeEvent of changeEvents) {
|
|
||||||
if (!changeEvent.file) continue
|
|
||||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
|
||||||
changedSlugs.add(changeEvent.file.data.slug!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [tree, file] of content) {
|
|
||||||
const slug = file.data.slug!
|
|
||||||
if (!changedSlugs.has(slug)) continue
|
|
||||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
|
||||||
|
|
||||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../
|
|||||||
import { FullPageLayout } from "../../cfg"
|
import { FullPageLayout } from "../../cfg"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import {
|
import {
|
||||||
|
FilePath,
|
||||||
FullSlug,
|
FullSlug,
|
||||||
SimpleSlug,
|
SimpleSlug,
|
||||||
stripSlashes,
|
stripSlashes,
|
||||||
@@ -17,89 +18,13 @@ import {
|
|||||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { FolderContent } from "../../components"
|
import { FolderContent } from "../../components"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { i18n, TRANSLATIONS } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
import DepGraph from "../../depgraph"
|
||||||
import { StaticResources } from "../../util/resources"
|
|
||||||
interface FolderPageOptions extends FullPageLayout {
|
interface FolderPageOptions extends FullPageLayout {
|
||||||
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function* processFolderInfo(
|
|
||||||
ctx: BuildCtx,
|
|
||||||
folderInfo: Record<SimpleSlug, ProcessedContent>,
|
|
||||||
allFiles: QuartzPluginData[],
|
|
||||||
opts: FullPageLayout,
|
|
||||||
resources: StaticResources,
|
|
||||||
) {
|
|
||||||
for (const [folder, folderContent] of Object.entries(folderInfo) as [
|
|
||||||
SimpleSlug,
|
|
||||||
ProcessedContent,
|
|
||||||
][]) {
|
|
||||||
const slug = joinSegments(folder, "index") as FullSlug
|
|
||||||
const [tree, file] = folderContent
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
|
||||||
const componentData: QuartzComponentProps = {
|
|
||||||
ctx,
|
|
||||||
fileData: file.data,
|
|
||||||
externalResources,
|
|
||||||
cfg,
|
|
||||||
children: [],
|
|
||||||
tree,
|
|
||||||
allFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
|
||||||
yield write({
|
|
||||||
ctx,
|
|
||||||
content,
|
|
||||||
slug,
|
|
||||||
ext: ".html",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeFolderInfo(
|
|
||||||
folders: Set<SimpleSlug>,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
locale: keyof typeof TRANSLATIONS,
|
|
||||||
): Record<SimpleSlug, ProcessedContent> {
|
|
||||||
// Create default folder descriptions
|
|
||||||
const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries(
|
|
||||||
[...folders].map((folder) => [
|
|
||||||
folder,
|
|
||||||
defaultProcessedContent({
|
|
||||||
slug: joinSegments(folder, "index") as FullSlug,
|
|
||||||
frontmatter: {
|
|
||||||
title: `${i18n(locale).pages.folderContent.folder}: ${folder}`,
|
|
||||||
tags: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update with actual content if available
|
|
||||||
for (const [tree, file] of content) {
|
|
||||||
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
|
|
||||||
if (folders.has(slug)) {
|
|
||||||
folderInfo[slug] = [tree, file]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return folderInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
|
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
...sharedPageComponents,
|
...sharedPageComponents,
|
||||||
@@ -128,6 +53,22 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
|||||||
Footer,
|
Footer,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
async getDependencyGraph(_ctx, content, _resources) {
|
||||||
|
// Example graph:
|
||||||
|
// nested/file.md --> nested/index.html
|
||||||
|
// nested/file2.md ------^
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
content.map(([_tree, vfile]) => {
|
||||||
|
const slug = vfile.data.slug
|
||||||
|
const folderName = path.dirname(slug ?? "") as SimpleSlug
|
||||||
|
if (slug && folderName !== "." && folderName !== "tags") {
|
||||||
|
graph.addEdge(vfile.data.filePath!, joinSegments(folderName, "index.html") as FilePath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
async *emit(ctx, content, resources) {
|
async *emit(ctx, content, resources) {
|
||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
@@ -142,29 +83,59 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const folderInfo = computeFolderInfo(folders, content, cfg.locale)
|
const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||||
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
|
[...folders].map((folder) => [
|
||||||
},
|
folder,
|
||||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
defaultProcessedContent({
|
||||||
const allFiles = content.map((c) => c[1].data)
|
slug: joinSegments(folder, "index") as FullSlug,
|
||||||
const cfg = ctx.cfg.configuration
|
frontmatter: {
|
||||||
|
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
// Find all folders that need to be updated based on changed files
|
for (const [tree, file] of content) {
|
||||||
const affectedFolders: Set<SimpleSlug> = new Set()
|
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
|
||||||
for (const changeEvent of changeEvents) {
|
if (folders.has(slug)) {
|
||||||
if (!changeEvent.file) continue
|
folderDescriptions[slug] = [tree, file]
|
||||||
const slug = changeEvent.file.data.slug!
|
}
|
||||||
const folders = _getFolders(slug).filter(
|
|
||||||
(folderName) => folderName !== "." && folderName !== "tags",
|
|
||||||
)
|
|
||||||
folders.forEach((folder) => affectedFolders.add(folder))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are affected folders, rebuild their pages
|
for (const folder of folders) {
|
||||||
if (affectedFolders.size > 0) {
|
const slug = joinSegments(folder, "index") as FullSlug
|
||||||
const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale)
|
const [tree, file] = folderDescriptions[folder]
|
||||||
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
ctx,
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||||
|
yield write({
|
||||||
|
ctx,
|
||||||
|
content,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
import { unescapeHTML } from "../../util/escape"
|
import { unescapeHTML } from "../../util/escape"
|
||||||
import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
|
import { FullSlug, getFileExtension } from "../../util/path"
|
||||||
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
|
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
|
||||||
import sharp from "sharp"
|
import sharp from "sharp"
|
||||||
import satori, { SatoriOptions } from "satori"
|
import satori from "satori"
|
||||||
import { loadEmoji, getIconCode } from "../../util/emoji"
|
import { loadEmoji, getIconCode } from "../../util/emoji"
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
|
||||||
import { QuartzPluginData } from "../vfile"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import chalk from "chalk"
|
|
||||||
|
|
||||||
const defaultOptions: SocialImageOptions = {
|
const defaultOptions: SocialImageOptions = {
|
||||||
colorScheme: "lightMode",
|
colorScheme: "lightMode",
|
||||||
@@ -30,34 +26,15 @@ async function generateSocialImage(
|
|||||||
userOpts: SocialImageOptions,
|
userOpts: SocialImageOptions,
|
||||||
): Promise<Readable> {
|
): Promise<Readable> {
|
||||||
const { width, height } = userOpts
|
const { width, height } = userOpts
|
||||||
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
|
||||||
let iconBase64: string | undefined = undefined
|
|
||||||
try {
|
|
||||||
const iconData = await fs.readFile(iconPath)
|
|
||||||
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(chalk.yellow(`Warning: Could not find icon at ${iconPath}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageComponent = userOpts.imageStructure({
|
|
||||||
cfg,
|
|
||||||
userOpts,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
fonts,
|
|
||||||
fileData,
|
|
||||||
iconBase64,
|
|
||||||
})
|
|
||||||
|
|
||||||
const svg = await satori(imageComponent, {
|
const svg = await satori(imageComponent, {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fonts,
|
fonts,
|
||||||
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
||||||
if (languageCode === "emoji") {
|
if (languageCode === "emoji") {
|
||||||
return await loadEmoji(getIconCode(segment))
|
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return languageCode
|
return languageCode
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -65,41 +42,6 @@ async function generateSocialImage(
|
|||||||
return sharp(Buffer.from(svg)).webp({ quality: 40 })
|
return sharp(Buffer.from(svg)).webp({ quality: 40 })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processOgImage(
|
|
||||||
ctx: BuildCtx,
|
|
||||||
fileData: QuartzPluginData,
|
|
||||||
fonts: SatoriOptions["fonts"],
|
|
||||||
fullOptions: SocialImageOptions,
|
|
||||||
) {
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
const slug = fileData.slug!
|
|
||||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
|
||||||
const title =
|
|
||||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
|
||||||
const description =
|
|
||||||
fileData.frontmatter?.socialDescription ??
|
|
||||||
fileData.frontmatter?.description ??
|
|
||||||
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
|
||||||
|
|
||||||
const stream = await generateSocialImage(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
fonts,
|
|
||||||
cfg,
|
|
||||||
fileData,
|
|
||||||
},
|
|
||||||
fullOptions,
|
|
||||||
)
|
|
||||||
|
|
||||||
return write({
|
|
||||||
ctx,
|
|
||||||
content: stream,
|
|
||||||
slug: `${slug}-og-image` as FullSlug,
|
|
||||||
ext: ".webp",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CustomOgImagesEmitterName = "CustomOgImages"
|
export const CustomOgImagesEmitterName = "CustomOgImages"
|
||||||
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
|
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
|
||||||
const fullOptions = { ...defaultOptions, ...userOpts }
|
const fullOptions = { ...defaultOptions, ...userOpts }
|
||||||
@@ -116,23 +58,39 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
|
|||||||
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
||||||
|
|
||||||
for (const [_tree, vfile] of content) {
|
for (const [_tree, vfile] of content) {
|
||||||
if (vfile.data.frontmatter?.socialImage !== undefined) continue
|
// if this file defines socialImage, we can skip
|
||||||
yield processOgImage(ctx, vfile.data, fonts, fullOptions)
|
if (vfile.data.frontmatter?.socialImage !== undefined) {
|
||||||
}
|
continue
|
||||||
},
|
|
||||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
const headerFont = cfg.theme.typography.header
|
|
||||||
const bodyFont = cfg.theme.typography.body
|
|
||||||
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
|
||||||
|
|
||||||
// find all slugs that changed or were added
|
|
||||||
for (const changeEvent of changeEvents) {
|
|
||||||
if (!changeEvent.file) continue
|
|
||||||
if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue
|
|
||||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
|
||||||
yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slug = vfile.data.slug!
|
||||||
|
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||||
|
const title =
|
||||||
|
(vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||||
|
const description =
|
||||||
|
vfile.data.frontmatter?.socialDescription ??
|
||||||
|
vfile.data.frontmatter?.description ??
|
||||||
|
unescapeHTML(
|
||||||
|
vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
const stream = await generateSocialImage(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fonts,
|
||||||
|
cfg,
|
||||||
|
fileData: vfile.data,
|
||||||
|
},
|
||||||
|
fullOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield write({
|
||||||
|
ctx,
|
||||||
|
content: stream,
|
||||||
|
slug: `${slug}-og-image` as FullSlug,
|
||||||
|
ext: ".webp",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
externalResources: (ctx) => {
|
externalResources: (ctx) => {
|
||||||
@@ -145,19 +103,13 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
|
|||||||
additionalHead: [
|
additionalHead: [
|
||||||
(pageData) => {
|
(pageData) => {
|
||||||
const isRealFile = pageData.filePath !== undefined
|
const isRealFile = pageData.filePath !== undefined
|
||||||
let userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
const userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
||||||
|
|
||||||
if (userDefinedOgImagePath) {
|
|
||||||
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
|
|
||||||
? userDefinedOgImagePath
|
|
||||||
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedOgImagePath = isRealFile
|
const generatedOgImagePath = isRealFile
|
||||||
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
|
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
|
||||||
: undefined
|
: undefined
|
||||||
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
|
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
|
||||||
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
|
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
|
||||||
|
|
||||||
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
|
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,11 +2,26 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path"
|
|||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { glob } from "../../util/glob"
|
import { glob } from "../../util/glob"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
import { dirname } from "path"
|
import { dirname } from "path"
|
||||||
|
|
||||||
export const Static: QuartzEmitterPlugin = () => ({
|
export const Static: QuartzEmitterPlugin = () => ({
|
||||||
name: "Static",
|
name: "Static",
|
||||||
async *emit({ argv, cfg }) {
|
async getDependencyGraph({ argv, cfg }, _content, _resources) {
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
const staticPath = joinSegments(QUARTZ, "static")
|
||||||
|
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||||
|
for (const fp of fps) {
|
||||||
|
graph.addEdge(
|
||||||
|
joinSegments("static", fp) as FilePath,
|
||||||
|
joinSegments(argv.output, "static", fp) as FilePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
|
async *emit({ argv, cfg }, _content) {
|
||||||
const staticPath = joinSegments(QUARTZ, "static")
|
const staticPath = joinSegments(QUARTZ, "static")
|
||||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||||
const outputStaticPath = joinSegments(argv.output, "static")
|
const outputStaticPath = joinSegments(argv.output, "static")
|
||||||
@@ -19,5 +34,4 @@ export const Static: QuartzEmitterPlugin = () => ({
|
|||||||
yield dest
|
yield dest
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async *partialEmit() {},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,94 +5,23 @@ import BodyConstructor from "../../components/Body"
|
|||||||
import { pageResources, renderPage } from "../../components/renderPage"
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
|
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
|
||||||
import { FullPageLayout } from "../../cfg"
|
import { FullPageLayout } from "../../cfg"
|
||||||
import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path"
|
import {
|
||||||
|
FilePath,
|
||||||
|
FullSlug,
|
||||||
|
getAllSegmentPrefixes,
|
||||||
|
joinSegments,
|
||||||
|
pathToRoot,
|
||||||
|
} from "../../util/path"
|
||||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { TagContent } from "../../components"
|
import { TagContent } from "../../components"
|
||||||
import { write } from "./helpers"
|
import { write } from "./helpers"
|
||||||
import { i18n, TRANSLATIONS } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
import { BuildCtx } from "../../util/ctx"
|
import DepGraph from "../../depgraph"
|
||||||
import { StaticResources } from "../../util/resources"
|
|
||||||
|
|
||||||
interface TagPageOptions extends FullPageLayout {
|
interface TagPageOptions extends FullPageLayout {
|
||||||
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTagInfo(
|
|
||||||
allFiles: QuartzPluginData[],
|
|
||||||
content: ProcessedContent[],
|
|
||||||
locale: keyof typeof TRANSLATIONS,
|
|
||||||
): [Set<string>, Record<string, ProcessedContent>] {
|
|
||||||
const tags: Set<string> = new Set(
|
|
||||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
|
||||||
)
|
|
||||||
|
|
||||||
// add base tag
|
|
||||||
tags.add("index")
|
|
||||||
|
|
||||||
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
|
||||||
[...tags].map((tag) => {
|
|
||||||
const title =
|
|
||||||
tag === "index"
|
|
||||||
? i18n(locale).pages.tagContent.tagIndex
|
|
||||||
: `${i18n(locale).pages.tagContent.tag}: ${tag}`
|
|
||||||
return [
|
|
||||||
tag,
|
|
||||||
defaultProcessedContent({
|
|
||||||
slug: joinSegments("tags", tag) as FullSlug,
|
|
||||||
frontmatter: { title, tags: [] },
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update with actual content if available
|
|
||||||
for (const [tree, file] of content) {
|
|
||||||
const slug = file.data.slug!
|
|
||||||
if (slug.startsWith("tags/")) {
|
|
||||||
const tag = slug.slice("tags/".length)
|
|
||||||
if (tags.has(tag)) {
|
|
||||||
tagDescriptions[tag] = [tree, file]
|
|
||||||
if (file.data.frontmatter?.title === tag) {
|
|
||||||
file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [tags, tagDescriptions]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processTagPage(
|
|
||||||
ctx: BuildCtx,
|
|
||||||
tag: string,
|
|
||||||
tagContent: ProcessedContent,
|
|
||||||
allFiles: QuartzPluginData[],
|
|
||||||
opts: FullPageLayout,
|
|
||||||
resources: StaticResources,
|
|
||||||
) {
|
|
||||||
const slug = joinSegments("tags", tag) as FullSlug
|
|
||||||
const [tree, file] = tagContent
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
|
||||||
const componentData: QuartzComponentProps = {
|
|
||||||
ctx,
|
|
||||||
fileData: file.data,
|
|
||||||
externalResources,
|
|
||||||
cfg,
|
|
||||||
children: [],
|
|
||||||
tree,
|
|
||||||
allFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
|
||||||
return write({
|
|
||||||
ctx,
|
|
||||||
content,
|
|
||||||
slug: file.data.slug!,
|
|
||||||
ext: ".html",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
|
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
...sharedPageComponents,
|
...sharedPageComponents,
|
||||||
@@ -121,50 +50,89 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
|
|||||||
Footer,
|
Footer,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
async getDependencyGraph(ctx, content, _resources) {
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
for (const [_tree, file] of content) {
|
||||||
|
const sourcePath = file.data.filePath!
|
||||||
|
const tags = (file.data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
|
||||||
|
// if the file has at least one tag, it is used in the tag index page
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tags.push("index")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
graph.addEdge(
|
||||||
|
sourcePath,
|
||||||
|
joinSegments(ctx.argv.output, "tags", tag + ".html") as FilePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
async *emit(ctx, content, resources) {
|
async *emit(ctx, content, resources) {
|
||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
|
|
||||||
|
|
||||||
for (const tag of tags) {
|
const tags: Set<string> = new Set(
|
||||||
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
|
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||||
}
|
)
|
||||||
},
|
|
||||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
|
||||||
const allFiles = content.map((c) => c[1].data)
|
|
||||||
const cfg = ctx.cfg.configuration
|
|
||||||
|
|
||||||
// Find all tags that need to be updated based on changed files
|
// add base tag
|
||||||
const affectedTags: Set<string> = new Set()
|
tags.add("index")
|
||||||
for (const changeEvent of changeEvents) {
|
|
||||||
if (!changeEvent.file) continue
|
|
||||||
const slug = changeEvent.file.data.slug!
|
|
||||||
|
|
||||||
// If it's a tag page itself that changed
|
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||||
|
[...tags].map((tag) => {
|
||||||
|
const title =
|
||||||
|
tag === "index"
|
||||||
|
? i18n(cfg.locale).pages.tagContent.tagIndex
|
||||||
|
: `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
|
||||||
|
return [
|
||||||
|
tag,
|
||||||
|
defaultProcessedContent({
|
||||||
|
slug: joinSegments("tags", tag) as FullSlug,
|
||||||
|
frontmatter: { title, tags: [] },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = file.data.slug!
|
||||||
if (slug.startsWith("tags/")) {
|
if (slug.startsWith("tags/")) {
|
||||||
const tag = slug.slice("tags/".length)
|
const tag = slug.slice("tags/".length)
|
||||||
affectedTags.add(tag)
|
if (tags.has(tag)) {
|
||||||
}
|
tagDescriptions[tag] = [tree, file]
|
||||||
|
if (file.data.frontmatter?.title === tag) {
|
||||||
// If a file with tags changed, we need to update those tag pages
|
file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
|
||||||
const fileTags = changeEvent.file.data.frontmatter?.tags ?? []
|
}
|
||||||
fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag))
|
|
||||||
|
|
||||||
// Always update the index tag page if any file changes
|
|
||||||
affectedTags.add("index")
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are affected tags, rebuild their pages
|
|
||||||
if (affectedTags.size > 0) {
|
|
||||||
// We still need to compute all tags because tag pages show all tags
|
|
||||||
const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
|
|
||||||
|
|
||||||
for (const tag of affectedTags) {
|
|
||||||
if (tagDescriptions[tag]) {
|
|
||||||
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const slug = joinSegments("tags", tag) as FullSlug
|
||||||
|
const [tree, file] = tagDescriptions[tag]
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
ctx,
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||||
|
yield write({
|
||||||
|
ctx,
|
||||||
|
content,
|
||||||
|
slug: file.data.slug!,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { escapeHTML } from "../../util/escape"
|
|||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
descriptionLength: number
|
descriptionLength: number
|
||||||
maxDescriptionLength: number
|
|
||||||
replaceExternalLinks: boolean
|
replaceExternalLinks: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
const defaultOptions: Options = {
|
||||||
descriptionLength: 150,
|
descriptionLength: 150,
|
||||||
maxDescriptionLength: 300,
|
|
||||||
replaceExternalLinks: true,
|
replaceExternalLinks: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,41 +37,35 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
text = text.replace(urlRegex, "$<domain>" + "$<path>")
|
text = text.replace(urlRegex, "$<domain>" + "$<path>")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontMatterDescription) {
|
const desc = frontMatterDescription ?? text
|
||||||
file.data.description = frontMatterDescription
|
|
||||||
file.data.text = text
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, use the text content
|
|
||||||
const desc = text
|
|
||||||
const sentences = desc.replace(/\s+/g, " ").split(/\.\s/)
|
const sentences = desc.replace(/\s+/g, " ").split(/\.\s/)
|
||||||
let finalDesc = ""
|
const finalDesc: string[] = []
|
||||||
|
const len = opts.descriptionLength
|
||||||
let sentenceIdx = 0
|
let sentenceIdx = 0
|
||||||
|
let currentDescriptionLength = 0
|
||||||
|
|
||||||
// Add full sentences until we exceed the guideline length
|
if (sentences[0] !== undefined && sentences[0].length >= len) {
|
||||||
while (sentenceIdx < sentences.length) {
|
const firstSentence = sentences[0].split(" ")
|
||||||
const sentence = sentences[sentenceIdx]
|
while (currentDescriptionLength < len) {
|
||||||
if (!sentence) break
|
const sentence = firstSentence[sentenceIdx]
|
||||||
|
if (!sentence) break
|
||||||
const currentSentence = sentence.endsWith(".") ? sentence : sentence + "."
|
finalDesc.push(sentence)
|
||||||
const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0)
|
currentDescriptionLength += sentence.length
|
||||||
|
sentenceIdx++
|
||||||
// Add the sentence if we're under the guideline length
|
}
|
||||||
// or if this is the first sentence (always include at least one)
|
finalDesc.push("...")
|
||||||
if (nextLength <= opts.descriptionLength || sentenceIdx === 0) {
|
} else {
|
||||||
finalDesc += (finalDesc ? " " : "") + currentSentence
|
while (currentDescriptionLength < len) {
|
||||||
|
const sentence = sentences[sentenceIdx]
|
||||||
|
if (!sentence) break
|
||||||
|
const currentSentence = sentence.endsWith(".") ? sentence : sentence + "."
|
||||||
|
finalDesc.push(currentSentence)
|
||||||
|
currentDescriptionLength += currentSentence.length
|
||||||
sentenceIdx++
|
sentenceIdx++
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncate to max length if necessary
|
file.data.description = finalDesc.join(" ")
|
||||||
file.data.description =
|
|
||||||
finalDesc.length > opts.maxDescriptionLength
|
|
||||||
? finalDesc.slice(0, opts.maxDescriptionLength) + "..."
|
|
||||||
: finalDesc
|
|
||||||
file.data.text = text
|
file.data.text = text
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import remarkFrontmatter from "remark-frontmatter"
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
import toml from "toml"
|
import toml from "toml"
|
||||||
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
|
import { FilePath, FullSlug, joinSegments, slugifyFilePath, slugTag } from "../../util/path"
|
||||||
import { QuartzPluginData } from "../vfile"
|
import { QuartzPluginData } from "../vfile"
|
||||||
import { i18n } from "../../i18n"
|
import { i18n } from "../../i18n"
|
||||||
|
import { Argv } from "../../util/ctx"
|
||||||
|
import { VFile } from "vfile"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
delimiters: string | [string, string]
|
delimiters: string | [string, string]
|
||||||
@@ -40,24 +43,26 @@ function coerceToArray(input: string | string[]): string[] | undefined {
|
|||||||
.map((tag: string | number) => tag.toString())
|
.map((tag: string | number) => tag.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAliasSlugs(aliases: string[]): FullSlug[] {
|
export function getAliasSlugs(aliases: string[], argv: Argv, file: VFile): FullSlug[] {
|
||||||
const res: FullSlug[] = []
|
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||||
for (const alias of aliases) {
|
const slugs: FullSlug[] = aliases.map(
|
||||||
const isMd = getFileExtension(alias) === "md"
|
(alias) => path.posix.join(dir, slugifyFilePath(alias as FilePath)) as FullSlug,
|
||||||
const mockFp = isMd ? alias : alias + ".md"
|
)
|
||||||
const slug = slugifyFilePath(mockFp as FilePath)
|
const permalink = file.data.frontmatter?.permalink
|
||||||
res.push(slug)
|
if (typeof permalink === "string") {
|
||||||
|
slugs.push(permalink as FullSlug)
|
||||||
}
|
}
|
||||||
|
// fix any slugs that have trailing slash
|
||||||
return res
|
return slugs.map((slug) =>
|
||||||
|
slug.endsWith("/") ? (joinSegments(slug, "index") as FullSlug) : slug,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
return {
|
return {
|
||||||
name: "FrontMatter",
|
name: "FrontMatter",
|
||||||
markdownPlugins(ctx) {
|
markdownPlugins({ cfg, allSlugs, argv }) {
|
||||||
const { cfg, allSlugs } = ctx
|
|
||||||
return [
|
return [
|
||||||
[remarkFrontmatter, ["yaml", "toml"]],
|
[remarkFrontmatter, ["yaml", "toml"]],
|
||||||
() => {
|
() => {
|
||||||
@@ -83,18 +88,9 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
||||||
if (aliases) {
|
if (aliases) {
|
||||||
data.aliases = aliases // frontmatter
|
data.aliases = aliases // frontmatter
|
||||||
file.data.aliases = getAliasSlugs(aliases)
|
const slugs = (file.data.aliases = getAliasSlugs(aliases, argv, file))
|
||||||
allSlugs.push(...file.data.aliases)
|
allSlugs.push(...slugs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.permalink != null && data.permalink.toString() !== "") {
|
|
||||||
data.permalink = data.permalink.toString() as FullSlug
|
|
||||||
const aliases = file.data.aliases ?? []
|
|
||||||
aliases.push(data.permalink)
|
|
||||||
file.data.aliases = aliases
|
|
||||||
allSlugs.push(data.permalink)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
||||||
if (cssclasses) data.cssclasses = cssclasses
|
if (cssclasses) data.cssclasses = cssclasses
|
||||||
|
|
||||||
@@ -114,10 +110,6 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
|||||||
|
|
||||||
if (socialImage) data.socialImage = socialImage
|
if (socialImage) data.socialImage = socialImage
|
||||||
|
|
||||||
// Remove duplicate slugs
|
|
||||||
const uniqueSlugs = [...new Set(allSlugs)]
|
|
||||||
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
|
||||||
|
|
||||||
// fill in frontmatter
|
// fill in frontmatter
|
||||||
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
import { Repository } from "@napi-rs/simple-git"
|
import { Repository } from "@napi-rs/simple-git"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
priority: ("frontmatter" | "git" | "filesystem")[]
|
priority: ("frontmatter" | "git" | "filesystem")[]
|
||||||
@@ -31,29 +31,17 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
return {
|
return {
|
||||||
name: "CreatedModifiedDate",
|
name: "CreatedModifiedDate",
|
||||||
markdownPlugins(ctx) {
|
markdownPlugins() {
|
||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
let repo: Repository | undefined = undefined
|
let repo: Repository | undefined = undefined
|
||||||
let repositoryWorkdir: string
|
|
||||||
if (opts.priority.includes("git")) {
|
|
||||||
try {
|
|
||||||
repo = Repository.discover(ctx.argv.directory)
|
|
||||||
repositoryWorkdir = repo.workdir() ?? ctx.argv.directory
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
chalk.yellow(`\nWarning: couldn't find git repository for ${ctx.argv.directory}`),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (_tree, file) => {
|
return async (_tree, file) => {
|
||||||
let created: MaybeDate = undefined
|
let created: MaybeDate = undefined
|
||||||
let modified: MaybeDate = undefined
|
let modified: MaybeDate = undefined
|
||||||
let published: MaybeDate = undefined
|
let published: MaybeDate = undefined
|
||||||
|
|
||||||
const fp = file.data.relativePath!
|
const fp = file.data.filePath!
|
||||||
const fullFp = file.data.filePath!
|
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
|
||||||
for (const source of opts.priority) {
|
for (const source of opts.priority) {
|
||||||
if (source === "filesystem") {
|
if (source === "filesystem") {
|
||||||
const st = await fs.promises.stat(fullFp)
|
const st = await fs.promises.stat(fullFp)
|
||||||
@@ -63,14 +51,21 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
created ||= file.data.frontmatter.created as MaybeDate
|
created ||= file.data.frontmatter.created as MaybeDate
|
||||||
modified ||= file.data.frontmatter.modified as MaybeDate
|
modified ||= file.data.frontmatter.modified as MaybeDate
|
||||||
published ||= file.data.frontmatter.published as MaybeDate
|
published ||= file.data.frontmatter.published as MaybeDate
|
||||||
} else if (source === "git" && repo) {
|
} else if (source === "git") {
|
||||||
|
if (!repo) {
|
||||||
|
// Get a reference to the main git repo.
|
||||||
|
// It's either the same as the workdir,
|
||||||
|
// or 1+ level higher in case of a submodule/subtree setup
|
||||||
|
repo = Repository.discover(file.cwd)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relativePath = path.relative(repositoryWorkdir, fullFp)
|
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||||
modified ||= await repo.getFileLatestModifiedDateAsync(relativePath)
|
|
||||||
} catch {
|
} catch {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
`\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`,
|
`\nWarning: ${file.data
|
||||||
|
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
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 ?? ""}`)
|
||||||
const displayAnchor = anchor ? `#${anchor.trim().replace(/^#+/, "")}` : ""
|
const blockRef = Boolean(rawHeader?.match(/^#?\^/)) ? "^" : ""
|
||||||
|
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
|
||||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
textTransform(_ctx, src) {
|
textTransform(_ctx, src) {
|
||||||
if (opts.wikilinks) {
|
if (opts.wikilinks) {
|
||||||
src = src.toString()
|
src = src.toString()
|
||||||
src = src.replaceAll(relrefRegex, (_value, ...capture) => {
|
src = src.replaceAll(relrefRegex, (value, ...capture) => {
|
||||||
const [text, link] = capture
|
const [text, link] = capture
|
||||||
return `[${text}](${link})`
|
return `[${text}](${link})`
|
||||||
})
|
})
|
||||||
@@ -62,7 +62,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
if (opts.removePredefinedAnchor) {
|
if (opts.removePredefinedAnchor) {
|
||||||
src = src.toString()
|
src = src.toString()
|
||||||
src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => {
|
src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
|
||||||
const [headingText] = capture
|
const [headingText] = capture
|
||||||
return headingText
|
return headingText
|
||||||
})
|
})
|
||||||
@@ -70,7 +70,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
if (opts.removeHugoShortcode) {
|
if (opts.removeHugoShortcode) {
|
||||||
src = src.toString()
|
src = src.toString()
|
||||||
src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
|
src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
|
||||||
const [scContent] = capture
|
const [scContent] = capture
|
||||||
return scContent
|
return scContent
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
if (opts.replaceFigureWithMdImg) {
|
if (opts.replaceFigureWithMdImg) {
|
||||||
src = src.toString()
|
src = src.toString()
|
||||||
src = src.replaceAll(figureTagRegex, (_value, ...capture) => {
|
src = src.replaceAll(figureTagRegex, (value, ...capture) => {
|
||||||
const [src] = capture
|
const [src] = capture
|
||||||
return ``
|
return ``
|
||||||
})
|
})
|
||||||
@@ -86,11 +86,11 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
|||||||
|
|
||||||
if (opts.replaceOrgLatex) {
|
if (opts.replaceOrgLatex) {
|
||||||
src = src.toString()
|
src = src.toString()
|
||||||
src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => {
|
src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
|
||||||
const [eqn] = capture
|
const [eqn] = capture
|
||||||
return `$${eqn}$`
|
return `$${eqn}$`
|
||||||
})
|
})
|
||||||
src = src.replaceAll(blockLatexRegex, (_value, ...capture) => {
|
src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
|
||||||
const [eqn] = capture
|
const [eqn] = capture
|
||||||
return `$$${eqn}$$`
|
return `$$${eqn}$$`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import { PluggableList } from "unified"
|
import { PluggableList } from "unified"
|
||||||
import { visit } from "unist-util-visit"
|
import { SKIP, visit } from "unist-util-visit"
|
||||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||||
import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
|
import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
|
||||||
|
import { Node } from "unist"
|
||||||
|
import { VFile } from "vfile"
|
||||||
import { BuildVisitor } from "unist-util-visit"
|
import { BuildVisitor } from "unist-util-visit"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
@@ -32,10 +34,21 @@ const defaultOptions: Options = {
|
|||||||
const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
|
const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
|
||||||
const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
|
const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
|
||||||
const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
|
const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
|
||||||
|
const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g")
|
||||||
|
const youtubeRegex = new RegExp(
|
||||||
|
/{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/,
|
||||||
|
"g",
|
||||||
|
)
|
||||||
|
|
||||||
|
// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g")
|
||||||
|
|
||||||
|
const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g")
|
||||||
|
const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g")
|
||||||
const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
|
const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
|
||||||
const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
|
const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
|
||||||
const roamItalicRegex = new RegExp(/__(.+)__/, "g")
|
const roamItalicRegex = new RegExp(/__(.+)__/, "g")
|
||||||
|
const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */
|
||||||
|
const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */
|
||||||
|
|
||||||
function isSpecialEmbed(node: Paragraph): boolean {
|
function isSpecialEmbed(node: Paragraph): boolean {
|
||||||
if (node.children.length !== 2) return false
|
if (node.children.length !== 2) return false
|
||||||
@@ -122,7 +135,7 @@ export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | un
|
|||||||
const plugins: PluggableList = []
|
const plugins: PluggableList = []
|
||||||
|
|
||||||
plugins.push(() => {
|
plugins.push(() => {
|
||||||
return (tree: Root) => {
|
return (tree: Root, file: VFile) => {
|
||||||
const replacements: [RegExp, ReplaceFunction][] = []
|
const replacements: [RegExp, ReplaceFunction][] = []
|
||||||
|
|
||||||
// Handle special embeds (audio, video, PDF)
|
// Handle special embeds (audio, video, PDF)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ProcessedContent } from "./vfile"
|
|||||||
import { QuartzComponent } from "../components/types"
|
import { QuartzComponent } from "../components/types"
|
||||||
import { FilePath } from "../util/path"
|
import { FilePath } from "../util/path"
|
||||||
import { BuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
import { VFile } from "vfile"
|
import DepGraph from "../depgraph"
|
||||||
|
|
||||||
export interface PluginTypes {
|
export interface PluginTypes {
|
||||||
transformers: QuartzTransformerPluginInstance[]
|
transformers: QuartzTransformerPluginInstance[]
|
||||||
@@ -33,33 +33,26 @@ export type QuartzFilterPluginInstance = {
|
|||||||
shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
|
shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChangeEvent = {
|
|
||||||
type: "add" | "change" | "delete"
|
|
||||||
path: FilePath
|
|
||||||
file?: VFile
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||||
opts?: Options,
|
opts?: Options,
|
||||||
) => QuartzEmitterPluginInstance
|
) => QuartzEmitterPluginInstance
|
||||||
export type QuartzEmitterPluginInstance = {
|
export type QuartzEmitterPluginInstance = {
|
||||||
name: string
|
name: string
|
||||||
emit: (
|
emit(
|
||||||
ctx: BuildCtx,
|
ctx: BuildCtx,
|
||||||
content: ProcessedContent[],
|
content: ProcessedContent[],
|
||||||
resources: StaticResources,
|
resources: StaticResources,
|
||||||
) => Promise<FilePath[]> | AsyncGenerator<FilePath>
|
): Promise<FilePath[]> | AsyncGenerator<FilePath>
|
||||||
partialEmit?: (
|
|
||||||
ctx: BuildCtx,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
resources: StaticResources,
|
|
||||||
changeEvents: ChangeEvent[],
|
|
||||||
) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null
|
|
||||||
/**
|
/**
|
||||||
* Returns the components (if any) that are used in rendering the page.
|
* Returns the components (if any) that are used in rendering the page.
|
||||||
* This helps Quartz optimize the page by only including necessary resources
|
* This helps Quartz optimize the page by only including necessary resources
|
||||||
* for components that are actually used.
|
* for components that are actually used.
|
||||||
*/
|
*/
|
||||||
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
|
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
|
||||||
|
getDependencyGraph?(
|
||||||
|
ctx: BuildCtx,
|
||||||
|
content: ProcessedContent[],
|
||||||
|
resources: StaticResources,
|
||||||
|
): Promise<DepGraph<FilePath>>
|
||||||
externalResources?: ExternalResourcesFn
|
externalResources?: ExternalResourcesFn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
|||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
const log = new QuartzLogger(ctx.argv.verbose)
|
const log = new QuartzLogger(ctx.argv.verbose)
|
||||||
|
|
||||||
log.start(`Emitting files`)
|
log.start(`Emitting output files`)
|
||||||
|
|
||||||
let emittedFiles = 0
|
let emittedFiles = 0
|
||||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||||
@@ -26,7 +26,7 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
|||||||
if (ctx.argv.verbose) {
|
if (ctx.argv.verbose) {
|
||||||
console.log(`[emit:${emitter.name}] ${file}`)
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
} else {
|
} else {
|
||||||
log.updateText(`${emitter.name} -> ${chalk.gray(file)}`)
|
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -36,7 +36,7 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
|||||||
if (ctx.argv.verbose) {
|
if (ctx.argv.verbose) {
|
||||||
console.log(`[emit:${emitter.name}] ${file}`)
|
console.log(`[emit:${emitter.name}] ${file}`)
|
||||||
} else {
|
} else {
|
||||||
log.updateText(`${emitter.name} -> ${chalk.gray(file)}`)
|
log.updateText(`Emitting output files: ${chalk.gray(file)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { Root as HTMLRoot } from "hast"
|
|||||||
import { MarkdownContent, 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, WorkerSerializableBuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
import chalk from "chalk"
|
|
||||||
|
|
||||||
export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
|
export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
|
||||||
export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
|
export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
|
||||||
@@ -172,46 +171,25 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
|||||||
workerType: "thread",
|
workerType: "thread",
|
||||||
})
|
})
|
||||||
const errorHandler = (err: any) => {
|
const errorHandler = (err: any) => {
|
||||||
console.error(err)
|
console.error(`${err}`.replace(/^error:\s*/i, ""))
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializableCtx: WorkerSerializableBuildCtx = {
|
const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
|
||||||
buildId: ctx.buildId,
|
|
||||||
argv: ctx.argv,
|
|
||||||
allSlugs: ctx.allSlugs,
|
|
||||||
allFiles: ctx.allFiles,
|
|
||||||
incremental: ctx.incremental,
|
|
||||||
}
|
|
||||||
|
|
||||||
const textToMarkdownPromises: WorkerPromise<MarkdownContent[]>[] = []
|
|
||||||
let processedFiles = 0
|
|
||||||
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
||||||
textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk]))
|
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
|
||||||
}
|
}
|
||||||
|
const mdResults: [MarkdownContent[], FullSlug[]][] =
|
||||||
|
await WorkerPromise.all(mdPromises).catch(errorHandler)
|
||||||
|
|
||||||
const mdResults: Array<MarkdownContent[]> = await Promise.all(
|
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
|
||||||
textToMarkdownPromises.map(async (promise) => {
|
for (const [_, extraSlugs] of mdResults) {
|
||||||
const result = await promise
|
ctx.allSlugs.push(...extraSlugs)
|
||||||
processedFiles += result.length
|
|
||||||
log.updateText(`text->markdown ${chalk.gray(`${processedFiles}/${fps.length}`)}`)
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
).catch(errorHandler)
|
|
||||||
|
|
||||||
const markdownToHtmlPromises: WorkerPromise<ProcessedContent[]>[] = []
|
|
||||||
processedFiles = 0
|
|
||||||
for (const mdChunk of mdResults) {
|
|
||||||
markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk]))
|
|
||||||
}
|
}
|
||||||
const results: ProcessedContent[][] = await Promise.all(
|
for (const [mdChunk, _] of mdResults) {
|
||||||
markdownToHtmlPromises.map(async (promise) => {
|
childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
|
||||||
const result = await promise
|
}
|
||||||
processedFiles += result.length
|
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)
|
||||||
log.updateText(`markdown->html ${chalk.gray(`${processedFiles}/${fps.length}`)}`)
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
).catch(errorHandler)
|
|
||||||
|
|
||||||
res = results.flat()
|
res = results.flat()
|
||||||
await pool.terminate()
|
await pool.terminate()
|
||||||
|
|||||||
@@ -65,21 +65,6 @@ ul,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
article {
|
|
||||||
> mjx-container.MathJax,
|
|
||||||
blockquote > div > mjx-container.MathJax {
|
|
||||||
display: flex;
|
|
||||||
> svg {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blockquote > div > mjx-container.MathJax > svg {
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: $semiBoldWeight;
|
font-weight: $semiBoldWeight;
|
||||||
}
|
}
|
||||||
@@ -238,7 +223,6 @@ a {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
& > * {
|
& > * {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-height: 24rem;
|
|
||||||
}
|
}
|
||||||
& > .toc {
|
& > .toc {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -562,8 +546,8 @@ video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div:has(> .overflow) {
|
div:has(> .overflow) {
|
||||||
|
display: flex;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.overflow,
|
ul.overflow,
|
||||||
@@ -578,7 +562,7 @@ ol.overflow {
|
|||||||
clear: both;
|
clear: both;
|
||||||
|
|
||||||
& > li.overflow-end {
|
& > li.overflow-end {
|
||||||
height: 0.5rem;
|
height: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,21 @@
|
|||||||
import { QuartzConfig } from "../cfg"
|
import { QuartzConfig } from "../cfg"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { FullSlug } from "./path"
|
||||||
import { FileTrieNode } from "./fileTrie"
|
|
||||||
import { FilePath, FullSlug } from "./path"
|
|
||||||
|
|
||||||
export interface Argv {
|
export interface Argv {
|
||||||
directory: string
|
directory: string
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
output: string
|
output: string
|
||||||
serve: boolean
|
serve: boolean
|
||||||
watch: boolean
|
fastRebuild: boolean
|
||||||
port: number
|
port: number
|
||||||
wsPort: number
|
wsPort: number
|
||||||
remoteDevHost?: string
|
remoteDevHost?: string
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BuildTimeTrieData = QuartzPluginData & {
|
|
||||||
slug: string
|
|
||||||
title: string
|
|
||||||
filePath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BuildCtx {
|
export interface BuildCtx {
|
||||||
buildId: string
|
buildId: string
|
||||||
argv: Argv
|
argv: Argv
|
||||||
cfg: QuartzConfig
|
cfg: QuartzConfig
|
||||||
allSlugs: FullSlug[]
|
allSlugs: FullSlug[]
|
||||||
allFiles: FilePath[]
|
|
||||||
trie?: FileTrieNode<BuildTimeTrieData>
|
|
||||||
incremental: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
|
|
||||||
const trie = new FileTrieNode<BuildTimeTrieData>([])
|
|
||||||
allFiles.forEach((file) => {
|
|
||||||
if (file.frontmatter) {
|
|
||||||
trie.add({
|
|
||||||
...file,
|
|
||||||
slug: file.slug!,
|
|
||||||
title: file.frontmatter.title,
|
|
||||||
filePath: file.filePath!,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return trie
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">
|
|
||||||
|
|||||||
@@ -25,23 +25,14 @@ function toCodePoint(unicodeSurrogates: string) {
|
|||||||
return r.join("-")
|
return r.join("-")
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmojiMap = {
|
const twemoji = (code: string) =>
|
||||||
codePointToName: Record<string, string>
|
`https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/${code.toLowerCase()}.svg`
|
||||||
nameToBase64: Record<string, string>
|
const emojiCache: Record<string, Promise<any>> = {}
|
||||||
}
|
|
||||||
|
export function loadEmoji(code: string) {
|
||||||
let emojimap: EmojiMap | undefined = undefined
|
const type = "twemoji"
|
||||||
export async function loadEmoji(code: string) {
|
const key = type + ":" + code
|
||||||
if (!emojimap) {
|
if (key in emojiCache) return emojiCache[key]
|
||||||
const data = await import("./emojimap.json")
|
|
||||||
emojimap = data
|
return (emojiCache[key] = fetch(twemoji(code)).then((r) => r.text()))
|
||||||
}
|
|
||||||
|
|
||||||
const name = emojimap.codePointToName[`U+${code.toUpperCase()}`]
|
|
||||||
if (!name) throw new Error(`codepoint ${code} not found in map`)
|
|
||||||
|
|
||||||
const b64 = emojimap.nameToBase64[name]
|
|
||||||
if (!b64) throw new Error(`name ${name} not found in map`)
|
|
||||||
|
|
||||||
return b64
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
|||||||
import test, { describe, beforeEach } from "node:test"
|
import test, { describe, beforeEach } from "node:test"
|
||||||
import assert from "node:assert"
|
import assert from "node:assert"
|
||||||
import { FileTrieNode } from "./fileTrie"
|
import { FileTrieNode } from "./fileTrie"
|
||||||
import { FullSlug } from "./path"
|
|
||||||
|
|
||||||
interface TestData {
|
interface TestData {
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
filePath: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("FileTrie", () => {
|
describe("FileTrie", () => {
|
||||||
@@ -28,24 +26,11 @@ describe("FileTrie", () => {
|
|||||||
const data = {
|
const data = {
|
||||||
title: "Test Title",
|
title: "Test Title",
|
||||||
slug: "test",
|
slug: "test",
|
||||||
filePath: "test.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trie.add(data)
|
trie.add(data)
|
||||||
assert.strictEqual(trie.children[0].displayName, "Test Title")
|
assert.strictEqual(trie.children[0].displayName, "Test Title")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should be able to set displayName", () => {
|
|
||||||
const data = {
|
|
||||||
title: "Test Title",
|
|
||||||
slug: "test",
|
|
||||||
filePath: "test.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
trie.add(data)
|
|
||||||
trie.children[0].displayName = "Modified"
|
|
||||||
assert.strictEqual(trie.children[0].displayName, "Modified")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("add", () => {
|
describe("add", () => {
|
||||||
@@ -53,7 +38,6 @@ describe("FileTrie", () => {
|
|||||||
const data = {
|
const data = {
|
||||||
title: "Test",
|
title: "Test",
|
||||||
slug: "test",
|
slug: "test",
|
||||||
filePath: "test.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trie.add(data)
|
trie.add(data)
|
||||||
@@ -66,7 +50,6 @@ describe("FileTrie", () => {
|
|||||||
const data = {
|
const data = {
|
||||||
title: "Index",
|
title: "Index",
|
||||||
slug: "index",
|
slug: "index",
|
||||||
filePath: "index.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trie.add(data)
|
trie.add(data)
|
||||||
@@ -78,13 +61,11 @@ describe("FileTrie", () => {
|
|||||||
const data1 = {
|
const data1 = {
|
||||||
title: "Nested",
|
title: "Nested",
|
||||||
slug: "folder/test",
|
slug: "folder/test",
|
||||||
filePath: "folder/test.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data2 = {
|
const data2 = {
|
||||||
title: "Really nested index",
|
title: "Really nested index",
|
||||||
slug: "a/b/c/index",
|
slug: "a/b/c/index",
|
||||||
filePath: "a/b/c/index.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
@@ -111,8 +92,8 @@ describe("FileTrie", () => {
|
|||||||
|
|
||||||
describe("filter", () => {
|
describe("filter", () => {
|
||||||
test("should filter nodes based on condition", () => {
|
test("should filter nodes based on condition", () => {
|
||||||
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
|
const data2 = { title: "Test2", slug: "test2" }
|
||||||
|
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
trie.add(data2)
|
trie.add(data2)
|
||||||
@@ -125,8 +106,8 @@ describe("FileTrie", () => {
|
|||||||
|
|
||||||
describe("map", () => {
|
describe("map", () => {
|
||||||
test("should apply function to all nodes", () => {
|
test("should apply function to all nodes", () => {
|
||||||
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
|
const data2 = { title: "Test2", slug: "test2" }
|
||||||
|
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
trie.add(data2)
|
trie.add(data2)
|
||||||
@@ -140,41 +121,12 @@ describe("FileTrie", () => {
|
|||||||
assert.strictEqual(trie.children[0].displayName, "Modified")
|
assert.strictEqual(trie.children[0].displayName, "Modified")
|
||||||
assert.strictEqual(trie.children[1].displayName, "Modified")
|
assert.strictEqual(trie.children[1].displayName, "Modified")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("map over folders should work", () => {
|
|
||||||
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
|
|
||||||
const data2 = {
|
|
||||||
title: "Test2",
|
|
||||||
slug: "a/b-with-space/test2",
|
|
||||||
filePath: "a/b with space/test2.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
trie.add(data1)
|
|
||||||
trie.add(data2)
|
|
||||||
|
|
||||||
trie.map((node) => {
|
|
||||||
if (node.isFolder) {
|
|
||||||
node.displayName = `Folder: ${node.displayName}`
|
|
||||||
} else {
|
|
||||||
node.displayName = `File: ${node.displayName}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.strictEqual(trie.children[0].displayName, "File: Test1")
|
|
||||||
assert.strictEqual(trie.children[1].displayName, "Folder: a")
|
|
||||||
assert.strictEqual(trie.children[1].children[0].displayName, "Folder: b with space")
|
|
||||||
assert.strictEqual(trie.children[1].children[0].children[0].displayName, "File: Test2")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("entries", () => {
|
describe("entries", () => {
|
||||||
test("should return all entries", () => {
|
test("should return all entries", () => {
|
||||||
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
const data2 = {
|
const data2 = { title: "Test2", slug: "a/b/test2" }
|
||||||
title: "Test2",
|
|
||||||
slug: "a/b-with-space/test2",
|
|
||||||
filePath: "a/b with space/test2.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
trie.add(data2)
|
trie.add(data2)
|
||||||
@@ -186,117 +138,26 @@ describe("FileTrie", () => {
|
|||||||
["index", trie.data],
|
["index", trie.data],
|
||||||
["test1", data1],
|
["test1", data1],
|
||||||
["a/index", null],
|
["a/index", null],
|
||||||
["a/b-with-space/index", null],
|
["a/b/index", null],
|
||||||
["a/b-with-space/test2", data2],
|
["a/b/test2", data2],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("fromEntries", () => {
|
|
||||||
test("nested", () => {
|
|
||||||
const trie = FileTrieNode.fromEntries([
|
|
||||||
["index" as FullSlug, { title: "Root", slug: "index", filePath: "index.md" }],
|
|
||||||
[
|
|
||||||
"folder/file1" as FullSlug,
|
|
||||||
{ title: "File 1", slug: "folder/file1", filePath: "folder/file1.md" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"folder/index" as FullSlug,
|
|
||||||
{ title: "Folder Index", slug: "folder/index", filePath: "folder/index.md" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"folder/file2" as FullSlug,
|
|
||||||
{ title: "File 2", slug: "folder/file2", filePath: "folder/file2.md" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"folder/folder2/index" as FullSlug,
|
|
||||||
{
|
|
||||||
title: "Subfolder Index",
|
|
||||||
slug: "folder/folder2/index",
|
|
||||||
filePath: "folder/folder2/index.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
assert.strictEqual(trie.children.length, 1)
|
|
||||||
assert.strictEqual(trie.children[0].slug, "folder/index")
|
|
||||||
assert.strictEqual(trie.children[0].children.length, 3)
|
|
||||||
assert.strictEqual(trie.children[0].children[0].slug, "folder/file1")
|
|
||||||
assert.strictEqual(trie.children[0].children[1].slug, "folder/file2")
|
|
||||||
assert.strictEqual(trie.children[0].children[2].slug, "folder/folder2/index")
|
|
||||||
assert.strictEqual(trie.children[0].children[2].children.length, 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("findNode", () => {
|
|
||||||
test("should find root node with empty path", () => {
|
|
||||||
const data = { title: "Root", slug: "index", filePath: "index.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const found = trie.findNode([])
|
|
||||||
assert.strictEqual(found, trie)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should find node at first level", () => {
|
|
||||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const found = trie.findNode(["test"])
|
|
||||||
assert.strictEqual(found?.data, data)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should find nested node", () => {
|
|
||||||
const data = {
|
|
||||||
title: "Nested",
|
|
||||||
slug: "folder/subfolder/test",
|
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
|
||||||
trie.add(data)
|
|
||||||
const found = trie.findNode(["folder", "subfolder", "test"])
|
|
||||||
assert.strictEqual(found?.data, data)
|
|
||||||
|
|
||||||
// should find the folder and subfolder indexes too
|
|
||||||
assert.strictEqual(
|
|
||||||
trie.findNode(["folder", "subfolder", "index"]),
|
|
||||||
trie.children[0].children[0],
|
|
||||||
)
|
|
||||||
assert.strictEqual(trie.findNode(["folder", "index"]), trie.children[0])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return undefined for non-existent path", () => {
|
|
||||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const found = trie.findNode(["nonexistent"])
|
|
||||||
assert.strictEqual(found, undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return undefined for partial path", () => {
|
|
||||||
const data = {
|
|
||||||
title: "Nested",
|
|
||||||
slug: "folder/subfolder/test",
|
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
|
||||||
trie.add(data)
|
|
||||||
const found = trie.findNode(["folder"])
|
|
||||||
assert.strictEqual(found?.data, null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getFolderPaths", () => {
|
describe("getFolderPaths", () => {
|
||||||
test("should return all folder paths", () => {
|
test("should return all folder paths", () => {
|
||||||
const data1 = {
|
const data1 = {
|
||||||
title: "Root",
|
title: "Root",
|
||||||
slug: "index",
|
slug: "index",
|
||||||
filePath: "index.md",
|
|
||||||
}
|
}
|
||||||
const data2 = {
|
const data2 = {
|
||||||
title: "Test",
|
title: "Test",
|
||||||
slug: "folder/subfolder/test",
|
slug: "folder/subfolder/test",
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
}
|
||||||
const data3 = {
|
const data3 = {
|
||||||
title: "Folder Index",
|
title: "Folder Index",
|
||||||
slug: "abc/index",
|
slug: "abc/index",
|
||||||
filePath: "abc/index.md",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
@@ -315,9 +176,9 @@ describe("FileTrie", () => {
|
|||||||
|
|
||||||
describe("sort", () => {
|
describe("sort", () => {
|
||||||
test("should sort nodes according to sort function", () => {
|
test("should sort nodes according to sort function", () => {
|
||||||
const data1 = { title: "A", slug: "a", filePath: "a.md" }
|
const data1 = { title: "A", slug: "a" }
|
||||||
const data2 = { title: "B", slug: "b", filePath: "b.md" }
|
const data2 = { title: "B", slug: "b" }
|
||||||
const data3 = { title: "C", slug: "c", filePath: "c.md" }
|
const data3 = { title: "C", slug: "c" }
|
||||||
|
|
||||||
trie.add(data3)
|
trie.add(data3)
|
||||||
trie.add(data1)
|
trie.add(data1)
|
||||||
@@ -330,86 +191,4 @@ describe("FileTrie", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pathToNode", () => {
|
|
||||||
test("should return root node for empty path", () => {
|
|
||||||
const data = { title: "Root", slug: "index", filePath: "index.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain([])
|
|
||||||
assert.deepStrictEqual(path, [trie])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return root node for index path", () => {
|
|
||||||
const data = { title: "Root", slug: "index", filePath: "index.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain(["index"])
|
|
||||||
assert.deepStrictEqual(path, [trie])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return path to first level node", () => {
|
|
||||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain(["test"])
|
|
||||||
assert.deepStrictEqual(path, [trie, trie.children[0]])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return path to nested node", () => {
|
|
||||||
const data = {
|
|
||||||
title: "Nested",
|
|
||||||
slug: "folder/subfolder/test",
|
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain(["folder", "subfolder", "test"])
|
|
||||||
assert.deepStrictEqual(path, [
|
|
||||||
trie,
|
|
||||||
trie.children[0],
|
|
||||||
trie.children[0].children[0],
|
|
||||||
trie.children[0].children[0].children[0],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return undefined for non-existent path", () => {
|
|
||||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain(["nonexistent"])
|
|
||||||
assert.strictEqual(path, undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return file data for intermediate folders", () => {
|
|
||||||
const data1 = {
|
|
||||||
title: "Root",
|
|
||||||
slug: "index",
|
|
||||||
filePath: "index.md",
|
|
||||||
}
|
|
||||||
const data2 = {
|
|
||||||
title: "Test",
|
|
||||||
slug: "folder/subfolder/test",
|
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
|
||||||
const data3 = {
|
|
||||||
title: "Folder Index",
|
|
||||||
slug: "folder/index",
|
|
||||||
filePath: "folder/index.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
trie.add(data1)
|
|
||||||
trie.add(data2)
|
|
||||||
trie.add(data3)
|
|
||||||
const path = trie.ancestryChain(["folder", "subfolder"])
|
|
||||||
assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
|
|
||||||
assert.strictEqual(path[1].data, data3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("should return path for partial path", () => {
|
|
||||||
const data = {
|
|
||||||
title: "Nested",
|
|
||||||
slug: "folder/subfolder/test",
|
|
||||||
filePath: "folder/subfolder/test.md",
|
|
||||||
}
|
|
||||||
trie.add(data)
|
|
||||||
const path = trie.ancestryChain(["folder"])
|
|
||||||
assert.deepStrictEqual(path, [trie, trie.children[0]])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FullSlug, joinSegments } from "./path"
|
|||||||
interface FileTrieData {
|
interface FileTrieData {
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
filePath: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
||||||
@@ -12,11 +11,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
|||||||
children: Array<FileTrieNode<T>>
|
children: Array<FileTrieNode<T>>
|
||||||
|
|
||||||
private slugSegments: string[]
|
private slugSegments: string[]
|
||||||
// prefer showing the file path segment over the slug segment
|
|
||||||
// so that folders that dont have index files can be shown as is
|
|
||||||
// without dashes in the slug
|
|
||||||
private fileSegmentHint?: string
|
|
||||||
private displayNameOverride?: string
|
|
||||||
data: T | null
|
data: T | null
|
||||||
|
|
||||||
constructor(segments: string[], data?: T) {
|
constructor(segments: string[], data?: T) {
|
||||||
@@ -24,18 +18,10 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
|||||||
this.slugSegments = segments
|
this.slugSegments = segments
|
||||||
this.data = data ?? null
|
this.data = data ?? null
|
||||||
this.isFolder = false
|
this.isFolder = false
|
||||||
this.displayNameOverride = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName(): string {
|
get displayName(): string {
|
||||||
const nonIndexTitle = this.data?.title === "index" ? undefined : this.data?.title
|
return this.data?.title ?? this.slugSegment ?? ""
|
||||||
return (
|
|
||||||
this.displayNameOverride ?? nonIndexTitle ?? this.fileSegmentHint ?? this.slugSegment ?? ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
set displayName(name: string) {
|
|
||||||
this.displayNameOverride = name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get slug(): FullSlug {
|
get slug(): FullSlug {
|
||||||
@@ -77,9 +63,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
|||||||
// recursive case, we are not at the end of the path
|
// recursive case, we are not at the end of the path
|
||||||
const child =
|
const child =
|
||||||
this.children.find((c) => c.slugSegment === segment) ?? this.makeChild(path, undefined)
|
this.children.find((c) => c.slugSegment === segment) ?? this.makeChild(path, undefined)
|
||||||
|
|
||||||
const fileParts = file.filePath.split("/")
|
|
||||||
child.fileSegmentHint = fileParts.at(-path.length)
|
|
||||||
child.insert(path.slice(1), file)
|
child.insert(path.slice(1), file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,32 +72,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
|||||||
this.insert(file.slug.split("/"), file)
|
this.insert(file.slug.split("/"), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
findNode(path: string[]): FileTrieNode<T> | undefined {
|
|
||||||
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
|
|
||||||
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
|
|
||||||
return [this]
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = this.children.find((c) => c.slugSegment === path[0])
|
|
||||||
if (!child) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const childPath = child.ancestryChain(path.slice(1))
|
|
||||||
if (!childPath) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return [this, ...childPath]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import truncate from "ansi-truncate"
|
|
||||||
import readline from "readline"
|
import readline from "readline"
|
||||||
|
|
||||||
export class QuartzLogger {
|
export class QuartzLogger {
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
private spinnerInterval: NodeJS.Timeout | undefined
|
private spinnerInterval: NodeJS.Timeout | undefined
|
||||||
private spinnerText: string = ""
|
private spinnerText: string = ""
|
||||||
private updateSuffix: string = ""
|
|
||||||
private spinnerIndex: number = 0
|
private spinnerIndex: number = 0
|
||||||
private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
constructor(verbose: boolean) {
|
constructor(verbose: boolean) {
|
||||||
const isInteractiveTerminal =
|
this.verbose = verbose
|
||||||
process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.CI
|
|
||||||
this.verbose = verbose || !isInteractiveTerminal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start(text: string) {
|
start(text: string) {
|
||||||
this.spinnerText = text
|
this.spinnerText = text
|
||||||
|
|
||||||
if (this.verbose) {
|
if (this.verbose) {
|
||||||
console.log(text)
|
console.log(text)
|
||||||
} else {
|
} else {
|
||||||
@@ -25,22 +20,14 @@ export class QuartzLogger {
|
|||||||
this.spinnerInterval = setInterval(() => {
|
this.spinnerInterval = setInterval(() => {
|
||||||
readline.clearLine(process.stdout, 0)
|
readline.clearLine(process.stdout, 0)
|
||||||
readline.cursorTo(process.stdout, 0)
|
readline.cursorTo(process.stdout, 0)
|
||||||
|
process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
|
||||||
const columns = process.stdout.columns || 80
|
|
||||||
let output = `${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`
|
|
||||||
if (this.updateSuffix) {
|
|
||||||
output += `: ${this.updateSuffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncated = truncate(output, columns)
|
|
||||||
process.stdout.write(truncated)
|
|
||||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
|
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
|
||||||
}, 50)
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateText(text: string) {
|
updateText(text: string) {
|
||||||
this.updateSuffix = text
|
this.spinnerText = text
|
||||||
}
|
}
|
||||||
|
|
||||||
end(text?: string) {
|
end(text?: string) {
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { FontWeight, SatoriOptions } from "satori/wasm"
|
|||||||
import { GlobalConfiguration } from "../cfg"
|
import { GlobalConfiguration } from "../cfg"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
import { JSXInternal } from "preact/src/jsx"
|
import { JSXInternal } from "preact/src/jsx"
|
||||||
import { FontSpecification, getFontSpecificationName, ThemeKey } from "./theme"
|
import { FontSpecification, ThemeKey } from "./theme"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { QUARTZ } from "./path"
|
import { QUARTZ } from "./path"
|
||||||
import { formatDate, getDate } from "../components/Date"
|
import { formatDate } from "../components/Date"
|
||||||
import readingTime from "reading-time"
|
import { getDate } from "../components/Date"
|
||||||
import { i18n } from "../i18n"
|
|
||||||
import chalk from "chalk"
|
|
||||||
|
|
||||||
const defaultHeaderWeight = [700]
|
const defaultHeaderWeight = [700]
|
||||||
const defaultBodyWeight = [400]
|
const defaultBodyWeight = [400]
|
||||||
|
|
||||||
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
|
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
|
||||||
// Get all weights for header and body fonts
|
// Get all weights for header and body fonts
|
||||||
const headerWeights: FontWeight[] = (
|
const headerWeights: FontWeight[] = (
|
||||||
@@ -28,38 +25,29 @@ export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: Fo
|
|||||||
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
|
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
|
||||||
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
|
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
|
||||||
|
|
||||||
// Fetch fonts for all weights and convert to satori format in one go
|
// Fetch fonts for all weights
|
||||||
const headerFontPromises = headerWeights.map(async (weight) => {
|
const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight))
|
||||||
const data = await fetchTtf(headerFontName, weight)
|
const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight))
|
||||||
if (!data) return null
|
|
||||||
return {
|
|
||||||
name: headerFontName,
|
|
||||||
data,
|
|
||||||
weight,
|
|
||||||
style: "normal" as const,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const bodyFontPromises = bodyWeights.map(async (weight) => {
|
const [headerFontData, bodyFontData] = await Promise.all([
|
||||||
const data = await fetchTtf(bodyFontName, weight)
|
|
||||||
if (!data) return null
|
|
||||||
return {
|
|
||||||
name: bodyFontName,
|
|
||||||
data,
|
|
||||||
weight,
|
|
||||||
style: "normal" as const,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const [headerFonts, bodyFonts] = await Promise.all([
|
|
||||||
Promise.all(headerFontPromises),
|
Promise.all(headerFontPromises),
|
||||||
Promise.all(bodyFontPromises),
|
Promise.all(bodyFontPromises),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Filter out any failed fetches and combine header and body fonts
|
// Convert fonts to satori font format and return
|
||||||
const fonts: SatoriOptions["fonts"] = [
|
const fonts: SatoriOptions["fonts"] = [
|
||||||
...headerFonts.filter((font): font is NonNullable<typeof font> => font !== null),
|
...headerFontData.map((data, idx) => ({
|
||||||
...bodyFonts.filter((font): font is NonNullable<typeof font> => font !== null),
|
name: headerFontName,
|
||||||
|
data,
|
||||||
|
weight: headerWeights[idx],
|
||||||
|
style: "normal" as const,
|
||||||
|
})),
|
||||||
|
...bodyFontData.map((data, idx) => ({
|
||||||
|
name: bodyFontName,
|
||||||
|
data,
|
||||||
|
weight: bodyWeights[idx],
|
||||||
|
style: "normal" as const,
|
||||||
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
return fonts
|
return fonts
|
||||||
@@ -72,11 +60,10 @@ export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: Fo
|
|||||||
* @returns `.ttf` file of google font
|
* @returns `.ttf` file of google font
|
||||||
*/
|
*/
|
||||||
export async function fetchTtf(
|
export async function fetchTtf(
|
||||||
rawFontName: string,
|
fontName: string,
|
||||||
weight: FontWeight,
|
weight: FontWeight,
|
||||||
): Promise<Buffer<ArrayBufferLike> | undefined> {
|
): Promise<Buffer<ArrayBufferLike>> {
|
||||||
const fontName = rawFontName.replaceAll(" ", "+")
|
const cacheKey = `${fontName.replaceAll(" ", "-")}-${weight}`
|
||||||
const cacheKey = `${fontName}-${weight}`
|
|
||||||
const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
|
const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
|
||||||
const cachePath = path.join(cacheDir, cacheKey)
|
const cachePath = path.join(cacheDir, cacheKey)
|
||||||
|
|
||||||
@@ -99,19 +86,20 @@ export async function fetchTtf(
|
|||||||
const match = urlRegex.exec(css)
|
const match = urlRegex.exec(css)
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
console.log(
|
throw new Error("Could not fetch font")
|
||||||
chalk.yellow(
|
|
||||||
`\nWarning: Failed to fetch font ${rawFontName} with weight ${weight}, got ${cssResponse.statusText}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fontData is an ArrayBuffer containing the .ttf file data
|
// fontData is an ArrayBuffer containing the .ttf file data
|
||||||
const fontResponse = await fetch(match[1])
|
const fontResponse = await fetch(match[1])
|
||||||
const fontData = Buffer.from(await fontResponse.arrayBuffer())
|
const fontData = Buffer.from(await fontResponse.arrayBuffer())
|
||||||
await fs.mkdir(cacheDir, { recursive: true })
|
|
||||||
await fs.writeFile(cachePath, fontData)
|
try {
|
||||||
|
await fs.mkdir(cacheDir, { recursive: true })
|
||||||
|
await fs.writeFile(cachePath, fontData)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to cache font: ${error}`)
|
||||||
|
// Continue even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
return fontData
|
return fontData
|
||||||
}
|
}
|
||||||
@@ -135,12 +123,21 @@ export type SocialImageOptions = {
|
|||||||
excludeRoot: boolean
|
excludeRoot: boolean
|
||||||
/**
|
/**
|
||||||
* JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
|
* 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: (
|
imageStructure: (
|
||||||
options: ImageOptions & {
|
cfg: GlobalConfiguration,
|
||||||
userOpts: UserOpts
|
userOpts: UserOpts,
|
||||||
iconBase64?: string
|
title: string,
|
||||||
},
|
description: string,
|
||||||
|
fonts: SatoriOptions["fonts"],
|
||||||
|
fileData: QuartzPluginData,
|
||||||
) => JSXInternal.Element
|
) => JSXInternal.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,32 +167,24 @@ export type ImageOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This is the default template for generated social image.
|
// This is the default template for generated social image.
|
||||||
export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||||
cfg,
|
cfg: GlobalConfiguration,
|
||||||
userOpts,
|
{ colorScheme }: UserOpts,
|
||||||
title,
|
title: string,
|
||||||
description,
|
description: string,
|
||||||
fileData,
|
fonts: SatoriOptions["fonts"],
|
||||||
iconBase64,
|
fileData: QuartzPluginData,
|
||||||
}) => {
|
) => {
|
||||||
const { colorScheme } = userOpts
|
|
||||||
const fontBreakPoint = 32
|
const fontBreakPoint = 32
|
||||||
const useSmallerFont = title.length > fontBreakPoint
|
const useSmallerFont = title.length > fontBreakPoint
|
||||||
|
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
|
||||||
|
|
||||||
// Format date if available
|
// Format date if available
|
||||||
const rawDate = getDate(cfg, fileData)
|
const rawDate = getDate(cfg, fileData)
|
||||||
const date = rawDate ? formatDate(rawDate, cfg.locale) : null
|
const date = rawDate ? formatDate(rawDate, cfg.locale) : null
|
||||||
|
|
||||||
// Calculate reading time
|
|
||||||
const { minutes } = readingTime(fileData.text ?? "")
|
|
||||||
const readingTimeText = i18n(cfg.locale).components.contentMeta.readingTime({
|
|
||||||
minutes: Math.ceil(minutes),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get tags if available
|
// Get tags if available
|
||||||
const tags = fileData.frontmatter?.tags ?? []
|
const tags = fileData.frontmatter?.tags ?? []
|
||||||
const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
|
|
||||||
const headerFont = getFontSpecificationName(cfg.theme.typography.header)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -206,7 +195,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
backgroundColor: cfg.theme.colors[colorScheme].light,
|
backgroundColor: cfg.theme.colors[colorScheme].light,
|
||||||
padding: "2.5rem",
|
padding: "2.5rem",
|
||||||
fontFamily: bodyFont,
|
fontFamily: fonts[1].name,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@@ -218,22 +207,20 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{iconBase64 && (
|
<img
|
||||||
<img
|
src={iconPath}
|
||||||
src={iconBase64}
|
width={56}
|
||||||
width={56}
|
height={56}
|
||||||
height={56}
|
style={{
|
||||||
style={{
|
borderRadius: "50%",
|
||||||
borderRadius: "50%",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
color: cfg.theme.colors[colorScheme].gray,
|
color: cfg.theme.colors[colorScheme].gray,
|
||||||
fontFamily: bodyFont,
|
fontFamily: fonts[1].name,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cfg.baseUrl}
|
{cfg.baseUrl}
|
||||||
@@ -252,7 +239,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
fontSize: useSmallerFont ? 64 : 72,
|
fontSize: useSmallerFont ? 64 : 72,
|
||||||
fontFamily: headerFont,
|
fontFamily: fonts[0].name,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: cfg.theme.colors[colorScheme].dark,
|
color: cfg.theme.colors[colorScheme].dark,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
@@ -260,7 +247,6 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
WebkitBoxOrient: "vertical",
|
WebkitBoxOrient: "vertical",
|
||||||
WebkitLineClamp: 2,
|
WebkitLineClamp: 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -282,9 +268,8 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
display: "-webkit-box",
|
display: "-webkit-box",
|
||||||
WebkitBoxOrient: "vertical",
|
WebkitBoxOrient: "vertical",
|
||||||
WebkitLineClamp: 5,
|
WebkitLineClamp: 4,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
@@ -302,12 +287,11 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
|
borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Date and Reading Time */}
|
{/* Left side - Date */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "2rem",
|
|
||||||
color: cfg.theme.colors[colorScheme].gray,
|
color: cfg.theme.colors[colorScheme].gray,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
}}
|
}}
|
||||||
@@ -330,20 +314,6 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
|||||||
{date}
|
{date}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<svg
|
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<polyline points="12 6 12 12 16 14"></polyline>
|
|
||||||
</svg>
|
|
||||||
{readingTimeText}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Tags */}
|
{/* Right side - Tags */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test, { describe } from "node:test"
|
import test, { describe } from "node:test"
|
||||||
import * as path from "./path"
|
import * as path from "./path"
|
||||||
import assert from "node:assert"
|
import assert from "node:assert"
|
||||||
import { FullSlug, TransformOptions, SimpleSlug } from "./path"
|
import { FullSlug, TransformOptions } from "./path"
|
||||||
|
|
||||||
describe("typeguards", () => {
|
describe("typeguards", () => {
|
||||||
test("isSimpleSlug", () => {
|
test("isSimpleSlug", () => {
|
||||||
@@ -38,17 +38,6 @@ describe("typeguards", () => {
|
|||||||
assert(!path.isRelativeURL("./abc/def.md"))
|
assert(!path.isRelativeURL("./abc/def.md"))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("isAbsoluteURL", () => {
|
|
||||||
assert(path.isAbsoluteURL("https://example.com"))
|
|
||||||
assert(path.isAbsoluteURL("http://example.com"))
|
|
||||||
assert(path.isAbsoluteURL("ftp://example.com/a/b/c"))
|
|
||||||
assert(path.isAbsoluteURL("http://host/%25"))
|
|
||||||
assert(path.isAbsoluteURL("file://host/twoslashes?more//slashes"))
|
|
||||||
|
|
||||||
assert(!path.isAbsoluteURL("example.com/abc/def"))
|
|
||||||
assert(!path.isAbsoluteURL("abc"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("isFullSlug", () => {
|
test("isFullSlug", () => {
|
||||||
assert(path.isFullSlug("index"))
|
assert(path.isFullSlug("index"))
|
||||||
assert(path.isFullSlug("abc/def"))
|
assert(path.isFullSlug("abc/def"))
|
||||||
@@ -314,50 +303,3 @@ describe("link strategies", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("resolveRelative", () => {
|
|
||||||
test("from index", () => {
|
|
||||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "index" as FullSlug), "./")
|
|
||||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc" as FullSlug), "./abc")
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("index" as FullSlug, "abc/def" as FullSlug),
|
|
||||||
"./abc/def",
|
|
||||||
)
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("index" as FullSlug, "abc/def/ghi" as FullSlug),
|
|
||||||
"./abc/def/ghi",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("from nested page", () => {
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "index" as FullSlug), "../")
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "abc" as FullSlug), "../abc")
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("abc/def" as FullSlug, "abc/def" as FullSlug),
|
|
||||||
"../abc/def",
|
|
||||||
)
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("abc/def" as FullSlug, "ghi/jkl" as FullSlug),
|
|
||||||
"../ghi/jkl",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("with index paths", () => {
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/index" as FullSlug, "index" as FullSlug), "../")
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("abc/def/index" as FullSlug, "index" as FullSlug),
|
|
||||||
"../../",
|
|
||||||
)
|
|
||||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc/index" as FullSlug), "./abc/")
|
|
||||||
assert.strictEqual(
|
|
||||||
path.resolveRelative("abc/def" as FullSlug, "abc/index" as FullSlug),
|
|
||||||
"../abc/",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("with simple slugs", () => {
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "" as SimpleSlug), "../")
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi" as SimpleSlug), "../ghi")
|
|
||||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi/" as SimpleSlug), "../ghi/")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { slug as slugAnchor } from "github-slugger"
|
import { slug as slugAnchor } from "github-slugger"
|
||||||
import type { Element as HastElement } from "hast"
|
import type { Element as HastElement } from "hast"
|
||||||
import { clone } from "./clone"
|
import { clone } from "./clone"
|
||||||
|
|
||||||
// this file must be isomorphic so it can't use node libs (e.g. path)
|
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||||
|
|
||||||
export const QUARTZ = "quartz"
|
export const QUARTZ = "quartz"
|
||||||
@@ -40,15 +39,6 @@ export function isRelativeURL(s: string): s is RelativeURL {
|
|||||||
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
|
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAbsoluteURL(s: string): boolean {
|
|
||||||
try {
|
|
||||||
new URL(s)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFullSlug(window: Window): FullSlug {
|
export function getFullSlug(window: Window): FullSlug {
|
||||||
const res = window.document.body.dataset.slug! as FullSlug
|
const res = window.document.body.dataset.slug! as FullSlug
|
||||||
return res
|
return res
|
||||||
@@ -257,7 +247,7 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// path helpers
|
// path helpers
|
||||||
export function isFolderPath(fplike: string): boolean {
|
function isFolderPath(fplike: string): boolean {
|
||||||
return (
|
return (
|
||||||
fplike.endsWith("/") ||
|
fplike.endsWith("/") ||
|
||||||
endsWith(fplike, "index") ||
|
endsWith(fplike, "index") ||
|
||||||
@@ -270,7 +260,7 @@ export function endsWith(s: string, suffix: string): boolean {
|
|||||||
return s === suffix || s.endsWith("/" + suffix)
|
return s === suffix || s.endsWith("/" + suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trimSuffix(s: string, suffix: string): string {
|
function trimSuffix(s: string, suffix: string): string {
|
||||||
if (endsWith(s, suffix)) {
|
if (endsWith(s, suffix)) {
|
||||||
s = s.slice(0, -suffix.length)
|
s = s.slice(0, -suffix.length)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export type FontSpecification =
|
|||||||
|
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
typography: {
|
typography: {
|
||||||
title?: FontSpecification
|
|
||||||
header: FontSpecification
|
header: FontSpecification
|
||||||
body: FontSpecification
|
body: FontSpecification
|
||||||
code: FontSpecification
|
code: FontSpecification
|
||||||
@@ -49,10 +48,7 @@ export function getFontSpecificationName(spec: FontSpecification): string {
|
|||||||
return spec.name
|
return spec.name
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFontSpecification(
|
function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpecification) {
|
||||||
type: "title" | "header" | "body" | "code",
|
|
||||||
spec: FontSpecification,
|
|
||||||
) {
|
|
||||||
if (typeof spec === "string") {
|
if (typeof spec === "string") {
|
||||||
spec = { name: spec }
|
spec = { name: spec }
|
||||||
}
|
}
|
||||||
@@ -86,19 +82,12 @@ function formatFontSpecification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function googleFontHref(theme: Theme) {
|
export function googleFontHref(theme: Theme) {
|
||||||
const { header, body, code } = theme.typography
|
const { code, header, body } = theme.typography
|
||||||
const headerFont = formatFontSpecification("header", header)
|
const headerFont = formatFontSpecification("header", header)
|
||||||
const bodyFont = formatFontSpecification("body", body)
|
const bodyFont = formatFontSpecification("body", body)
|
||||||
const codeFont = formatFontSpecification("code", code)
|
const codeFont = formatFontSpecification("code", code)
|
||||||
|
|
||||||
return `https://fonts.googleapis.com/css2?family=${headerFont}&family=${bodyFont}&family=${codeFont}&display=swap`
|
return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap`
|
||||||
}
|
|
||||||
|
|
||||||
export function googleFontSubsetHref(theme: Theme, text: string) {
|
|
||||||
const title = theme.typography.title || theme.typography.header
|
|
||||||
const titleFont = formatFontSpecification("title", title)
|
|
||||||
|
|
||||||
return `https://fonts.googleapis.com/css2?family=${titleFont}&text=${encodeURIComponent(text)}&display=swap`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleFontFile {
|
export interface GoogleFontFile {
|
||||||
@@ -146,10 +135,9 @@ ${stylesheet.join("\n\n")}
|
|||||||
--highlight: ${theme.colors.lightMode.highlight};
|
--highlight: ${theme.colors.lightMode.highlight};
|
||||||
--textHighlight: ${theme.colors.lightMode.textHighlight};
|
--textHighlight: ${theme.colors.lightMode.textHighlight};
|
||||||
|
|
||||||
--titleFont: "${getFontSpecificationName(theme.typography.title || theme.typography.header)}", ${DEFAULT_SANS_SERIF};
|
--headerFont: "${theme.typography.header}", ${DEFAULT_SANS_SERIF};
|
||||||
--headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF};
|
--bodyFont: "${theme.typography.body}", ${DEFAULT_SANS_SERIF};
|
||||||
--bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF};
|
--codeFont: "${theme.typography.code}", ${DEFAULT_MONO};
|
||||||
--codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[saved-theme="dark"] {
|
:root[saved-theme="dark"] {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import sourceMapSupport from "source-map-support"
|
import sourceMapSupport from "source-map-support"
|
||||||
sourceMapSupport.install(options)
|
sourceMapSupport.install(options)
|
||||||
import cfg from "../quartz.config"
|
import cfg from "../quartz.config"
|
||||||
import { BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx"
|
import { Argv, BuildCtx } from "./util/ctx"
|
||||||
import { FilePath } from "./util/path"
|
import { FilePath, FullSlug } from "./util/path"
|
||||||
import {
|
import {
|
||||||
createFileParser,
|
createFileParser,
|
||||||
createHtmlProcessor,
|
createHtmlProcessor,
|
||||||
@@ -14,24 +14,35 @@ import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
|
|||||||
|
|
||||||
// only called from worker thread
|
// only called from worker thread
|
||||||
export async function parseMarkdown(
|
export async function parseMarkdown(
|
||||||
partialCtx: WorkerSerializableBuildCtx,
|
buildId: string,
|
||||||
|
argv: Argv,
|
||||||
fps: FilePath[],
|
fps: FilePath[],
|
||||||
): Promise<MarkdownContent[]> {
|
): 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 = {
|
||||||
...partialCtx,
|
buildId,
|
||||||
cfg,
|
cfg,
|
||||||
|
argv,
|
||||||
|
allSlugs,
|
||||||
}
|
}
|
||||||
return await createFileParser(ctx, fps)(createMdProcessor(ctx))
|
return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
|
||||||
}
|
}
|
||||||
|
|
||||||
// only called from worker thread
|
// only called from worker thread
|
||||||
export function processHtml(
|
export function processHtml(
|
||||||
partialCtx: WorkerSerializableBuildCtx,
|
buildId: string,
|
||||||
|
argv: Argv,
|
||||||
mds: MarkdownContent[],
|
mds: MarkdownContent[],
|
||||||
|
allSlugs: FullSlug[],
|
||||||
): Promise<ProcessedContent[]> {
|
): Promise<ProcessedContent[]> {
|
||||||
const ctx: BuildCtx = {
|
const ctx: BuildCtx = {
|
||||||
...partialCtx,
|
buildId,
|
||||||
cfg,
|
cfg,
|
||||||
|
argv,
|
||||||
|
allSlugs,
|
||||||
}
|
}
|
||||||
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
|
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact"
|
"jsxImportSource": "preact"
|
||||||
|
|||||||
Reference in New Issue
Block a user