mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-03 11:17:57 +01:00
Compare commits
77 Commits
jackyzha0/
...
26ff78f81f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ff78f81f | ||
|
|
0cacd48458 | ||
|
|
51790b3926 | ||
|
|
08717394ff | ||
|
|
a201105442 | ||
|
|
cc9704becc | ||
|
|
6d195fd40a | ||
|
|
2213424195 | ||
|
|
5b13ff2199 | ||
|
|
5a39719898 | ||
|
|
3c8ccde624 | ||
|
|
c97fd7089a | ||
|
|
2acfa0fa23 | ||
|
|
f6f417a505 | ||
|
|
a3b6201365 | ||
|
|
a1162b978a | ||
|
|
c6f10b44f6 | ||
|
|
b050162f82 | ||
|
|
6d350500f1 | ||
|
|
9b47279fe6 | ||
|
|
4901472aa8 | ||
|
|
28fe9b2095 | ||
|
|
9a0d919a3b | ||
|
|
598741a571 | ||
|
|
29c533a265 | ||
|
|
1a02642469 | ||
|
|
5794405974 | ||
|
|
59d5a0ed3a | ||
|
|
dd6bd498db | ||
|
|
04423d4931 | ||
|
|
be846e6a5f | ||
|
|
32ec711767 | ||
|
|
91189dfd2f | ||
|
|
fbc45548f7 | ||
|
|
7be47742a6 | ||
|
|
4e4930ef9c | ||
|
|
01943ff5a0 | ||
|
|
8cf3e3001f | ||
|
|
992ac00f7c | ||
|
|
09f8670db7 | ||
|
|
7e828252bb | ||
|
|
c90dbacab0 | ||
|
|
b7a945e034 | ||
|
|
dc3323b574 | ||
|
|
c98ef7e89f | ||
|
|
2e6a675edd | ||
|
|
e3162f7a7e | ||
|
|
9466c145b1 | ||
|
|
a934397961 | ||
|
|
b2752e7262 | ||
|
|
d9e36e60d1 | ||
|
|
46adb35966 | ||
|
|
e1c9eabef4 | ||
|
|
d88e43010a | ||
|
|
ef72f1bf70 | ||
|
|
7d4bed64a9 | ||
|
|
99011cb1b0 | ||
|
|
c91cf97f99 | ||
|
|
05e6f05a50 | ||
|
|
7533d504dc | ||
|
|
69a0ddf733 | ||
|
|
a582505daf | ||
|
|
965f9e123c | ||
|
|
ff9e60a7fc | ||
|
|
367bb0e6ce | ||
|
|
8141cb1587 | ||
|
|
5ccc2dcbba | ||
|
|
11c98f5600 | ||
|
|
5a5ef670fe | ||
|
|
285c0e9768 | ||
|
|
adb326c933 | ||
|
|
0c4281eb53 | ||
|
|
7ac94e1d84 | ||
|
|
e1d754ef79 | ||
|
|
a727b57e46 | ||
|
|
1a4cfa606f | ||
|
|
410001b942 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
run: npm test
|
||||
|
||||
- name: Ensure Quartz builds, check bundle info
|
||||
run: npx quartz build --bundleInfo
|
||||
run: npx quartz build --bundleInfo -d docs
|
||||
|
||||
publish-tag:
|
||||
if: ${{ github.repository == 'jackyzha0/quartz' && github.ref == 'refs/heads/v4' }}
|
||||
|
||||
2
.github/workflows/docker-build-push.yaml
vendored
2
.github/workflows/docker-build-push.yaml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
network=host
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.7.0
|
||||
uses: sigstore/cosign-installer@v3.8.1
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY package.json .
|
||||
COPY package-lock.json* .
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=builder /usr/src/app/ /usr/src/app/
|
||||
COPY . .
|
||||
|
||||
@@ -37,7 +37,7 @@ Transformers **map** over content, taking a Markdown file and outputting modifie
|
||||
```ts
|
||||
export type QuartzTransformerPluginInstance = {
|
||||
name: string
|
||||
textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer
|
||||
textTransform?: (ctx: BuildCtx, src: string) => string
|
||||
markdownPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
htmlPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
externalResources?: (ctx: BuildCtx) => Partial<StaticResources>
|
||||
@@ -99,8 +99,6 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
|
||||
},
|
||||
],
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -274,7 +272,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
for (const [tree, file] of content) {
|
||||
const slug = canonicalizeServer(file.data.slug!)
|
||||
const externalResources = pageResources(slug, resources)
|
||||
const externalResources = pageResources(slug, file.data, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: file.data,
|
||||
externalResources,
|
||||
|
||||
@@ -35,6 +35,8 @@ Some common frontmatter fields that are natively supported by Quartz:
|
||||
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.
|
||||
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
|
||||
|
||||
See [[Frontmatter]] for a complete list of frontmatter.
|
||||
|
||||
## Syncing your Content
|
||||
|
||||
When your Quartz is at a point you're happy with, you can save your changes to GitHub.
|
||||
|
||||
31
docs/features/Citations.md
Normal file
31
docs/features/Citations.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Citations
|
||||
tags:
|
||||
- feature/transformer
|
||||
---
|
||||
|
||||
Quartz uses [rehype-citation](https://github.com/timlrx/rehype-citation) to support parsing of a BibTex bibliography file.
|
||||
|
||||
Under the default configuration, a citation key `[@templeton2024scaling]` will be exported as `(Templeton et al., 2024)`.
|
||||
|
||||
> [!example]- BibTex file
|
||||
>
|
||||
> ```bib title="bibliography.bib"
|
||||
> @article{templeton2024scaling,
|
||||
> title={Scaling Monosemanticity: Extracting Interpretable Features from Claude 3 Sonnet},
|
||||
> author={Templeton, Adly and Conerly, Tom and Marcus, Jonathan and Lindsey, Jack and Bricken, Trenton and Chen, Brian and Pearce, Adam and Citro, Craig and Ameisen, Emmanuel and Jones, Andy and Cunningham, Hoagy and Turner, Nicholas L and McDougall, Callum and MacDiarmid, Monte and Freeman, C. Daniel and Sumers, Theodore R. and Rees, Edward and Batson, Joshua and Jermyn, Adam and Carter, Shan and Olah, Chris and Henighan, Tom},
|
||||
> year={2024},
|
||||
> journal={Transformer Circuits Thread},
|
||||
> url={https://transformer-circuits.pub/2024/scaling-monosemanticity/index.html}
|
||||
> }
|
||||
> ```
|
||||
|
||||
> [!note] Behaviour of references
|
||||
>
|
||||
> By default, the references will be included at the end of the file. To control where the references to be included, uses `[^ref]`
|
||||
>
|
||||
> Refer to `rehype-citation` docs for more information.
|
||||
|
||||
## Customization
|
||||
|
||||
Citation parsing is a functionality of the [[plugins/Citations|Citation]] plugin. **This plugin is not enabled by default**. See the plugin page for customization options.
|
||||
@@ -3,5 +3,5 @@ Quartz comes shipped with a Docker image that will allow you to preview your Qua
|
||||
You can run the below one-liner to run Quartz in Docker.
|
||||
|
||||
```sh
|
||||
docker run --rm -itp 8080:8080 $(docker build -q .)
|
||||
docker run --rm -itp 8080:8080 -p 3001:3001 -v ./content:/usr/src/app/content $(docker build -q .)
|
||||
```
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly.
|
||||
|
||||
> [!info]
|
||||
> After deploying, the generated RSS link will be available at `https://${baseUrl}/index.xml` by default.
|
||||
>
|
||||
> The `index.xml` path can be customized by passing the `rssSlug` option to the [[ContentIndex]] plugin.
|
||||
|
||||
## Configuration
|
||||
|
||||
This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options.
|
||||
|
||||
@@ -9,6 +9,7 @@ A backlink for a note is a link from another note to that note. Links in the bac
|
||||
## Customization
|
||||
|
||||
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`.
|
||||
- Hide when empty: hide `Backlinks` if given page doesn't contain any backlinks (default to `true`). To disable this, use `Component.Backlinks({ hideWhenEmpty: false })`.
|
||||
- Component: `quartz/components/Backlinks.tsx`
|
||||
- Style: `quartz/components/styles/backlinks.scss`
|
||||
- Script: `quartz/components/scripts/search.inline.ts`
|
||||
|
||||
@@ -36,6 +36,7 @@ Component.Graph({
|
||||
opacityScale: 1, // how quickly do we fade out the labels when zooming out?
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
enableRadial: false, // whether to constrain the graph, similar to Obsidian
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
@@ -49,6 +50,7 @@ Component.Graph({
|
||||
opacityScale: 1,
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
enableRadial: true, // whether to constrain the graph, similar to Obsidian
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -247,6 +247,28 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### Using Apache
|
||||
|
||||
Here's an example of how to do this with Apache:
|
||||
|
||||
```apache title=".htaccess"
|
||||
RewriteEngine On
|
||||
|
||||
ErrorDocument 404 /404.html
|
||||
|
||||
# Rewrite rule for .html extension removal (with directory check)
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_URI}.html -f
|
||||
RewriteRule ^(.*)$ $1.html [L]
|
||||
|
||||
# Handle directory requests explicitly
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^(.*)/$ $1/index.html [L]
|
||||
```
|
||||
|
||||
Don't forget to activate brotli / gzip compression.
|
||||
|
||||
### Using Caddy
|
||||
|
||||
Here's and example of how to do this with Caddy:
|
||||
|
||||
24
docs/plugins/Citations.md
Normal file
24
docs/plugins/Citations.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Citations"
|
||||
tags:
|
||||
- plugin/transformer
|
||||
---
|
||||
|
||||
This plugin adds Citation support to Quartz.
|
||||
|
||||
> [!note]
|
||||
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
|
||||
|
||||
This plugin accepts the following configuration options:
|
||||
|
||||
- `bibliographyFile`: the path to the bibliography file. Defaults to `./bibliography.bib`. This is relative to git source of your vault.
|
||||
- `suppressBibliography`: whether to suppress the bibliography at the end of the document. Defaults to `false`.
|
||||
- `linkCitations`: whether to link citations to the bibliography. Defaults to `false`.
|
||||
- `csl`: the citation style to use. Defaults to `apa`. Reference [rehype-citation](https://rehype-citation.netlify.app/custom-csl) for more options.
|
||||
- `prettyLink`: whether to use pretty links for citations. Defaults to `true`.
|
||||
|
||||
## API
|
||||
|
||||
- Category: Transformer
|
||||
- Function name: `Plugin.Citations()`.
|
||||
- Source: [`quartz/plugins/transformers/citations.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/citations.ts).
|
||||
@@ -17,6 +17,7 @@ This plugin accepts the following configuration options:
|
||||
- `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates.
|
||||
- `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`.
|
||||
- `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries.
|
||||
- `rssSlug`: Slug to the generated RSS feed XML file. Defaults to `"index"`.
|
||||
- `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources.
|
||||
|
||||
## API
|
||||
|
||||
@@ -13,6 +13,8 @@ This plugin accepts the following configuration options:
|
||||
|
||||
- `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `["frontmatter", "git", "filesystem"]`.
|
||||
|
||||
When loading the frontmatter, the value of [[Frontmatter#List]] is used.
|
||||
|
||||
> [!warning]
|
||||
> If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`.
|
||||
>
|
||||
|
||||
@@ -17,6 +17,54 @@ This plugin accepts the following configuration options:
|
||||
> [!warning]
|
||||
> This plugin must not be removed, otherwise Quartz will break.
|
||||
|
||||
## List
|
||||
|
||||
Quartz supports the following frontmatter:
|
||||
|
||||
- title
|
||||
- `title`
|
||||
- description
|
||||
- `description`
|
||||
- permalink
|
||||
- `permalink`
|
||||
- comments
|
||||
- `comments`
|
||||
- lang
|
||||
- `lang`
|
||||
- publish
|
||||
- `publish`
|
||||
- draft
|
||||
- `draft`
|
||||
- enableToc
|
||||
- `enableToc`
|
||||
- tags
|
||||
- `tags`
|
||||
- `tag`
|
||||
- aliases
|
||||
- `aliases`
|
||||
- `alias`
|
||||
- cssclasses
|
||||
- `cssclasses`
|
||||
- `cssclass`
|
||||
- socialDescription
|
||||
- `socialDescription`
|
||||
- socialImage
|
||||
- `socialImage`
|
||||
- `image`
|
||||
- `cover`
|
||||
- created
|
||||
- `created`
|
||||
- `date`
|
||||
- modified
|
||||
- `modified`
|
||||
- `lastmod`
|
||||
- `updated`
|
||||
- `last-modified`
|
||||
- published
|
||||
- `published`
|
||||
- `publishDate`
|
||||
- `date`
|
||||
|
||||
## API
|
||||
|
||||
- Category: Transformer
|
||||
|
||||
@@ -31,4 +31,4 @@ This plugin accepts the following configuration options:
|
||||
|
||||
- Category: Transformer
|
||||
- Function name: `Plugin.ObsidianFlavoredMarkdown()`.
|
||||
- Source: [`quartz/plugins/transformers/toc.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/toc.ts).
|
||||
- Source: [`quartz/plugins/transformers/ofm.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/ofm.ts)
|
||||
|
||||
@@ -9,6 +9,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
||||
- [Socratica Toolbox](https://toolbox.socratica.info/)
|
||||
- [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
|
||||
- [Aaron Pham's Garden](https://aarnphm.xyz/)
|
||||
- [The Pond](https://turntrout.com/welcome)
|
||||
- [Pelayo Arbues' Notes](https://pelayoarbues.com/)
|
||||
- [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
|
||||
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
|
||||
@@ -29,5 +30,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
||||
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
|
||||
- [Eledah's Crystalline](https://blog.eledah.ir/)
|
||||
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)
|
||||
- [Zen Browser Docs](https://docs.zen-browser.app)
|
||||
- [🪴8cat life](https://8cat.life)
|
||||
|
||||
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!
|
||||
|
||||
2323
package-lock.json
generated
2323
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@@ -16,12 +16,12 @@
|
||||
"docs": "npx quartz build --serve -d docs",
|
||||
"check": "tsc --noEmit && npx prettier . --check",
|
||||
"format": "npx prettier . --write",
|
||||
"test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
|
||||
"test": "tsx --test",
|
||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=9.3.1",
|
||||
"node": "20 || >=22"
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"site generator",
|
||||
@@ -35,59 +35,58 @@
|
||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@floating-ui/dom": "^1.6.12",
|
||||
"@myriaddreamin/rehype-typst": "^0.5.0-rc7",
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@myriaddreamin/rehype-typst": "^0.5.4",
|
||||
"@napi-rs/simple-git": "0.1.19",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chalk": "^5.4.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.9.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"flexsearch": "0.7.43",
|
||||
"github-slugger": "^2.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"globby": "^14.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hast-util-to-html": "^9.0.3",
|
||||
"hast-util-to-jsx-runtime": "^2.3.2",
|
||||
"hast-util-to-html": "^9.0.5",
|
||||
"hast-util-to-jsx-runtime": "^2.3.5",
|
||||
"hast-util-to-string": "^3.0.1",
|
||||
"is-absolute-url": "^4.0.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lightningcss": "^1.28.1",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"lightningcss": "^1.29.1",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mdast-util-to-hast": "^13.2.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"micromorph": "^0.4.5",
|
||||
"pixi.js": "^8.5.2",
|
||||
"preact": "^10.24.3",
|
||||
"preact-render-to-string": "^6.5.11",
|
||||
"pixi.js": "^8.8.1",
|
||||
"preact": "^10.26.4",
|
||||
"preact-render-to-string": "^6.5.13",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"pretty-time": "^1.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.2.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfdc": "^1.4.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"satori": "^0.10.14",
|
||||
"satori": "^0.12.1",
|
||||
"serve-handler": "^6.1.6",
|
||||
"sharp": "^0.33.5",
|
||||
"shiki": "^1.22.2",
|
||||
"shiki": "^1.26.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"to-vfile": "^8.0.0",
|
||||
"toml": "^3.0.0",
|
||||
@@ -95,7 +94,7 @@
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.3",
|
||||
"workerpool": "^9.2.0",
|
||||
"ws": "^8.18.0",
|
||||
"ws": "^8.18.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,14 +102,14 @@
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"esbuild": "^0.24.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"esbuild": "^0.25.0",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { QuartzConfig } from "./quartz/cfg"
|
||||
import * as Plugin from "./quartz/plugins"
|
||||
|
||||
/**
|
||||
* Quartz 4.0 Configuration
|
||||
* Quartz 4 Configuration
|
||||
*
|
||||
* See https://quartz.jzhao.xyz/configuration for more information.
|
||||
*/
|
||||
const config: QuartzConfig = {
|
||||
configuration: {
|
||||
pageTitle: "🪴 Quartz 4.0",
|
||||
pageTitle: "🪴 Quartz 4",
|
||||
pageTitleSuffix: "",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
@@ -19,7 +19,7 @@ const config: QuartzConfig = {
|
||||
baseUrl: "quartz.jzhao.xyz",
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "created",
|
||||
generateSocialImages: false,
|
||||
generateSocialImages: true,
|
||||
theme: {
|
||||
fontOrigin: "googleFonts",
|
||||
cdnCaching: true,
|
||||
|
||||
@@ -26,8 +26,9 @@ export const defaultContentPageLayout: PageLayout = {
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
|
||||
Component.Explorer(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
],
|
||||
right: [
|
||||
Component.Graph(),
|
||||
@@ -44,7 +45,7 @@ export const defaultListPageLayout: PageLayout = {
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
Component.Explorer(),
|
||||
],
|
||||
right: [],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-deprecation
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import workerpool from "workerpool"
|
||||
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
|
||||
const { parseFiles } = await import(cacheFile)
|
||||
const { parseMarkdown, processHtml } = await import(cacheFile)
|
||||
workerpool.worker({
|
||||
parseFiles,
|
||||
parseMarkdown,
|
||||
processHtml,
|
||||
})
|
||||
|
||||
@@ -139,9 +139,9 @@ async function startServing(
|
||||
|
||||
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
|
||||
watcher
|
||||
.on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData))
|
||||
.on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData))
|
||||
.on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData))
|
||||
.on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
|
||||
.on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
|
||||
.on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
|
||||
|
||||
return async () => {
|
||||
await watcher.close()
|
||||
|
||||
@@ -33,6 +33,15 @@ import {
|
||||
cwd,
|
||||
} from "./constants.js"
|
||||
|
||||
/**
|
||||
* Resolve content directory path
|
||||
* @param contentPath path to resolve
|
||||
*/
|
||||
function resolveContentPath(contentPath) {
|
||||
if (path.isAbsolute(contentPath)) return path.relative(cwd, contentPath)
|
||||
return path.join(cwd, contentPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz create`
|
||||
* @param {*} argv arguments for `create`
|
||||
@@ -40,7 +49,7 @@ import {
|
||||
export async function handleCreate(argv) {
|
||||
console.log()
|
||||
intro(chalk.bgGreen.black(` Quartz v${version} `))
|
||||
const contentFolder = path.join(cwd, argv.directory)
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
let setupStrategy = argv.strategy?.toLowerCase()
|
||||
let linkResolutionStrategy = argv.links?.toLowerCase()
|
||||
const sourceDirectory = argv.source
|
||||
@@ -450,7 +459,7 @@ export async function handleBuild(argv) {
|
||||
* @param {*} argv arguments for `update`
|
||||
*/
|
||||
export async function handleUpdate(argv) {
|
||||
const contentFolder = path.join(cwd, argv.directory)
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
|
||||
console.log("Backing up your content")
|
||||
execSync(
|
||||
@@ -502,7 +511,7 @@ export async function handleUpdate(argv) {
|
||||
* @param {*} argv arguments for `restore`
|
||||
*/
|
||||
export async function handleRestore(argv) {
|
||||
const contentFolder = path.join(cwd, argv.directory)
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
await popContentFolder(contentFolder)
|
||||
}
|
||||
|
||||
@@ -511,7 +520,7 @@ export async function handleRestore(argv) {
|
||||
* @param {*} argv arguments for `sync`
|
||||
*/
|
||||
export async function handleSync(argv) {
|
||||
const contentFolder = path.join(cwd, argv.directory)
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
|
||||
console.log("Backing up your content")
|
||||
|
||||
|
||||
@@ -3,19 +3,34 @@ import style from "./styles/backlinks.scss"
|
||||
import { resolveRelative, simplifySlug } from "../util/path"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
import OverflowList from "./OverflowList"
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
interface BacklinksOptions {
|
||||
hideWhenEmpty: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BacklinksOptions = {
|
||||
hideWhenEmpty: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
}: QuartzComponentProps) => {
|
||||
const slug = simplifySlug(fileData.slug!)
|
||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={classNames(displayClass, "backlinks")}>
|
||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||
<ul class="overflow">
|
||||
<OverflowList id="backlinks-ul">
|
||||
{backlinkFiles.length > 0 ? (
|
||||
backlinkFiles.map((f) => (
|
||||
<li>
|
||||
@@ -27,10 +42,13 @@ const Backlinks: QuartzComponent = ({
|
||||
) : (
|
||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||
)}
|
||||
</ul>
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Backlinks.css = style
|
||||
export default (() => Backlinks) satisfies QuartzComponentConstructor
|
||||
Backlinks.css = style
|
||||
Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
|
||||
|
||||
return Backlinks
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
||||
@@ -28,7 +28,8 @@ export default ((opts: Options) => {
|
||||
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
|
||||
// check if comments should be displayed according to frontmatter
|
||||
const disableComment: boolean =
|
||||
!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false"
|
||||
typeof fileData.frontmatter?.comments !== "undefined" &&
|
||||
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
|
||||
if (disableComment) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDate, getDate } from "./Date"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
@@ -30,7 +30,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
const segments: (string | JSX.Element)[] = []
|
||||
|
||||
if (fileData.dates) {
|
||||
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
|
||||
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||
}
|
||||
|
||||
// Display reading time if enabled
|
||||
@@ -39,14 +39,12 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||
minutes: Math.ceil(minutes),
|
||||
})
|
||||
segments.push(displayedTime)
|
||||
segments.push(<span>{displayedTime}</span>)
|
||||
}
|
||||
|
||||
const segmentsElements = segments.map((segment) => <span>{segment}</span>)
|
||||
|
||||
return (
|
||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||
{segmentsElements}
|
||||
{segments}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||
}
|
||||
|
||||
export function Date({ date, locale }: Props) {
|
||||
return <>{formatDate(date, locale)}</>
|
||||
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import explorerStyle from "./styles/explorer.scss"
|
||||
import style from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { FileTrieNode } from "../util/fileTrie"
|
||||
import OverflowList from "./OverflowList"
|
||||
|
||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
||||
const defaultOptions = {
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
folderClickBehavior: "collapse",
|
||||
folderDefaultState: "collapsed",
|
||||
useSavedState: true,
|
||||
@@ -17,8 +29,8 @@ const defaultOptions = {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
if ((!a.file && !b.file) || (a.file && b.file)) {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||
@@ -27,74 +39,66 @@ const defaultOptions = {
|
||||
})
|
||||
}
|
||||
|
||||
if (a.file && !b.file) {
|
||||
if (!a.isFolder && b.isFolder) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
},
|
||||
filterFn: (node) => node.name !== "tags",
|
||||
filterFn: (node) => node.slugSegment !== "tags",
|
||||
order: ["filter", "map", "sort"],
|
||||
} satisfies Options
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
// Parse config
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
|
||||
// memoized
|
||||
let fileTree: FileNode
|
||||
let jsonTree: string
|
||||
let lastBuildId: string = ""
|
||||
|
||||
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||
// Construct tree from allFiles
|
||||
fileTree = new FileNode("")
|
||||
allFiles.forEach((file) => fileTree.add(file))
|
||||
|
||||
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
||||
if (opts.order) {
|
||||
// Order is important, use loop with index instead of order.map()
|
||||
for (let i = 0; i < opts.order.length; i++) {
|
||||
const functionName = opts.order[i]
|
||||
if (functionName === "map") {
|
||||
fileTree.map(opts.mapFn)
|
||||
} else if (functionName === "sort") {
|
||||
fileTree.sort(opts.sortFn)
|
||||
} else if (functionName === "filter") {
|
||||
fileTree.filter(opts.filterFn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all folders of tree. Initialize with collapsed state
|
||||
// Stringify to pass json tree as data attribute ([data-tree])
|
||||
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||
jsonTree = JSON.stringify(folders)
|
||||
}
|
||||
|
||||
const Explorer: QuartzComponent = ({
|
||||
ctx,
|
||||
cfg,
|
||||
allFiles,
|
||||
displayClass,
|
||||
fileData,
|
||||
}: QuartzComponentProps) => {
|
||||
if (ctx.buildId !== lastBuildId) {
|
||||
lastBuildId = ctx.buildId
|
||||
constructFileTree(allFiles)
|
||||
}
|
||||
|
||||
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||
return (
|
||||
<div class={classNames(displayClass, "explorer")}>
|
||||
<button
|
||||
type="button"
|
||||
id="explorer"
|
||||
<div
|
||||
class={classNames(displayClass, "explorer")}
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
data-data-fns={JSON.stringify({
|
||||
order: opts.order,
|
||||
sortFn: opts.sortFn.toString(),
|
||||
filterFn: opts.filterFn.toString(),
|
||||
mapFn: opts.mapFn.toString(),
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="mobile-explorer"
|
||||
class="collapsed hide-until-loaded explorer-toggle"
|
||||
data-mobile={true}
|
||||
aria-controls="explorer-content"
|
||||
aria-expanded={opts.folderDefaultState === "open"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="desktop-explorer"
|
||||
class="title-button explorer-toggle"
|
||||
data-mobile={false}
|
||||
aria-expanded={true}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
@@ -112,17 +116,47 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="explorer-content">
|
||||
<ul class="overflow" id="explorer-ul">
|
||||
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
|
||||
<li id="explorer-end" />
|
||||
</ul>
|
||||
<div id="explorer-content" aria-expanded={false}>
|
||||
<OverflowList id="explorer-ul" />
|
||||
</div>
|
||||
<template id="template-file">
|
||||
<li>
|
||||
<a href="#"></a>
|
||||
</li>
|
||||
</template>
|
||||
<template id="template-folder">
|
||||
<li>
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-outer">
|
||||
<ul class="content"></ul>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = explorerStyle
|
||||
Explorer.afterDOMLoaded = script
|
||||
Explorer.css = style
|
||||
Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// @ts-ignore
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import {
|
||||
joinSegments,
|
||||
resolveRelative,
|
||||
clone,
|
||||
simplifySlug,
|
||||
SimpleSlug,
|
||||
FilePath,
|
||||
} from "../util/path"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileNode, b: FileNode) => number
|
||||
filterFn: (node: FileNode) => boolean
|
||||
mapFn: (node: FileNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
type DataWrapper = {
|
||||
file: QuartzPluginData
|
||||
path: string[]
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
|
||||
if (!fp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return fp.split("/").at(idx)
|
||||
}
|
||||
|
||||
// Structure to add all files into a tree
|
||||
export class FileNode {
|
||||
children: Array<FileNode>
|
||||
name: string // this is the slug segment
|
||||
displayName: string
|
||||
file: QuartzPluginData | null
|
||||
depth: number
|
||||
|
||||
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
|
||||
this.children = []
|
||||
this.name = slugSegment
|
||||
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
|
||||
this.file = file ? clone(file) : null
|
||||
this.depth = depth ?? 0
|
||||
}
|
||||
|
||||
private insert(fileData: DataWrapper) {
|
||||
if (fileData.path.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSegment = fileData.path[0]
|
||||
|
||||
// base case, insert here
|
||||
if (fileData.path.length === 1) {
|
||||
if (nextSegment === "") {
|
||||
// index case (we are the root and we just found index.md), set our data appropriately
|
||||
const title = fileData.file.frontmatter?.title
|
||||
if (title && title !== "index") {
|
||||
this.displayName = title
|
||||
}
|
||||
} else {
|
||||
// direct child
|
||||
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// find the right child to insert into
|
||||
fileData.path = fileData.path.splice(1)
|
||||
const child = this.children.find((c) => c.name === nextSegment)
|
||||
if (child) {
|
||||
child.insert(fileData)
|
||||
return
|
||||
}
|
||||
|
||||
const newChild = new FileNode(
|
||||
nextSegment,
|
||||
getPathSegment(fileData.file.relativePath, this.depth),
|
||||
undefined,
|
||||
this.depth + 1,
|
||||
)
|
||||
newChild.insert(fileData)
|
||||
this.children.push(newChild)
|
||||
}
|
||||
|
||||
// Add new file to tree
|
||||
add(file: QuartzPluginData) {
|
||||
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||
* @param filterFn function to filter tree with
|
||||
*/
|
||||
filter(filterFn: (node: FileNode) => boolean) {
|
||||
this.children = this.children.filter(filterFn)
|
||||
this.children.forEach((child) => child.filter(filterFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
|
||||
* @param mapFn function to use for mapping over tree
|
||||
*/
|
||||
map(mapFn: (node: FileNode) => void) {
|
||||
mapFn(this)
|
||||
this.children.forEach((child) => child.map(mapFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder representation with state of tree.
|
||||
* Intended to only be called on root node before changes to the tree are made
|
||||
* @param collapsed default state of folders (collapsed by default or not)
|
||||
* @returns array containing folder state for tree
|
||||
*/
|
||||
getFolderPaths(collapsed: boolean): FolderState[] {
|
||||
const folderPaths: FolderState[] = []
|
||||
|
||||
const traverse = (node: FileNode, currentPath: string) => {
|
||||
if (!node.file) {
|
||||
const folderPath = joinSegments(currentPath, node.name)
|
||||
if (folderPath !== "") {
|
||||
folderPaths.push({ path: folderPath, collapsed })
|
||||
}
|
||||
|
||||
node.children.forEach((child) => traverse(child, folderPath))
|
||||
}
|
||||
}
|
||||
|
||||
traverse(this, "")
|
||||
return folderPaths
|
||||
}
|
||||
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
/**
|
||||
* Sorts tree according to sort/compare function
|
||||
* @param sortFn compare function used for `.sort()`, also used recursively for children
|
||||
*/
|
||||
sort(sortFn: (a: FileNode, b: FileNode) => number) {
|
||||
this.children = this.children.sort(sortFn)
|
||||
this.children.forEach((e) => e.sort(sortFn))
|
||||
}
|
||||
}
|
||||
|
||||
type ExplorerNodeProps = {
|
||||
node: FileNode
|
||||
opts: Options
|
||||
fileData: QuartzPluginData
|
||||
fullPath?: string
|
||||
}
|
||||
|
||||
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
|
||||
// Get options
|
||||
const folderBehavior = opts.folderClickBehavior
|
||||
const isDefaultOpen = opts.folderDefaultState === "open"
|
||||
|
||||
// Calculate current folderPath
|
||||
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
|
||||
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
|
||||
|
||||
return (
|
||||
<>
|
||||
{node.file ? (
|
||||
// Single file node
|
||||
<li key={node.file.slug}>
|
||||
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
|
||||
{node.displayName}
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
{node.name !== "" && (
|
||||
// Node with entire folder
|
||||
// Render svg button + folder name, then children
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
||||
<div key={node.name} data-folderpath={folderPath}>
|
||||
{folderBehavior === "link" ? (
|
||||
<a href={href} data-for={node.name} class="folder-title">
|
||||
{node.displayName}
|
||||
</a>
|
||||
) : (
|
||||
<button class="folder-button">
|
||||
<span class="folder-title">{node.displayName}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Recursively render children of folder */}
|
||||
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
|
||||
<ul
|
||||
// Inline style for left folder paddings
|
||||
style={{
|
||||
paddingLeft: node.name !== "" ? "1.4rem" : "0",
|
||||
}}
|
||||
class="content"
|
||||
data-folderul={folderPath}
|
||||
>
|
||||
{node.children.map((childNode, i) => (
|
||||
<ExplorerNode
|
||||
node={childNode}
|
||||
key={i}
|
||||
opts={opts}
|
||||
fullPath={folderPath}
|
||||
fileData={fileData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface D3Config {
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
enableRadial?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
@@ -39,6 +40,7 @@ const defaultOptions: GraphOptions = {
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
enableRadial: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
@@ -53,10 +55,11 @@ const defaultOptions: GraphOptions = {
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
enableRadial: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
export default ((opts?: Partial<GraphOptions>) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { i18n } from "../i18n"
|
||||
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { googleFontHref } from "../util/theme"
|
||||
import { getFontSpecificationName, googleFontHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import satori, { SatoriOptions } from "satori"
|
||||
import { loadEmoji, getIconCode } from "../util/emoji"
|
||||
import fs from "fs"
|
||||
import sharp from "sharp"
|
||||
import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og"
|
||||
@@ -24,7 +25,18 @@ async function generateSocialImage(
|
||||
// JSX that will be used to generate satori svg
|
||||
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
|
||||
|
||||
const svg = await satori(imageComponent, { width, height, fonts })
|
||||
const svg = await satori(imageComponent, {
|
||||
width,
|
||||
height,
|
||||
fonts,
|
||||
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
||||
if (languageCode === "emoji") {
|
||||
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}`
|
||||
}
|
||||
|
||||
return languageCode
|
||||
},
|
||||
})
|
||||
|
||||
// Convert svg directly to webp (with additional compression)
|
||||
const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer()
|
||||
@@ -65,7 +77,9 @@ export default (() => {
|
||||
|
||||
// Memoize google fonts
|
||||
if (!fontsPromise && cfg.generateSocialImages) {
|
||||
fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body)
|
||||
const headerFont = getFontSpecificationName(cfg.theme.typography.header)
|
||||
const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
|
||||
fontsPromise = getSatoriFont(headerFont, bodyFont)
|
||||
}
|
||||
|
||||
const slug = fileData.filePath
|
||||
@@ -98,7 +112,7 @@ export default (() => {
|
||||
|
||||
if (fileName) {
|
||||
// Generate social image (happens async)
|
||||
generateSocialImage(
|
||||
void generateSocialImage(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
@@ -115,7 +129,7 @@ export default (() => {
|
||||
}
|
||||
}
|
||||
|
||||
const { css, js } = externalResources
|
||||
const { css, js, additionalHead } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
@@ -165,6 +179,7 @@ export default (() => {
|
||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||
</>
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{/* OG/Twitter meta tags */}
|
||||
<meta name="og:site_name" content={cfg.pageTitle}></meta>
|
||||
@@ -181,8 +196,6 @@ export default (() => {
|
||||
<>
|
||||
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
||||
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
||||
<meta property="og:width" content={fullOptions.width.toString()} />
|
||||
<meta property="og:height" content={fullOptions.height.toString()} />
|
||||
</>
|
||||
)}
|
||||
<meta property="og:image:url" content={ogImagePath} />
|
||||
@@ -202,6 +215,13 @@ export default (() => {
|
||||
{js
|
||||
.filter((resource) => resource.loadTime === "beforeDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res, true))}
|
||||
{additionalHead.map((resource) => {
|
||||
if (typeof resource === "function") {
|
||||
return resource(fileData)
|
||||
} else {
|
||||
return resource
|
||||
}
|
||||
})}
|
||||
</head>
|
||||
)
|
||||
}
|
||||
|
||||
40
quartz/components/OverflowList.tsx
Normal file
40
quartz/components/OverflowList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { JSX } from "preact"
|
||||
|
||||
const OverflowList = ({
|
||||
children,
|
||||
...props
|
||||
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
||||
return (
|
||||
<ul class="overflow" {...props}>
|
||||
{children}
|
||||
<li class="overflow-end" />
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
OverflowList.afterDOMLoaded = (id: string) => `
|
||||
document.addEventListener("nav", (e) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const parentUl = entry.target.parentElement
|
||||
console.log(parentUl)
|
||||
if (entry.isIntersecting) {
|
||||
parentUl.classList.remove("gradient-active")
|
||||
} else {
|
||||
parentUl.classList.add("gradient-active")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ul = document.getElementById("${id}")
|
||||
if (!ul) return
|
||||
|
||||
const end = ul.querySelector(".overflow-end")
|
||||
if (!end) return
|
||||
|
||||
observer.observe(end)
|
||||
window.addCleanup(() => observer.disconnect())
|
||||
})
|
||||
`
|
||||
|
||||
export default OverflowList
|
||||
@@ -46,13 +46,9 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
|
||||
return (
|
||||
<li class="section-li">
|
||||
<div class="section">
|
||||
<div>
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
{page.dates && <Date date={getDate(cfg, page)!} locale={cfg.locale} />}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { classNames } from "../util/lang"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/toc.inline"
|
||||
import { i18n } from "../i18n"
|
||||
import OverflowList from "./OverflowList"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
@@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
|
||||
</svg>
|
||||
</button>
|
||||
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||
<ul class="overflow">
|
||||
<OverflowList id="toc-ul">
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
@@ -58,13 +59,13 @@ const TableOfContents: QuartzComponent = ({
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OverflowList>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = script
|
||||
TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ComponentChildren } from "preact"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
|
||||
const content = htmlToJsx(fileData.filePath!, tree)
|
||||
const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { ComponentChildren } from "preact"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
@@ -71,21 +72,22 @@ export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
})
|
||||
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||
const classes = cssClasses.join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
sort: options.sort,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content =
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article>{content}</article>
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { ComponentChildren } from "preact"
|
||||
|
||||
interface TagContentOptions {
|
||||
sort?: SortFn
|
||||
@@ -33,12 +34,13 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
|
||||
)
|
||||
|
||||
const content =
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||
const classes = cssClasses.join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
@@ -50,8 +52,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article>
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
@@ -93,7 +95,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={options.numPages} {...listProps} sort={opts?.sort} />
|
||||
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -110,11 +112,11 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article>{content}</article>
|
||||
<article class="popover-hint">{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
<PageList {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,16 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import HeaderConstructor from "./Header"
|
||||
import BodyConstructor from "./Body"
|
||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { clone } from "../util/clone"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
// @ts-ignore
|
||||
import mermaidScript from "./scripts/mermaid.inline"
|
||||
import mermaidStyle from "./styles/mermaid.inline.scss"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
@@ -23,12 +28,13 @@ interface RenderComponents {
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
export function pageResources(
|
||||
baseDir: FullSlug | RelativeURL,
|
||||
fileData: QuartzPluginData,
|
||||
staticResources: StaticResources,
|
||||
): StaticResources {
|
||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||
|
||||
return {
|
||||
const resources: StaticResources = {
|
||||
css: [
|
||||
{
|
||||
content: joinSegments(baseDir, "index.css"),
|
||||
@@ -48,14 +54,29 @@ export function pageResources(
|
||||
script: contentIndexScript,
|
||||
},
|
||||
...staticResources.js,
|
||||
{
|
||||
],
|
||||
additionalHead: staticResources.additionalHead,
|
||||
}
|
||||
|
||||
if (fileData.hasMermaidDiagram) {
|
||||
resources.js.push({
|
||||
script: mermaidScript,
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "inline",
|
||||
})
|
||||
resources.css.push({ content: mermaidStyle, inline: true })
|
||||
}
|
||||
|
||||
// NOTE: we have to put this last to make sure spa.inline.ts is the last item.
|
||||
resources.js.push({
|
||||
src: joinSegments(baseDir, "postscript.js"),
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "external",
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
|
||||
@@ -27,9 +27,10 @@ document.addEventListener("nav", () => {
|
||||
|
||||
// Darkmode toggle
|
||||
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
|
||||
if (themeButton) {
|
||||
themeButton.addEventListener("click", switchTheme)
|
||||
window.addCleanup(() => themeButton.removeEventListener("click", switchTheme))
|
||||
|
||||
}
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { FolderState } from "../ExplorerNode"
|
||||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { FullSlug, resolveRelative } from "../../util/path"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
let currentExplorerState: FolderState[]
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||
const explorerUl = document.getElementById("explorer-ul")
|
||||
if (!explorerUl) return
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
explorerUl.classList.add("no-background")
|
||||
} else {
|
||||
explorerUl.classList.remove("no-background")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
interface ParsedOptions {
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: "sort" | "filter" | "map"[]
|
||||
}
|
||||
|
||||
type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let currentExplorerState: Array<FolderState>
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
this.setAttribute(
|
||||
const explorers = document.querySelectorAll(".explorer")
|
||||
for (const explorer of explorers) {
|
||||
explorer.classList.toggle("collapsed")
|
||||
explorer.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as MaybeHTMLElement
|
||||
if (!content) return
|
||||
|
||||
content.classList.toggle("collapsed")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
@@ -32,104 +36,208 @@ function toggleFolder(evt: MouseEvent) {
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
const childFolderContainer = (
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
const folderContainer = (
|
||||
isSvg
|
||||
? target.parentElement?.nextSibling
|
||||
: target.parentElement?.parentElement?.nextElementSibling
|
||||
? // svg -> div.folder-container
|
||||
target.parentElement
|
||||
: // button.folder-button -> div -> div.folder-container
|
||||
target.parentElement?.parentElement
|
||||
) as MaybeHTMLElement
|
||||
const currentFolderParent = (
|
||||
isSvg ? target.nextElementSibling : target.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!(childFolderContainer && currentFolderParent)) return
|
||||
if (!folderContainer) return
|
||||
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
|
||||
if (!childFolderContainer) return
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, !isCollapsed)
|
||||
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
||||
|
||||
// Collapse folder container
|
||||
const isCollapsed = !childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, isCollapsed)
|
||||
|
||||
const currentFolderState = currentExplorerState.find(
|
||||
(item) => item.path === folderContainer.dataset.folderpath,
|
||||
)
|
||||
if (currentFolderState) {
|
||||
currentFolderState.collapsed = isCollapsed
|
||||
} else {
|
||||
currentExplorerState.push({
|
||||
path: folderContainer.dataset.folderpath as FullSlug,
|
||||
collapsed: isCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function setupExplorer() {
|
||||
const explorer = document.getElementById("explorer")
|
||||
if (!explorer) return
|
||||
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
|
||||
const template = document.getElementById("template-file") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.data?.slug!)
|
||||
a.dataset.for = node.data?.slug
|
||||
a.textContent = node.displayName
|
||||
return li
|
||||
}
|
||||
|
||||
if (explorer.dataset.behavior === "collapse") {
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
function createFolderNode(
|
||||
currentSlug: FullSlug,
|
||||
node: FileTrieNode,
|
||||
opts: ParsedOptions,
|
||||
): HTMLLIElement {
|
||||
const template = document.getElementById("template-folder") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const folderContainer = li.querySelector(".folder-container") as HTMLElement
|
||||
const titleContainer = folderContainer.querySelector("div") as HTMLElement
|
||||
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
|
||||
const ul = folderOuter.querySelector("ul") as HTMLUListElement
|
||||
|
||||
const folderPath = node.data?.slug!
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||
const a = document.createElement("a")
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = node.data?.slug
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
button.replaceWith(a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
}
|
||||
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
const isCollapsed =
|
||||
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
|
||||
opts.folderDefaultState === "collapsed"
|
||||
if (!isCollapsed) {
|
||||
folderOuter.classList.add("open")
|
||||
}
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
for (const child of node.children) {
|
||||
const childNode = child.data
|
||||
? createFileNode(currentSlug, child)
|
||||
: createFolderNode(currentSlug, child, opts)
|
||||
ul.appendChild(childNode)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
async function setupExplorer(currentSlug: FullSlug) {
|
||||
const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
|
||||
|
||||
for (const explorer of allExplorers) {
|
||||
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
|
||||
const opts: ParsedOptions = {
|
||||
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
|
||||
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
|
||||
useSavedState: explorer.dataset.savestate === "true",
|
||||
order: dataFns.order || ["filter", "map", "sort"],
|
||||
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
|
||||
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
|
||||
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||
const oldExplorerState: FolderState[] =
|
||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||
? JSON.parse(explorer.dataset.tree)
|
||||
: []
|
||||
currentExplorerState = []
|
||||
for (const { path, collapsed } of newExplorerState) {
|
||||
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
|
||||
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(
|
||||
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||
)
|
||||
|
||||
const data = await fetchData
|
||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||
const trie = FileTrieNode.fromEntries(entries)
|
||||
|
||||
// Apply functions in order
|
||||
for (const fn of opts.order) {
|
||||
switch (fn) {
|
||||
case "filter":
|
||||
if (opts.filterFn) trie.filter(opts.filterFn)
|
||||
break
|
||||
case "map":
|
||||
if (opts.mapFn) trie.map(opts.mapFn)
|
||||
break
|
||||
case "sort":
|
||||
if (opts.sortFn) trie.sort(opts.sortFn)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentExplorerState.map((folderState) => {
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='${folderState.path}']`,
|
||||
) as MaybeHTMLElement
|
||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||
if (folderUl) {
|
||||
setFolderState(folderUl, folderState.collapsed)
|
||||
// Get folder paths for state management
|
||||
const folderPaths = trie.getFolderPaths()
|
||||
currentExplorerState = folderPaths.map((path) => ({
|
||||
path,
|
||||
collapsed: oldIndex.get(path) === true,
|
||||
}))
|
||||
|
||||
const explorerUl = document.getElementById("explorer-ul")
|
||||
if (!explorerUl) continue
|
||||
|
||||
// Create and insert new content
|
||||
const fragment = document.createDocumentFragment()
|
||||
for (const child of trie.children) {
|
||||
const node = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
|
||||
fragment.appendChild(node)
|
||||
}
|
||||
explorerUl.insertBefore(fragment, explorerUl.firstChild)
|
||||
|
||||
// Set up event handlers
|
||||
const explorerButtons = explorer.querySelectorAll(
|
||||
"button.explorer-toggle",
|
||||
) as NodeListOf<HTMLElement>
|
||||
if (explorerButtons) {
|
||||
window.addCleanup(() =>
|
||||
explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
|
||||
)
|
||||
explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
|
||||
}
|
||||
|
||||
// Set up folder click handlers
|
||||
if (opts.folderClickBehavior === "collapse") {
|
||||
const folderButtons = explorer.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of folderButtons) {
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
|
||||
button.addEventListener("click", toggleFolder)
|
||||
}
|
||||
}
|
||||
|
||||
const folderIcons = explorer.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const icon of folderIcons) {
|
||||
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
|
||||
icon.addEventListener("click", toggleFolder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("resize", setupExplorer)
|
||||
document.addEventListener("nav", () => {
|
||||
setupExplorer()
|
||||
observer.disconnect()
|
||||
|
||||
// select pseudo element at end of list
|
||||
const lastItem = document.getElementById("explorer-end")
|
||||
if (lastItem) {
|
||||
observer.observe(lastItem)
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const mobileExplorer = document.querySelector("#mobile-explorer")
|
||||
if (mobileExplorer) {
|
||||
mobileExplorer.classList.add("collapsed")
|
||||
}
|
||||
await setupExplorer(currentSlug)
|
||||
|
||||
// Hide explorer on mobile until it is requested
|
||||
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
|
||||
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggles the state of a given folder
|
||||
* @param folderElement <div class="folder-outer"> Element of folder (parent)
|
||||
* @param collapsed if folder should be set to collapsed or not
|
||||
*/
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles visibility of a folder
|
||||
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
|
||||
* @param path path to folder (e.g. 'advanced/more/more2')
|
||||
*/
|
||||
function toggleCollapsedByPath(array: FolderState[], path: string) {
|
||||
const entry = array.find((item) => item.path === path)
|
||||
if (entry) {
|
||||
entry.collapsed = !entry.collapsed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
forceRadial,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
@@ -87,6 +88,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
enableRadial,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
@@ -161,15 +163,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
})),
|
||||
}
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
// Calculate the radius of the container circle
|
||||
const radius = Math.min(width, height) / 2 - 40 // 40px padding
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
if (enableRadial)
|
||||
simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
@@ -363,9 +370,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
const stage = app.stage
|
||||
stage.interactive = false
|
||||
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3 })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2 })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1 })
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
||||
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
||||
|
||||
for (const n of graphData.nodes) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { removeAllChildren } from "./util"
|
||||
import mermaid from "mermaid"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
@@ -144,6 +143,7 @@ const cssVars = [
|
||||
"--codeFont",
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
@@ -157,6 +157,12 @@ document.addEventListener("nav", async () => {
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
mermaidImport ||= await import(
|
||||
//@ts-ignore
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||
)
|
||||
const mermaid = mermaidImport.default
|
||||
|
||||
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
async function mouseEnterHandler(
|
||||
@@ -37,7 +38,7 @@ async function mouseEnterHandler(
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
|
||||
const response = await fetch(`${targetUrl}`).catch((err) => {
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import micromorph from "micromorph"
|
||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
// adapted from `micromorph`
|
||||
// https://github.com/natemoo-re/micromorph
|
||||
@@ -42,10 +43,24 @@ function notifyNav(url: FullSlug) {
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
function startLoading() {
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
if (!document.body.contains(loadingBar)) {
|
||||
document.body.appendChild(loadingBar)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
let p: DOMParser
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
startLoading()
|
||||
p = p || new DOMParser()
|
||||
const contents = await fetch(`${url}`)
|
||||
const contents = await fetchCanonical(url)
|
||||
.then((res) => {
|
||||
const contentType = res.headers.get("content-type")
|
||||
if (contentType?.startsWith("text/html")) {
|
||||
@@ -104,6 +119,7 @@ async function navigate(url: URL, isBack: boolean = false) {
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const bufferPx = 150
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
@@ -28,7 +27,6 @@ function toggleToc(this: HTMLElement) {
|
||||
function setupToc() {
|
||||
const toc = document.getElementById("toc")
|
||||
if (toc) {
|
||||
const collapsed = toc.classList.contains("collapsed")
|
||||
const content = toc.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
toc.addEventListener("click", toggleToc)
|
||||
|
||||
@@ -24,3 +24,23 @@ export function removeAllChildren(node: HTMLElement) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
|
||||
// containing the URL it's redirecting to.
|
||||
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
|
||||
// with a DOMParser effectively twice (here and later in the SPA code), even if
|
||||
// way less robust - we only care about our own generated redirects after all.
|
||||
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
|
||||
|
||||
export async function fetchCanonical(url: URL): Promise<Response> {
|
||||
const res = await fetch(`${url}`)
|
||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||
return res
|
||||
}
|
||||
|
||||
// reading the body can only be done once, so we need to clone the response
|
||||
// to allow the caller to read it if it's was not a redirect
|
||||
const text = await res.clone().text()
|
||||
const [_, redirect] = text.match(canonicalRegex) ?? []
|
||||
return redirect ? fetch(`${new URL(redirect, url)}`) : res
|
||||
}
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
.backlinks {
|
||||
flex-direction: column;
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(transparent 0px, var(--light));
|
||||
}*/
|
||||
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
@@ -31,14 +19,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .overflow {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
height: auto;
|
||||
@media all and not ($desktop) {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
color: var(--gray);
|
||||
|
||||
&[show-comma="true"] {
|
||||
> span:not(:last-child) {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -1,29 +1,71 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@media all and ($mobile) {
|
||||
.page > #quartz-body {
|
||||
// Shift page position when toggling Explorer on mobile.
|
||||
& > :not(.sidebar.left:has(.explorer)) {
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(100dvw);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
// Sticky top bar (stays in place when scrolling down on mobile).
|
||||
.sidebar.left:has(.explorer) {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
background-color: var(--light);
|
||||
}
|
||||
|
||||
// Hide Explorer on mobile until done loading.
|
||||
// Prevents ugly animation on page load.
|
||||
.hide-until-loaded ~ #explorer-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
|
||||
@media all and ($mobile) {
|
||||
order: -1;
|
||||
height: initial;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button#mobile-explorer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button#desktop-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
button#mobile-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button#desktop-explorer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(transparent 0px, var(--light));
|
||||
}*/
|
||||
}
|
||||
|
||||
button#explorer {
|
||||
button#mobile-explorer,
|
||||
button#desktop-explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
@@ -45,42 +87,22 @@ button#explorer {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.collapsed .fold {
|
||||
.explorer.collapsed > & .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#explorer-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0s;
|
||||
transition: max-height 0.35s ease;
|
||||
margin-top: 0.5rem;
|
||||
visibility: visible;
|
||||
|
||||
&.collapsed {
|
||||
max-height: 0;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
visibility 0s linear 0.35s;
|
||||
visibility: hidden;
|
||||
.explorer.collapsed > & {
|
||||
max-height: 0px;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
|
||||
& ul {
|
||||
@@ -91,15 +113,34 @@ button#explorer {
|
||||
max-height 0.35s ease,
|
||||
transform 0.35s ease,
|
||||
opacity 0.2s ease;
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
> #explorer-ul {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
margin-left: 6px;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 1px solid var(--lightgray);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -169,13 +210,74 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.no-background::after {
|
||||
background: none !important;
|
||||
.explorer {
|
||||
@media all and ($mobile) {
|
||||
#explorer-content {
|
||||
box-sizing: border-box;
|
||||
overscroll-behavior: none;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background-color: var(--light);
|
||||
max-width: 100dvw;
|
||||
left: -100dvw;
|
||||
width: 100%;
|
||||
transition: transform 300ms ease-in-out;
|
||||
overflow: hidden;
|
||||
padding: $topSpacing 2rem 2rem;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin-top: 0;
|
||||
visibility: hidden;
|
||||
|
||||
&:not(.collapsed) {
|
||||
transform: translateX(100dvw);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-explorer {
|
||||
margin: 5px;
|
||||
z-index: 101;
|
||||
|
||||
&:not(.collapsed) .lucide-menu {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.lucide-menu {
|
||||
stroke: var(--darkgray);
|
||||
transition: transform 200ms ease;
|
||||
|
||||
&:hover {
|
||||
stroke: var(--dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#explorer-end {
|
||||
// needs height so IntersectionObserver gets triggered
|
||||
height: 4px;
|
||||
// remove default margin from li
|
||||
margin: 0;
|
||||
.no-scroll {
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html:has(.no-scroll) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
.no-scroll {
|
||||
opacity: 1 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html:has(.no-scroll) {
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
flex: 0 0 min(30%, 450px);
|
||||
}
|
||||
|
||||
@media all and not ($tablet) {
|
||||
@media all and not ($mobile) {
|
||||
&[data-preview] {
|
||||
& .result-card > p.preview {
|
||||
display: none;
|
||||
@@ -132,7 +132,7 @@
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media all and ($tablet) {
|
||||
@media all and ($mobile) {
|
||||
& > #preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -151,6 +151,7 @@
|
||||
}
|
||||
|
||||
& > #preview-container {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
|
||||
@@ -61,10 +61,6 @@ button#toc {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.collapsed > .overflow::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0;
|
||||
@@ -80,10 +76,6 @@ button#toc {
|
||||
}
|
||||
}
|
||||
}
|
||||
> ul.overflow {
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
& .depth-#{$i} {
|
||||
|
||||
@@ -14,6 +14,7 @@ import uk from "./locales/uk-UA"
|
||||
import ru from "./locales/ru-RU"
|
||||
import ko from "./locales/ko-KR"
|
||||
import zh from "./locales/zh-CN"
|
||||
import zhTw from "./locales/zh-TW"
|
||||
import vi from "./locales/vi-VN"
|
||||
import pt from "./locales/pt-BR"
|
||||
import hu from "./locales/hu-HU"
|
||||
@@ -21,6 +22,10 @@ import fa from "./locales/fa-IR"
|
||||
import pl from "./locales/pl-PL"
|
||||
import cs from "./locales/cs-CZ"
|
||||
import tr from "./locales/tr-TR"
|
||||
import th from "./locales/th-TH"
|
||||
import lt from "./locales/lt-LT"
|
||||
import fi from "./locales/fi-FI"
|
||||
import no from "./locales/nb-NO"
|
||||
|
||||
export const TRANSLATIONS = {
|
||||
"en-US": enUs,
|
||||
@@ -59,6 +64,7 @@ export const TRANSLATIONS = {
|
||||
"ru-RU": ru,
|
||||
"ko-KR": ko,
|
||||
"zh-CN": zh,
|
||||
"zh-TW": zhTw,
|
||||
"vi-VN": vi,
|
||||
"pt-BR": pt,
|
||||
"hu-HU": hu,
|
||||
@@ -66,6 +72,10 @@ export const TRANSLATIONS = {
|
||||
"pl-PL": pl,
|
||||
"cs-CZ": cs,
|
||||
"tr-TR": tr,
|
||||
"th-TH": th,
|
||||
"lt-LT": lt,
|
||||
"fi-FI": fi,
|
||||
"nb-NO": no,
|
||||
} as const
|
||||
|
||||
export const defaultTranslation = "en-US"
|
||||
|
||||
84
quartz/i18n/locales/fi-FI.ts
Normal file
84
quartz/i18n/locales/fi-FI.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Nimetön",
|
||||
description: "Ei kuvausta saatavilla",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Merkintä",
|
||||
abstract: "Tiivistelmä",
|
||||
info: "Info",
|
||||
todo: "Tehtävälista",
|
||||
tip: "Vinkki",
|
||||
success: "Onnistuminen",
|
||||
question: "Kysymys",
|
||||
warning: "Varoitus",
|
||||
failure: "Epäonnistuminen",
|
||||
danger: "Vaara",
|
||||
bug: "Virhe",
|
||||
example: "Esimerkki",
|
||||
quote: "Lainaus",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Takalinkit",
|
||||
noBacklinksFound: "Takalinkkejä ei löytynyt",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Vaalea tila",
|
||||
darkMode: "Tumma tila",
|
||||
},
|
||||
explorer: {
|
||||
title: "Selain",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Luotu käyttäen",
|
||||
},
|
||||
graph: {
|
||||
title: "Verkkonäkymä",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Viimeisimmät muistiinpanot",
|
||||
seeRemainingMore: ({ remaining }) => `Näytä ${remaining} lisää →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Upote kohteesta ${targetSlug}`,
|
||||
linkToOriginal: "Linkki alkuperäiseen",
|
||||
},
|
||||
search: {
|
||||
title: "Haku",
|
||||
searchBarPlaceholder: "Hae jotain",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Sisällysluettelo",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min lukuaika`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Viimeisimmät muistiinpanot",
|
||||
lastFewNotes: ({ count }) => `Viimeiset ${count} muistiinpanoa`,
|
||||
},
|
||||
error: {
|
||||
title: "Ei löytynyt",
|
||||
notFound: "Tämä sivu on joko yksityinen tai sitä ei ole olemassa.",
|
||||
home: "Palaa etusivulle",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Kansio",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 kohde tässä kansiossa." : `${count} kohdetta tässä kansiossa.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tunniste",
|
||||
tagIndex: "Tunnisteluettelo",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 kohde tällä tunnisteella." : `${count} kohdetta tällä tunnisteella.`,
|
||||
showingFirst: ({ count }) => `Näytetään ensimmäiset ${count} tunnistetta.`,
|
||||
totalTags: ({ count }) => `Löytyi yhteensä ${count} tunnistetta.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
104
quartz/i18n/locales/lt-LT.ts
Normal file
104
quartz/i18n/locales/lt-LT.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Be Pavadinimo",
|
||||
description: "Aprašymas Nepateiktas",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Pastaba",
|
||||
abstract: "Santrauka",
|
||||
info: "Informacija",
|
||||
todo: "Darbų sąrašas",
|
||||
tip: "Patarimas",
|
||||
success: "Sėkmingas",
|
||||
question: "Klausimas",
|
||||
warning: "Įspėjimas",
|
||||
failure: "Nesėkmingas",
|
||||
danger: "Pavojus",
|
||||
bug: "Klaida",
|
||||
example: "Pavyzdys",
|
||||
quote: "Citata",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Atgalinės Nuorodos",
|
||||
noBacklinksFound: "Atgalinių Nuorodų Nerasta",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Šviesus Režimas",
|
||||
darkMode: "Tamsus Režimas",
|
||||
},
|
||||
explorer: {
|
||||
title: "Naršyklė",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Sukurta Su",
|
||||
},
|
||||
graph: {
|
||||
title: "Grafiko Vaizdas",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Naujausi Užrašai",
|
||||
seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining} →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`,
|
||||
linkToOriginal: "Nuoroda į originalą",
|
||||
},
|
||||
search: {
|
||||
title: "Paieška",
|
||||
searchBarPlaceholder: "Ieškoti",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Turinys",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Naujausi užrašai",
|
||||
lastFewNotes: ({ count }) =>
|
||||
count === 1
|
||||
? "Paskutinis 1 užrašas"
|
||||
: count < 10
|
||||
? `Paskutiniai ${count} užrašai`
|
||||
: `Paskutiniai ${count} užrašų`,
|
||||
},
|
||||
error: {
|
||||
title: "Nerasta",
|
||||
notFound:
|
||||
"Arba šis puslapis yra pasiekiamas tik tam tikriems vartotojams, arba tokio puslapio nėra.",
|
||||
home: "Grįžti į pagrindinį puslapį",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Aplankas",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1
|
||||
? "1 elementas šiame aplanke."
|
||||
: count < 10
|
||||
? `${count} elementai šiame aplanke.`
|
||||
: `${count} elementų šiame aplanke.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Žyma",
|
||||
tagIndex: "Žymų indeksas",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1
|
||||
? "1 elementas su šia žyma."
|
||||
: count < 10
|
||||
? `${count} elementai su šia žyma.`
|
||||
: `${count} elementų su šia žyma.`,
|
||||
showingFirst: ({ count }) =>
|
||||
count < 10 ? `Rodomos pirmosios ${count} žymos.` : `Rodomos pirmosios ${count} žymų.`,
|
||||
totalTags: ({ count }) =>
|
||||
count === 1
|
||||
? "Rasta iš viso 1 žyma."
|
||||
: count < 10
|
||||
? `Rasta iš viso ${count} žymos.`
|
||||
: `Rasta iš viso ${count} žymų.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
84
quartz/i18n/locales/nb-NO.ts
Normal file
84
quartz/i18n/locales/nb-NO.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Uten navn",
|
||||
description: "Ingen beskrivelse angitt",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Notis",
|
||||
abstract: "Abstrakt",
|
||||
info: "Info",
|
||||
todo: "Husk på",
|
||||
tip: "Tips",
|
||||
success: "Suksess",
|
||||
question: "Spørsmål",
|
||||
warning: "Advarsel",
|
||||
failure: "Feil",
|
||||
danger: "Farlig",
|
||||
bug: "Bug",
|
||||
example: "Eksempel",
|
||||
quote: "Sitat",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Tilbakekoblinger",
|
||||
noBacklinksFound: "Ingen tilbakekoblinger funnet",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Lys modus",
|
||||
darkMode: "Mørk modus",
|
||||
},
|
||||
explorer: {
|
||||
title: "Utforsker",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Laget med",
|
||||
},
|
||||
graph: {
|
||||
title: "Graf-visning",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Nylige notater",
|
||||
seeRemainingMore: ({ remaining }) => `Se ${remaining} til →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transkludering of ${targetSlug}`,
|
||||
linkToOriginal: "Lenke til original",
|
||||
},
|
||||
search: {
|
||||
title: "Søk",
|
||||
searchBarPlaceholder: "Søk etter noe",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Oversikt",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} min lesning`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Nylige notat",
|
||||
lastFewNotes: ({ count }) => `Siste ${count} notat`,
|
||||
},
|
||||
error: {
|
||||
title: "Ikke funnet",
|
||||
notFound: "Enten er denne siden privat eller så finnes den ikke.",
|
||||
home: "Returner til hovedsiden",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Mappe",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 gjenstand i denne mappen." : `${count} gjenstander i denne mappen.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tagg",
|
||||
tagIndex: "Tagg Indeks",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 gjenstand med denne taggen." : `${count} gjenstander med denne taggen.`,
|
||||
showingFirst: ({ count }) => `Viser første ${count} tagger.`,
|
||||
totalTags: ({ count }) => `Fant totalt ${count} tagger.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
82
quartz/i18n/locales/th-TH.ts
Normal file
82
quartz/i18n/locales/th-TH.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "ไม่มีชื่อ",
|
||||
description: "ไม่ได้ระบุคำอธิบายย่อ",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "หมายเหตุ",
|
||||
abstract: "บทคัดย่อ",
|
||||
info: "ข้อมูล",
|
||||
todo: "ต้องทำเพิ่มเติม",
|
||||
tip: "คำแนะนำ",
|
||||
success: "เรียบร้อย",
|
||||
question: "คำถาม",
|
||||
warning: "คำเตือน",
|
||||
failure: "ข้อผิดพลาด",
|
||||
danger: "อันตราย",
|
||||
bug: "บั๊ก",
|
||||
example: "ตัวอย่าง",
|
||||
quote: "คำพูกยกมา",
|
||||
},
|
||||
backlinks: {
|
||||
title: "หน้าที่กล่าวถึง",
|
||||
noBacklinksFound: "ไม่มีหน้าที่โยงมาหน้านี้",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "โหมดสว่าง",
|
||||
darkMode: "โหมดมืด",
|
||||
},
|
||||
explorer: {
|
||||
title: "รายการหน้า",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "สร้างด้วย",
|
||||
},
|
||||
graph: {
|
||||
title: "มุมมองกราฟ",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "บันทึกล่าสุด",
|
||||
seeRemainingMore: ({ remaining }) => `ดูเพิ่มอีก ${remaining} รายการ →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `รวมข้ามเนื้อหาจาก ${targetSlug}`,
|
||||
linkToOriginal: "ดูหน้าต้นทาง",
|
||||
},
|
||||
search: {
|
||||
title: "ค้นหา",
|
||||
searchBarPlaceholder: "ค้นหาบางอย่าง",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "สารบัญ",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "บันทึกล่าสุด",
|
||||
lastFewNotes: ({ count }) => `${count} บันทึกล่าสุด`,
|
||||
},
|
||||
error: {
|
||||
title: "ไม่มีหน้านี้",
|
||||
notFound: "หน้านี้อาจตั้งค่าเป็นส่วนตัวหรือยังไม่ถูกสร้าง",
|
||||
home: "กลับหน้าหลัก",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "โฟลเดอร์",
|
||||
itemsUnderFolder: ({ count }) => `มี ${count} รายการในโฟลเดอร์นี้`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "แท็ก",
|
||||
tagIndex: "แท็กทั้งหมด",
|
||||
itemsUnderTag: ({ count }) => `มี ${count} รายการในแท็กนี้`,
|
||||
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
|
||||
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
82
quartz/i18n/locales/zh-TW.ts
Normal file
82
quartz/i18n/locales/zh-TW.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "無題",
|
||||
description: "無描述",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "筆記",
|
||||
abstract: "摘要",
|
||||
info: "提示",
|
||||
todo: "待辦",
|
||||
tip: "提示",
|
||||
success: "成功",
|
||||
question: "問題",
|
||||
warning: "警告",
|
||||
failure: "失敗",
|
||||
danger: "危險",
|
||||
bug: "錯誤",
|
||||
example: "範例",
|
||||
quote: "引用",
|
||||
},
|
||||
backlinks: {
|
||||
title: "反向連結",
|
||||
noBacklinksFound: "無法找到反向連結",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "亮色模式",
|
||||
darkMode: "暗色模式",
|
||||
},
|
||||
explorer: {
|
||||
title: "探索",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Created with",
|
||||
},
|
||||
graph: {
|
||||
title: "關係圖譜",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "最近的筆記",
|
||||
seeRemainingMore: ({ remaining }) => `查看更多 ${remaining} 篇筆記 →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `包含 ${targetSlug}`,
|
||||
linkToOriginal: "指向原始筆記的連結",
|
||||
},
|
||||
search: {
|
||||
title: "搜尋",
|
||||
searchBarPlaceholder: "搜尋些什麼",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "目錄",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "最近的筆記",
|
||||
lastFewNotes: ({ count }) => `最近的 ${count} 條筆記`,
|
||||
},
|
||||
error: {
|
||||
title: "無法找到",
|
||||
notFound: "私人筆記或筆記不存在。",
|
||||
home: "返回首頁",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "資料夾",
|
||||
itemsUnderFolder: ({ count }) => `此資料夾下有 ${count} 條筆記。`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "標籤",
|
||||
tagIndex: "標籤索引",
|
||||
itemsUnderTag: ({ count }) => `此標籤下有 ${count} 條筆記。`,
|
||||
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
|
||||
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
@@ -37,7 +37,6 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const externalResources = pageResources(path, resources)
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
@@ -45,6 +44,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
const externalResources = pageResources(path, vfile.data, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: vfile.data,
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { FilePath, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import path from "path"
|
||||
import { write } from "./helpers"
|
||||
import DepGraph from "../../depgraph"
|
||||
import { getAliasSlugs } from "../transformers/frontmatter"
|
||||
|
||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
name: "AliasRedirects",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async getDependencyGraph(ctx, content, _resources) {
|
||||
const graph = new DepGraph<FilePath>()
|
||||
|
||||
const { argv } = ctx
|
||||
for (const [_tree, file] of content) {
|
||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||
const aliases = file.data.frontmatter?.aliases ?? []
|
||||
const slugs = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||
const permalink = file.data.frontmatter?.permalink
|
||||
if (typeof permalink === "string") {
|
||||
slugs.push(permalink as FullSlug)
|
||||
}
|
||||
|
||||
for (let slug of slugs) {
|
||||
// fix any slugs that have trailing slash
|
||||
if (slug.endsWith("/")) {
|
||||
slug = joinSegments(slug, "index") as FullSlug
|
||||
}
|
||||
|
||||
for (const slug of getAliasSlugs(file.data.frontmatter?.aliases ?? [], argv, file)) {
|
||||
graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath)
|
||||
}
|
||||
}
|
||||
@@ -35,25 +19,12 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
return graph
|
||||
},
|
||||
async emit(ctx, content, _resources): Promise<FilePath[]> {
|
||||
const { argv } = ctx
|
||||
const fps: FilePath[] = []
|
||||
|
||||
for (const [_tree, file] of content) {
|
||||
const ogSlug = simplifySlug(file.data.slug!)
|
||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||
const aliases = file.data.frontmatter?.aliases ?? []
|
||||
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||
const permalink = file.data.frontmatter?.permalink
|
||||
if (typeof permalink === "string") {
|
||||
slugs.push(permalink as FullSlug)
|
||||
}
|
||||
|
||||
for (let slug of slugs) {
|
||||
// fix any slugs that have trailing slash
|
||||
if (slug.endsWith("/")) {
|
||||
slug = joinSegments(slug, "index") as FullSlug
|
||||
}
|
||||
|
||||
for (const slug of file.data.aliases ?? []) {
|
||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
|
||||
@@ -15,9 +15,6 @@ const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
|
||||
export const Assets: QuartzEmitterPlugin = () => {
|
||||
return {
|
||||
name: "Assets",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async getDependencyGraph(ctx, _content, _resources) {
|
||||
const { argv, cfg } = ctx
|
||||
const graph = new DepGraph<FilePath>()
|
||||
|
||||
@@ -11,9 +11,6 @@ export function extractDomainFromBaseUrl(baseUrl: string) {
|
||||
|
||||
export const CNAME: QuartzEmitterPlugin = () => ({
|
||||
name: "CNAME",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async getDependencyGraph(_ctx, _content, _resources) {
|
||||
return new DepGraph<FilePath>()
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ type ComponentResources = {
|
||||
function getComponentResources(ctx: BuildCtx): ComponentResources {
|
||||
const allComponents: Set<QuartzComponent> = new Set()
|
||||
for (const emitter of ctx.cfg.plugins.emitters) {
|
||||
const components = emitter.getQuartzComponents(ctx)
|
||||
const components = emitter.getQuartzComponents?.(ctx) ?? []
|
||||
for (const component of components) {
|
||||
allComponents.add(component)
|
||||
}
|
||||
@@ -116,9 +116,13 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
const umamiScript = document.createElement("script")
|
||||
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"
|
||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||
umamiScript.setAttribute("data-auto-track", "false")
|
||||
umamiScript.async = true
|
||||
|
||||
document.head.appendChild(umamiScript)
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
umami.track();
|
||||
})
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "goatcounter") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
@@ -128,21 +132,37 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
goatcounterScript.setAttribute("data-goatcounter",
|
||||
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
|
||||
document.head.appendChild(goatcounterScript)
|
||||
|
||||
window.goatcounter = { no_onload: true }
|
||||
document.addEventListener("nav", () => {
|
||||
goatcounter.count({ path: location.pathname })
|
||||
})
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "posthog") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const posthogScript = document.createElement("script")
|
||||
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('${cfg.analytics.apiKey}',{api_host:'${cfg.analytics.host ?? "https://app.posthog.com"}'})\`
|
||||
posthog.init('${cfg.analytics.apiKey}', {
|
||||
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
|
||||
capture_pageview: false,
|
||||
})\`
|
||||
document.head.appendChild(posthogScript)
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
posthog.capture('$pageview', { path: location.pathname })
|
||||
})
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "tinylytics") {
|
||||
const siteId = cfg.analytics.siteId
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const tinylyticsScript = document.createElement("script")
|
||||
tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js"
|
||||
tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js?spa"
|
||||
tinylyticsScript.defer = true
|
||||
document.head.appendChild(tinylyticsScript)
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
window.tinylytics.triggerUpdate()
|
||||
})
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "cabin") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
@@ -180,9 +200,6 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
export const ComponentResources: QuartzEmitterPlugin = () => {
|
||||
return {
|
||||
name: "ComponentResources",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async getDependencyGraph(_ctx, _content, _resources) {
|
||||
return new DepGraph<FilePath>()
|
||||
},
|
||||
|
||||
@@ -9,8 +9,9 @@ import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
import DepGraph from "../../depgraph"
|
||||
|
||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||
export type ContentDetails = {
|
||||
slug: FullSlug
|
||||
title: string
|
||||
links: SimpleSlug[]
|
||||
tags: string[]
|
||||
@@ -25,6 +26,7 @@ interface Options {
|
||||
enableRSS: boolean
|
||||
rssLimit?: number
|
||||
rssFullHtml: boolean
|
||||
rssSlug: string
|
||||
includeEmptyFiles: boolean
|
||||
}
|
||||
|
||||
@@ -33,10 +35,11 @@ const defaultOptions: Options = {
|
||||
enableRSS: true,
|
||||
rssLimit: 10,
|
||||
rssFullHtml: false,
|
||||
rssSlug: "index",
|
||||
includeEmptyFiles: true,
|
||||
}
|
||||
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||
@@ -48,7 +51,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||
}
|
||||
|
||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||
@@ -116,12 +119,13 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
async emit(ctx, content, _resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const emitted: FilePath[] = []
|
||||
const linkIndex: ContentIndex = new Map()
|
||||
const linkIndex: ContentIndexMap = new Map()
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||
linkIndex.set(slug, {
|
||||
slug,
|
||||
title: file.data.frontmatter?.title!,
|
||||
links: file.data.links ?? [],
|
||||
tags: file.data.frontmatter?.tags ?? [],
|
||||
@@ -151,7 +155,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
await write({
|
||||
ctx,
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: "index" as FullSlug,
|
||||
slug: (opts?.rssSlug ?? "index") as FullSlug,
|
||||
ext: ".xml",
|
||||
}),
|
||||
)
|
||||
@@ -180,6 +184,19 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
|
||||
return emitted
|
||||
},
|
||||
getQuartzComponents: () => [],
|
||||
externalResources: (ctx) => {
|
||||
if (opts?.enableRSS) {
|
||||
return {
|
||||
additionalHead: [
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="RSS Feed"
|
||||
href={`https://${ctx.cfg.configuration.baseUrl}/index.xml`}
|
||||
/>,
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
containsIndex = true
|
||||
}
|
||||
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: file.data,
|
||||
@@ -131,7 +131,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
if (!containsIndex && !ctx.argv.fastRebuild) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. 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.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
|
||||
|
||||
for (const folder of folders) {
|
||||
const slug = joinSegments(folder, "index") as FullSlug
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const [tree, file] = folderDescriptions[folder]
|
||||
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: file.data,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { ContentPage } from "./contentPage"
|
||||
export { TagPage } from "./tagPage"
|
||||
export { FolderPage } from "./folderPage"
|
||||
export { ContentIndex } from "./contentIndex"
|
||||
export { ContentIndex as ContentIndex } from "./contentIndex"
|
||||
export { AliasRedirects } from "./aliases"
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
|
||||
@@ -6,9 +6,6 @@ import DepGraph from "../../depgraph"
|
||||
|
||||
export const Static: QuartzEmitterPlugin = () => ({
|
||||
name: "Static",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async getDependencyGraph({ argv, cfg }, _content, _resources) {
|
||||
const graph = new DepGraph<FilePath>()
|
||||
|
||||
|
||||
@@ -105,14 +105,17 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
|
||||
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(cfg.locale).pages.tagContent.tag}: ${tag}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const slug = joinSegments("tags", tag) as FullSlug
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const [tree, file] = tagDescriptions[tag]
|
||||
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: file.data,
|
||||
|
||||
@@ -6,9 +6,10 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||
const staticResources: StaticResources = {
|
||||
css: [],
|
||||
js: [],
|
||||
additionalHead: [],
|
||||
}
|
||||
|
||||
for (const transformer of ctx.cfg.plugins.transformers) {
|
||||
for (const transformer of [...ctx.cfg.plugins.transformers, ...ctx.cfg.plugins.emitters]) {
|
||||
const res = transformer.externalResources ? transformer.externalResources(ctx) : {}
|
||||
if (res?.js) {
|
||||
staticResources.js.push(...res.js)
|
||||
@@ -16,6 +17,9 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||
if (res?.css) {
|
||||
staticResources.css.push(...res.css)
|
||||
}
|
||||
if (res?.additionalHead) {
|
||||
staticResources.additionalHead.push(...res.additionalHead)
|
||||
}
|
||||
}
|
||||
|
||||
// if serving locally, listen for rebuilds and reload the page
|
||||
|
||||
@@ -3,9 +3,12 @@ import remarkFrontmatter from "remark-frontmatter"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import yaml from "js-yaml"
|
||||
import toml from "toml"
|
||||
import { slugTag } from "../../util/path"
|
||||
import { FilePath, FullSlug, joinSegments, slugifyFilePath, slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { Argv } from "../../util/ctx"
|
||||
import { VFile } from "vfile"
|
||||
import path from "path"
|
||||
|
||||
export interface Options {
|
||||
delimiters: string | [string, string]
|
||||
@@ -40,16 +43,32 @@ function coerceToArray(input: string | string[]): string[] | undefined {
|
||||
.map((tag: string | number) => tag.toString())
|
||||
}
|
||||
|
||||
export function getAliasSlugs(aliases: string[], argv: Argv, file: VFile): FullSlug[] {
|
||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||
const slugs: FullSlug[] = aliases.map(
|
||||
(alias) => path.posix.join(dir, slugifyFilePath(alias as FilePath)) as FullSlug,
|
||||
)
|
||||
const permalink = file.data.frontmatter?.permalink
|
||||
if (typeof permalink === "string") {
|
||||
slugs.push(permalink as FullSlug)
|
||||
}
|
||||
// fix any slugs that have trailing slash
|
||||
return slugs.map((slug) =>
|
||||
slug.endsWith("/") ? (joinSegments(slug, "index") as FullSlug) : slug,
|
||||
)
|
||||
}
|
||||
|
||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "FrontMatter",
|
||||
markdownPlugins({ cfg }) {
|
||||
markdownPlugins({ cfg, allSlugs, argv }) {
|
||||
return [
|
||||
[remarkFrontmatter, ["yaml", "toml"]],
|
||||
() => {
|
||||
return (_, file) => {
|
||||
const { data } = matter(Buffer.from(file.value), {
|
||||
const fileData = Buffer.from(file.value as Uint8Array)
|
||||
const { data } = matter(fileData, {
|
||||
...opts,
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||
@@ -67,12 +86,28 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
|
||||
|
||||
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
||||
if (aliases) data.aliases = aliases
|
||||
if (aliases) {
|
||||
data.aliases = aliases // frontmatter
|
||||
const slugs = (file.data.aliases = getAliasSlugs(aliases, argv, file))
|
||||
allSlugs.push(...slugs)
|
||||
}
|
||||
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
||||
if (cssclasses) data.cssclasses = cssclasses
|
||||
|
||||
const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"])
|
||||
|
||||
const created = coalesceAliases(data, ["created", "date"])
|
||||
if (created) data.created = created
|
||||
const modified = coalesceAliases(data, [
|
||||
"modified",
|
||||
"lastmod",
|
||||
"updated",
|
||||
"last-modified",
|
||||
])
|
||||
if (modified) data.modified = modified
|
||||
const published = coalesceAliases(data, ["published", "publishDate", "date"])
|
||||
if (published) data.published = published
|
||||
|
||||
if (socialImage) data.socialImage = socialImage
|
||||
|
||||
// fill in frontmatter
|
||||
@@ -86,11 +121,15 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
aliases: FullSlug[]
|
||||
frontmatter: { [key: string]: unknown } & {
|
||||
title: string
|
||||
} & Partial<{
|
||||
tags: string[]
|
||||
aliases: string[]
|
||||
modified: string
|
||||
created: string
|
||||
published: string
|
||||
description: string
|
||||
publish: boolean | string
|
||||
draft: boolean | string
|
||||
|
||||
@@ -48,11 +48,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
||||
created ||= st.birthtimeMs
|
||||
modified ||= st.mtimeMs
|
||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||
created ||= file.data.frontmatter.date as MaybeDate
|
||||
modified ||= file.data.frontmatter.lastmod as MaybeDate
|
||||
modified ||= file.data.frontmatter.updated as MaybeDate
|
||||
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
|
||||
published ||= file.data.frontmatter.publishDate as MaybeDate
|
||||
created ||= file.data.frontmatter.created as MaybeDate
|
||||
modified ||= file.data.frontmatter.modified as MaybeDate
|
||||
published ||= file.data.frontmatter.published as MaybeDate
|
||||
} else if (source === "git") {
|
||||
if (!repo) {
|
||||
// Get a reference to the main git repo.
|
||||
|
||||
@@ -59,8 +59,6 @@ export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
||||
},
|
||||
],
|
||||
}
|
||||
default:
|
||||
return { css: [], js: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
|
||||
import {
|
||||
Root,
|
||||
Html,
|
||||
BlockContent,
|
||||
PhrasingContent,
|
||||
DefinitionContent,
|
||||
Paragraph,
|
||||
Code,
|
||||
} from "mdast"
|
||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
@@ -11,13 +19,9 @@ import { JSResource, CSSResource } from "../../util/resources"
|
||||
import calloutScript from "../../components/scripts/callout.inline.ts"
|
||||
// @ts-ignore
|
||||
import checkboxScript from "../../components/scripts/checkbox.inline.ts"
|
||||
// @ts-ignore
|
||||
import mermaidExtensionScript from "../../components/scripts/mermaid.inline.ts"
|
||||
import mermaidStyle from "../../components/styles/mermaid.inline.scss"
|
||||
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
||||
import { toHast } from "mdast-util-to-hast"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
||||
import { capitalize } from "../../util/lang"
|
||||
import { PluggableList } from "unified"
|
||||
|
||||
@@ -124,12 +128,12 @@ const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
|
||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
|
||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm)
|
||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
||||
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
|
||||
// #(...) -> capturing group, tag itself must start with #
|
||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||
const tagRegex = new RegExp(
|
||||
/(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
||||
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
||||
)
|
||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
|
||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||
@@ -152,19 +156,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
textTransform(_ctx, src) {
|
||||
// do comments at text level
|
||||
if (opts.comments) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(commentRegex, "")
|
||||
}
|
||||
|
||||
// pre-transform blockquotes
|
||||
if (opts.callouts) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(calloutLineRegex, (value) => {
|
||||
// force newline after title of callout
|
||||
return value + "\n> "
|
||||
@@ -173,10 +169,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
|
||||
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
||||
if (opts.wikilinks) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
// replace all wikilinks inside a table first
|
||||
src = src.replace(tableRegex, (value) => {
|
||||
// escape all aliases and headers in wikilinks inside a table
|
||||
@@ -513,9 +505,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
|
||||
if (opts.mermaid) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
return (tree: Root, file) => {
|
||||
visit(tree, "code", (node: Code) => {
|
||||
if (node.lang === "mermaid") {
|
||||
file.data.hasMermaidDiagram = true
|
||||
node.data = {
|
||||
hProperties: {
|
||||
className: ["mermaid"],
|
||||
@@ -813,19 +806,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.mermaid) {
|
||||
js.push({
|
||||
script: mermaidExtensionScript,
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "inline",
|
||||
})
|
||||
css.push({
|
||||
content: mermaidStyle,
|
||||
inline: true,
|
||||
})
|
||||
}
|
||||
|
||||
return { js, css }
|
||||
},
|
||||
}
|
||||
@@ -835,5 +815,6 @@ declare module "vfile" {
|
||||
interface DataMap {
|
||||
blocks: Record<string, Element>
|
||||
htmlAst: HtmlRoot
|
||||
hasMermaidDiagram: boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ export interface PluginTypes {
|
||||
}
|
||||
|
||||
type OptionType = object | undefined
|
||||
type ExternalResourcesFn = (ctx: BuildCtx) => Partial<StaticResources> | undefined
|
||||
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzTransformerPluginInstance
|
||||
export type QuartzTransformerPluginInstance = {
|
||||
name: string
|
||||
textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer
|
||||
textTransform?: (ctx: BuildCtx, src: string) => string
|
||||
markdownPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
htmlPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
externalResources?: (ctx: BuildCtx) => Partial<StaticResources>
|
||||
externalResources?: ExternalResourcesFn
|
||||
}
|
||||
|
||||
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
|
||||
@@ -38,10 +39,16 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||
/**
|
||||
* Returns the components (if any) that are used in rendering the page.
|
||||
* This helps Quartz optimize the page by only including necessary resources
|
||||
* for components that are actually used.
|
||||
*/
|
||||
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
|
||||
getDependencyGraph?(
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
): Promise<DepGraph<FilePath>>
|
||||
externalResources?: ExternalResourcesFn
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Node, Parent } from "hast"
|
||||
import { Root as HtmlRoot } from "hast"
|
||||
import { Root as MdRoot } from "mdast"
|
||||
import { Data, VFile } from "vfile"
|
||||
|
||||
export type QuartzPluginData = Data
|
||||
export type ProcessedContent = [Node, VFile]
|
||||
export type MarkdownContent = [MdRoot, VFile]
|
||||
export type ProcessedContent = [HtmlRoot, VFile]
|
||||
|
||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
||||
const root: Parent = { type: "root", children: [] }
|
||||
const root: HtmlRoot = { type: "root", children: [] }
|
||||
const vfile = new VFile("")
|
||||
vfile.data = vfileData
|
||||
return [root, vfile]
|
||||
|
||||
@@ -4,18 +4,20 @@ import remarkRehype from "remark-rehype"
|
||||
import { Processor, unified } from "unified"
|
||||
import { Root as MDRoot } from "remark-parse/lib"
|
||||
import { Root as HTMLRoot } from "hast"
|
||||
import { ProcessedContent } from "../plugins/vfile"
|
||||
import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
|
||||
import { PerfTimer } from "../util/perf"
|
||||
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 workerpool, { Promise as WorkerPromise } from "workerpool"
|
||||
import { QuartzLogger } from "../util/log"
|
||||
import { trace } from "../util/trace"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export type QuartzProcessor = Processor<MDRoot, MDRoot, HTMLRoot>
|
||||
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
||||
export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
|
||||
export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
|
||||
|
||||
export function createMdProcessor(ctx: BuildCtx): QuartzMdProcessor {
|
||||
const transformers = ctx.cfg.plugins.transformers
|
||||
|
||||
return (
|
||||
@@ -24,14 +26,20 @@ export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
||||
.use(remarkParse)
|
||||
// MD AST -> MD AST transforms
|
||||
.use(
|
||||
transformers
|
||||
.filter((p) => p.markdownPlugins)
|
||||
.flatMap((plugin) => plugin.markdownPlugins!(ctx)),
|
||||
transformers.flatMap((plugin) => plugin.markdownPlugins?.(ctx) ?? []),
|
||||
) as unknown as QuartzMdProcessor
|
||||
// ^ sadly the typing of `use` is not smart enough to infer the correct type from our plugin list
|
||||
)
|
||||
}
|
||||
|
||||
export function createHtmlProcessor(ctx: BuildCtx): QuartzHtmlProcessor {
|
||||
const transformers = ctx.cfg.plugins.transformers
|
||||
return (
|
||||
unified()
|
||||
// MD AST -> HTML AST
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
// HTML AST -> HTML AST transforms
|
||||
.use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
|
||||
.use(transformers.flatMap((plugin) => plugin.htmlPlugins?.(ctx) ?? []))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,8 +83,8 @@ async function transpileWorkerScript() {
|
||||
|
||||
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
||||
const { argv, cfg } = ctx
|
||||
return async (processor: QuartzProcessor) => {
|
||||
const res: ProcessedContent[] = []
|
||||
return async (processor: QuartzMdProcessor) => {
|
||||
const res: MarkdownContent[] = []
|
||||
for (const fp of fps) {
|
||||
try {
|
||||
const perf = new PerfTimer()
|
||||
@@ -100,10 +108,32 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
||||
res.push([newAst, file])
|
||||
|
||||
if (argv.verbose) {
|
||||
console.log(`[process] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
|
||||
console.log(`[markdown] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
|
||||
}
|
||||
} catch (err) {
|
||||
trace(`\nFailed to process \`${fp}\``, err as Error)
|
||||
trace(`\nFailed to process markdown \`${fp}\``, err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarkdownParser(ctx: BuildCtx, mdContent: MarkdownContent[]) {
|
||||
return async (processor: QuartzHtmlProcessor) => {
|
||||
const res: ProcessedContent[] = []
|
||||
for (const [ast, file] of mdContent) {
|
||||
try {
|
||||
const perf = new PerfTimer()
|
||||
|
||||
const newAst = await processor.run(ast as MDRoot, file)
|
||||
res.push([newAst, file])
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
console.log(`[html] ${file.data.slug} (${perf.timeSince()})`)
|
||||
}
|
||||
} catch (err) {
|
||||
trace(`\nFailed to process html \`${file.data.filePath}\``, err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +143,7 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
||||
|
||||
const clamp = (num: number, min: number, max: number) =>
|
||||
Math.min(Math.max(Math.round(num), min), max)
|
||||
|
||||
export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<ProcessedContent[]> {
|
||||
const { argv } = ctx
|
||||
const perf = new PerfTimer()
|
||||
@@ -126,9 +157,8 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
||||
log.start(`Parsing input files using ${concurrency} threads`)
|
||||
if (concurrency === 1) {
|
||||
try {
|
||||
const processor = createProcessor(ctx)
|
||||
const parse = createFileParser(ctx, fps)
|
||||
res = await parse(processor)
|
||||
const mdRes = await createFileParser(ctx, fps)(createMdProcessor(ctx))
|
||||
res = await createMarkdownParser(ctx, mdRes)(createHtmlProcessor(ctx))
|
||||
} catch (error) {
|
||||
log.end()
|
||||
throw error
|
||||
@@ -140,17 +170,27 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
||||
maxWorkers: concurrency,
|
||||
workerType: "thread",
|
||||
})
|
||||
|
||||
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
|
||||
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
||||
childPromises.push(pool.exec("parseFiles", [ctx.buildId, argv, chunk, ctx.allSlugs]))
|
||||
const errorHandler = (err: any) => {
|
||||
console.error(`${err}`.replace(/^error:\s*/i, ""))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch((err) => {
|
||||
const errString = err.toString().slice("Error:".length)
|
||||
console.error(errString)
|
||||
process.exit(1)
|
||||
})
|
||||
const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
|
||||
for (const chunk of chunks(fps, CHUNK_SIZE)) {
|
||||
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
|
||||
}
|
||||
const mdResults: [MarkdownContent[], FullSlug[]][] =
|
||||
await WorkerPromise.all(mdPromises).catch(errorHandler)
|
||||
|
||||
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
|
||||
for (const [_, extraSlugs] of mdResults) {
|
||||
ctx.allSlugs.push(...extraSlugs)
|
||||
}
|
||||
for (const [mdChunk, _] of mdResults) {
|
||||
childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
|
||||
}
|
||||
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)
|
||||
|
||||
res = results.flat()
|
||||
await pool.terminate()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use "sass:map";
|
||||
|
||||
@use "./variables.scss" as *;
|
||||
@use "./syntax.scss";
|
||||
@use "./callouts.scss";
|
||||
@@ -85,7 +87,7 @@ a {
|
||||
line-height: 1.4rem;
|
||||
|
||||
&:has(> img) {
|
||||
background-color: none;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -121,7 +123,7 @@ a {
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: calc(#{map-get($breakpoints, desktop)} + 300px);
|
||||
max-width: calc(#{map.get($breakpoints, desktop)} + 300px);
|
||||
margin: 0 auto;
|
||||
& article {
|
||||
& > h1 {
|
||||
@@ -151,24 +153,25 @@ a {
|
||||
|
||||
& > #quartz-body {
|
||||
display: grid;
|
||||
grid-template-columns: #{map-get($desktopGrid, templateColumns)};
|
||||
grid-template-rows: #{map-get($desktopGrid, templateRows)};
|
||||
column-gap: #{map-get($desktopGrid, columnGap)};
|
||||
row-gap: #{map-get($desktopGrid, rowGap)};
|
||||
grid-template-areas: #{map-get($desktopGrid, templateAreas)};
|
||||
grid-template-columns: #{map.get($desktopGrid, templateColumns)};
|
||||
grid-template-rows: #{map.get($desktopGrid, templateRows)};
|
||||
column-gap: #{map.get($desktopGrid, columnGap)};
|
||||
row-gap: #{map.get($desktopGrid, rowGap)};
|
||||
grid-template-areas: #{map.get($desktopGrid, templateAreas)};
|
||||
|
||||
@media all and ($tablet) {
|
||||
grid-template-columns: #{map-get($tabletGrid, templateColumns)};
|
||||
grid-template-rows: #{map-get($tabletGrid, templateRows)};
|
||||
column-gap: #{map-get($tabletGrid, columnGap)};
|
||||
row-gap: #{map-get($tabletGrid, rowGap)};
|
||||
grid-template-areas: #{map-get($tabletGrid, templateAreas)};
|
||||
grid-template-columns: #{map.get($tabletGrid, templateColumns)};
|
||||
grid-template-rows: #{map.get($tabletGrid, templateRows)};
|
||||
column-gap: #{map.get($tabletGrid, columnGap)};
|
||||
row-gap: #{map.get($tabletGrid, rowGap)};
|
||||
grid-template-areas: #{map.get($tabletGrid, templateAreas)};
|
||||
}
|
||||
@media all and ($mobile) {
|
||||
grid-template-columns: #{map-get($mobileGrid, templateColumns)};
|
||||
grid-template-rows: #{map-get($mobileGrid, templateRows)};
|
||||
column-gap: #{map-get($mobileGrid, columnGap)};
|
||||
row-gap: #{map-get($mobileGrid, rowGap)};
|
||||
grid-template-areas: #{map-get($mobileGrid, templateAreas)};
|
||||
grid-template-columns: #{map.get($mobileGrid, templateColumns)};
|
||||
grid-template-rows: #{map.get($mobileGrid, templateRows)};
|
||||
column-gap: #{map.get($mobileGrid, columnGap)};
|
||||
row-gap: #{map.get($mobileGrid, rowGap)};
|
||||
grid-template-areas: #{map.get($mobileGrid, templateAreas)};
|
||||
}
|
||||
|
||||
@media all and not ($desktop) {
|
||||
@@ -388,7 +391,7 @@ figure[data-rehype-pretty-code-figure] {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border: 1px solid var(--lightgray);
|
||||
width: max-content;
|
||||
width: fit-content;
|
||||
border-radius: 5px;
|
||||
margin-bottom: -0.5rem;
|
||||
color: var(--darkgray);
|
||||
@@ -512,6 +515,7 @@ img {
|
||||
max-width: 100%;
|
||||
border-radius: 5px;
|
||||
margin: 1rem 0;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
p > img + em {
|
||||
@@ -547,6 +551,7 @@ ul.overflow,
|
||||
ol.overflow {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
|
||||
// clearfix
|
||||
content: "";
|
||||
@@ -555,18 +560,15 @@ ol.overflow {
|
||||
& > li:last-of-type {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
/*&:after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
background: linear-gradient(transparent 0px, var(--light));
|
||||
}*/
|
||||
|
||||
& > li.overflow-end {
|
||||
height: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.gradient-active {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.transclude {
|
||||
@@ -587,3 +589,14 @@ iframe.pdf {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.navigation-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: var(--secondary);
|
||||
transition: width 0.2s ease;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use "sass:map";
|
||||
|
||||
/**
|
||||
* Layout breakpoints
|
||||
* $mobile: screen width below this value will use mobile styles
|
||||
@@ -10,11 +12,11 @@ $breakpoints: (
|
||||
desktop: 1200px,
|
||||
);
|
||||
|
||||
$mobile: "(max-width: #{map-get($breakpoints, mobile)})";
|
||||
$tablet: "(min-width: #{map-get($breakpoints, mobile)}) and (max-width: #{map-get($breakpoints, desktop)})";
|
||||
$desktop: "(min-width: #{map-get($breakpoints, desktop)})";
|
||||
$mobile: "(max-width: #{map.get($breakpoints, mobile)})";
|
||||
$tablet: "(min-width: #{map.get($breakpoints, mobile)}) and (max-width: #{map.get($breakpoints, desktop)})";
|
||||
$desktop: "(min-width: #{map.get($breakpoints, desktop)})";
|
||||
|
||||
$pageWidth: #{map-get($breakpoints, mobile)};
|
||||
$pageWidth: #{map.get($breakpoints, mobile)};
|
||||
$sidePanelWidth: 320px; //380px;
|
||||
$topSpacing: 6rem;
|
||||
$boldWeight: 700;
|
||||
|
||||
3
quartz/util/clone.ts
Normal file
3
quartz/util/clone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import rfdc from "rfdc"
|
||||
|
||||
export const clone = rfdc()
|
||||
38
quartz/util/emoji.ts
Normal file
38
quartz/util/emoji.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const U200D = String.fromCharCode(8205)
|
||||
const UFE0Fg = /\uFE0F/g
|
||||
|
||||
export function getIconCode(char: string) {
|
||||
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char)
|
||||
}
|
||||
|
||||
function toCodePoint(unicodeSurrogates: string) {
|
||||
const r = []
|
||||
let c = 0,
|
||||
p = 0,
|
||||
i = 0
|
||||
|
||||
while (i < unicodeSurrogates.length) {
|
||||
c = unicodeSurrogates.charCodeAt(i++)
|
||||
if (p) {
|
||||
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
|
||||
p = 0
|
||||
} else if (55296 <= c && c <= 56319) {
|
||||
p = c
|
||||
} else {
|
||||
r.push(c.toString(16))
|
||||
}
|
||||
}
|
||||
return r.join("-")
|
||||
}
|
||||
|
||||
const twemoji = (code: string) =>
|
||||
`https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/${code.toLowerCase()}.svg`
|
||||
const emojiCache: Record<string, Promise<any>> = {}
|
||||
|
||||
export function loadEmoji(code: string) {
|
||||
const type = "twemoji"
|
||||
const key = type + ":" + code
|
||||
if (key in emojiCache) return emojiCache[key]
|
||||
|
||||
return (emojiCache[key] = fetch(twemoji(code)).then((r) => r.text()))
|
||||
}
|
||||
190
quartz/util/fileTrie.test.ts
Normal file
190
quartz/util/fileTrie.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import test, { describe, beforeEach } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { FileTrieNode } from "./fileTrie"
|
||||
|
||||
interface TestData {
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
describe("FileTrie", () => {
|
||||
let trie: FileTrieNode<TestData>
|
||||
|
||||
beforeEach(() => {
|
||||
trie = new FileTrieNode<TestData>("")
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
test("should create an empty trie", () => {
|
||||
assert.deepStrictEqual(trie.children, [])
|
||||
assert.strictEqual(trie.slugSegment, "")
|
||||
assert.strictEqual(trie.displayName, "")
|
||||
assert.strictEqual(trie.data, null)
|
||||
assert.strictEqual(trie.depth, 0)
|
||||
})
|
||||
|
||||
test("should set displayName from data title", () => {
|
||||
const data = {
|
||||
title: "Test Title",
|
||||
slug: "test",
|
||||
}
|
||||
|
||||
trie.add(data)
|
||||
assert.strictEqual(trie.children[0].displayName, "Test Title")
|
||||
})
|
||||
})
|
||||
|
||||
describe("add", () => {
|
||||
test("should add a file at root level", () => {
|
||||
const data = {
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
}
|
||||
|
||||
trie.add(data)
|
||||
assert.strictEqual(trie.children.length, 1)
|
||||
assert.strictEqual(trie.children[0].slugSegment, "test")
|
||||
assert.strictEqual(trie.children[0].data, data)
|
||||
})
|
||||
|
||||
test("should handle index files", () => {
|
||||
const data = {
|
||||
title: "Index",
|
||||
slug: "index",
|
||||
}
|
||||
|
||||
trie.add(data)
|
||||
assert.strictEqual(trie.data, data)
|
||||
assert.strictEqual(trie.children.length, 0)
|
||||
})
|
||||
|
||||
test("should add nested files", () => {
|
||||
const data1 = {
|
||||
title: "Nested",
|
||||
slug: "folder/test",
|
||||
}
|
||||
|
||||
const data2 = {
|
||||
title: "Really nested index",
|
||||
slug: "a/b/c/index",
|
||||
}
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
assert.strictEqual(trie.children.length, 2)
|
||||
assert.strictEqual(trie.children[0].slugSegment, "folder")
|
||||
assert.strictEqual(trie.children[0].children.length, 1)
|
||||
assert.strictEqual(trie.children[0].children[0].slugSegment, "test")
|
||||
assert.strictEqual(trie.children[0].children[0].data, data1)
|
||||
|
||||
assert.strictEqual(trie.children[1].slugSegment, "a")
|
||||
assert.strictEqual(trie.children[1].children.length, 1)
|
||||
assert.strictEqual(trie.children[1].data, null)
|
||||
|
||||
assert.strictEqual(trie.children[1].children[0].slugSegment, "b")
|
||||
assert.strictEqual(trie.children[1].children[0].children.length, 1)
|
||||
assert.strictEqual(trie.children[1].children[0].data, null)
|
||||
|
||||
assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c")
|
||||
assert.strictEqual(trie.children[1].children[0].children[0].data, data2)
|
||||
assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filter", () => {
|
||||
test("should filter nodes based on condition", () => {
|
||||
const data1 = { title: "Test1", slug: "test1" }
|
||||
const data2 = { title: "Test2", slug: "test2" }
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
|
||||
trie.filter((node) => node.slugSegment !== "test1")
|
||||
assert.strictEqual(trie.children.length, 1)
|
||||
assert.strictEqual(trie.children[0].slugSegment, "test2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("map", () => {
|
||||
test("should apply function to all nodes", () => {
|
||||
const data1 = { title: "Test1", slug: "test1" }
|
||||
const data2 = { title: "Test2", slug: "test2" }
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
|
||||
trie.map((node) => {
|
||||
if (node.data) {
|
||||
node.displayName = "Modified"
|
||||
}
|
||||
})
|
||||
|
||||
assert.strictEqual(trie.children[0].displayName, "Modified")
|
||||
assert.strictEqual(trie.children[1].displayName, "Modified")
|
||||
})
|
||||
})
|
||||
|
||||
describe("entries", () => {
|
||||
test("should return all entries", () => {
|
||||
const data1 = { title: "Test1", slug: "test1" }
|
||||
const data2 = { title: "Test2", slug: "a/b/test2" }
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
|
||||
const entries = trie.entries()
|
||||
assert.deepStrictEqual(
|
||||
entries.map(([path, node]) => [path, node.data]),
|
||||
[
|
||||
["", trie.data],
|
||||
["test1", data1],
|
||||
["a/index", null],
|
||||
["a/b/index", null],
|
||||
["a/b/test2", data2],
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFolderPaths", () => {
|
||||
test("should return all folder paths", () => {
|
||||
const data1 = {
|
||||
title: "Root",
|
||||
slug: "index",
|
||||
}
|
||||
const data2 = {
|
||||
title: "Test",
|
||||
slug: "folder/subfolder/test",
|
||||
}
|
||||
const data3 = {
|
||||
title: "Folder Index",
|
||||
slug: "abc/index",
|
||||
}
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
trie.add(data3)
|
||||
const paths = trie.getFolderPaths()
|
||||
|
||||
assert.deepStrictEqual(paths, ["folder/index", "folder/subfolder/index", "abc/index"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sort", () => {
|
||||
test("should sort nodes according to sort function", () => {
|
||||
const data1 = { title: "A", slug: "a" }
|
||||
const data2 = { title: "B", slug: "b" }
|
||||
const data3 = { title: "C", slug: "c" }
|
||||
|
||||
trie.add(data3)
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
|
||||
trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment))
|
||||
assert.deepStrictEqual(
|
||||
trie.children.map((n) => n.slugSegment),
|
||||
["a", "b", "c"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
128
quartz/util/fileTrie.ts
Normal file
128
quartz/util/fileTrie.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ContentDetails } from "../plugins/emitters/contentIndex"
|
||||
import { FullSlug, joinSegments } from "./path"
|
||||
|
||||
interface FileTrieData {
|
||||
slug: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
||||
children: Array<FileTrieNode<T>>
|
||||
slugSegment: string
|
||||
displayName: string
|
||||
data: T | null
|
||||
depth: number
|
||||
isFolder: boolean
|
||||
|
||||
constructor(segment: string, data?: T, depth: number = 0) {
|
||||
this.children = []
|
||||
this.slugSegment = segment
|
||||
this.displayName = data?.title ?? segment
|
||||
this.data = data ?? null
|
||||
this.depth = depth
|
||||
this.isFolder = segment === "index"
|
||||
}
|
||||
|
||||
private insert(path: string[], file: T) {
|
||||
if (path.length === 0) return
|
||||
|
||||
const nextSegment = path[0]
|
||||
|
||||
// base case, insert here
|
||||
if (path.length === 1) {
|
||||
if (nextSegment === "index") {
|
||||
// index case (we are the root and we just found index.md)
|
||||
this.data ??= file
|
||||
const title = file.title
|
||||
if (title !== "index") {
|
||||
this.displayName = title
|
||||
}
|
||||
} else {
|
||||
// direct child
|
||||
this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1))
|
||||
this.isFolder = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// find the right child to insert into, creating it if it doesn't exist
|
||||
path = path.splice(1)
|
||||
let child = this.children.find((c) => c.slugSegment === nextSegment)
|
||||
if (!child) {
|
||||
child = new FileTrieNode<T>(nextSegment, undefined, this.depth + 1)
|
||||
this.children.push(child)
|
||||
child.isFolder = true
|
||||
}
|
||||
|
||||
child.insert(path, file)
|
||||
}
|
||||
|
||||
// Add new file to trie
|
||||
add(file: T) {
|
||||
this.insert(file.slug.split("/"), file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||
*/
|
||||
filter(filterFn: (node: FileTrieNode<T>) => boolean) {
|
||||
this.children = this.children.filter(filterFn)
|
||||
this.children.forEach((child) => child.filter(filterFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place
|
||||
*/
|
||||
map(mapFn: (node: FileTrieNode<T>) => void) {
|
||||
mapFn(this)
|
||||
this.children.forEach((child) => child.map(mapFn))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort trie nodes according to sort/compare function
|
||||
*/
|
||||
sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) {
|
||||
this.children = this.children.sort(sortFn)
|
||||
this.children.forEach((e) => e.sort(sortFn))
|
||||
}
|
||||
|
||||
static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) {
|
||||
const trie = new FileTrieNode<T>("")
|
||||
entries.forEach(([, entry]) => trie.add(entry))
|
||||
return trie
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries in the trie
|
||||
* in the a flat array including the full path and the node
|
||||
*/
|
||||
entries(): [FullSlug, FileTrieNode<T>][] {
|
||||
const traverse = (
|
||||
node: FileTrieNode<T>,
|
||||
currentPath: string,
|
||||
): [FullSlug, FileTrieNode<T>][] => {
|
||||
const segments = [currentPath, node.slugSegment]
|
||||
const fullPath = joinSegments(...segments) as FullSlug
|
||||
|
||||
const indexQualifiedPath =
|
||||
node.isFolder && node.depth > 0 ? (joinSegments(fullPath, "index") as FullSlug) : fullPath
|
||||
|
||||
const result: [FullSlug, FileTrieNode<T>][] = [[indexQualifiedPath, node]]
|
||||
|
||||
return result.concat(...node.children.map((child) => traverse(child, fullPath)))
|
||||
}
|
||||
|
||||
return traverse(this, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all folder paths in the trie
|
||||
* @returns array containing folder state for trie
|
||||
*/
|
||||
getFolderPaths() {
|
||||
return this.entries()
|
||||
.filter(([_, node]) => node.isFolder)
|
||||
.map(([path, _]) => path)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export async function getSatoriFont(headerFontName: string, bodyFontName: string
|
||||
async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
|
||||
try {
|
||||
// Get css file from google fonts
|
||||
const cssResponse = await fetch(`https://fonts.googleapis.com/css?family=${fontName}:${weight}`)
|
||||
const cssResponse = await fetch(
|
||||
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
|
||||
)
|
||||
const css = await cssResponse.text()
|
||||
|
||||
// Extract .ttf url from css file
|
||||
@@ -141,12 +143,10 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
fonts: SatoriOptions["fonts"],
|
||||
_fileData: QuartzPluginData,
|
||||
) => {
|
||||
// How many characters are allowed before switching to smaller font
|
||||
const fontBreakPoint = 22
|
||||
const useSmallerFont = title.length > fontBreakPoint
|
||||
|
||||
// Setup to access image
|
||||
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -158,43 +158,66 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
width: "100%",
|
||||
backgroundColor: cfg.theme.colors[colorScheme].light,
|
||||
gap: "2rem",
|
||||
paddingTop: "1.5rem",
|
||||
paddingBottom: "1.5rem",
|
||||
paddingLeft: "5rem",
|
||||
paddingRight: "5rem",
|
||||
padding: "1.5rem 5rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
gap: "2.5rem",
|
||||
}}
|
||||
>
|
||||
<img src={iconPath} width={135} height={135} />
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
color: cfg.theme.colors[colorScheme].dark,
|
||||
fontSize: useSmallerFont ? 70 : 82,
|
||||
fontFamily: fonts[0].name,
|
||||
maxWidth: "70%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
color: cfg.theme.colors[colorScheme].dark,
|
||||
fontSize: 44,
|
||||
lineClamp: 3,
|
||||
fontFamily: fonts[1].name,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "40%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 3,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,29 @@ describe("transforms", () => {
|
||||
path.isRelativeURL,
|
||||
)
|
||||
})
|
||||
|
||||
test("joinSegments", () => {
|
||||
assert.strictEqual(path.joinSegments("a", "b"), "a/b")
|
||||
assert.strictEqual(path.joinSegments("a/", "b"), "a/b")
|
||||
assert.strictEqual(path.joinSegments("a", "b/"), "a/b/")
|
||||
assert.strictEqual(path.joinSegments("a/", "b/"), "a/b/")
|
||||
|
||||
// preserve leading and trailing slashes
|
||||
assert.strictEqual(path.joinSegments("/a", "b"), "/a/b")
|
||||
assert.strictEqual(path.joinSegments("/a/", "b"), "/a/b")
|
||||
assert.strictEqual(path.joinSegments("/a", "b/"), "/a/b/")
|
||||
assert.strictEqual(path.joinSegments("/a/", "b/"), "/a/b/")
|
||||
|
||||
// lone slash
|
||||
assert.strictEqual(path.joinSegments("/a/", "b", "/"), "/a/b/")
|
||||
assert.strictEqual(path.joinSegments("a/", "b" + "/"), "a/b/")
|
||||
|
||||
// works with protocol specifiers
|
||||
assert.strictEqual(path.joinSegments("https://example.com", "a"), "https://example.com/a")
|
||||
assert.strictEqual(path.joinSegments("https://example.com/", "a"), "https://example.com/a")
|
||||
assert.strictEqual(path.joinSegments("https://example.com", "a/"), "https://example.com/a/")
|
||||
assert.strictEqual(path.joinSegments("https://example.com/", "a/"), "https://example.com/a/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("link strategies", () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import type { Element as HastElement } from "hast"
|
||||
import rfdc from "rfdc"
|
||||
|
||||
export const clone = rfdc()
|
||||
|
||||
import { clone } from "./clone"
|
||||
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||
|
||||
export const QUARTZ = "quartz"
|
||||
@@ -108,10 +105,10 @@ const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) =>
|
||||
el.setAttribute(attr, rebased.pathname + rebased.hash)
|
||||
}
|
||||
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
|
||||
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
|
||||
el.querySelectorAll('[href=""], [href^="./"], [href^="../"]').forEach((item) =>
|
||||
_rebaseHtmlElement(item, "href", destination),
|
||||
)
|
||||
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
|
||||
el.querySelectorAll('[src=""], [src^="./"], [src^="../"]').forEach((item) =>
|
||||
_rebaseHtmlElement(item, "src", destination),
|
||||
)
|
||||
}
|
||||
@@ -183,10 +180,26 @@ export function slugTag(tag: string) {
|
||||
}
|
||||
|
||||
export function joinSegments(...args: string[]): string {
|
||||
return args
|
||||
.filter((segment) => segment !== "")
|
||||
if (args.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let joined = args
|
||||
.filter((segment) => segment !== "" && segment !== "/")
|
||||
.map((segment) => stripSlashes(segment))
|
||||
.join("/")
|
||||
.replace(/\/\/+/g, "/")
|
||||
|
||||
// if the first segment starts with a slash, add it back
|
||||
if (args[0].startsWith("/")) {
|
||||
joined = "/" + joined
|
||||
}
|
||||
|
||||
// if the last segment is a folder, add a trailing slash
|
||||
if (args[args.length - 1].endsWith("/")) {
|
||||
joined = joined + "/"
|
||||
}
|
||||
|
||||
return joined
|
||||
}
|
||||
|
||||
export function getAllSegmentPrefixes(tags: string): string[] {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { JSX } from "preact/jsx-runtime"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
export type JSResource = {
|
||||
loadTime: "beforeDOMReady" | "afterDOMReady"
|
||||
@@ -62,4 +63,5 @@ export function CSSResourceToStyleElement(resource: CSSResource, preserve?: bool
|
||||
export interface StaticResources {
|
||||
css: CSSResource[]
|
||||
js: JSResource[]
|
||||
additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ interface Colors {
|
||||
darkMode: ColorScheme
|
||||
}
|
||||
|
||||
type FontSpecification =
|
||||
| string
|
||||
| {
|
||||
name: string
|
||||
weights?: number[]
|
||||
includeItalic?: boolean
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
typography: {
|
||||
header: string
|
||||
body: string
|
||||
code: string
|
||||
header: FontSpecification
|
||||
body: FontSpecification
|
||||
code: FontSpecification
|
||||
}
|
||||
cdnCaching: boolean
|
||||
colors: Colors
|
||||
@@ -29,12 +37,57 @@ export interface Theme {
|
||||
export type ThemeKey = keyof Colors
|
||||
|
||||
const DEFAULT_SANS_SERIF =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
|
||||
'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'
|
||||
const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"
|
||||
|
||||
export function getFontSpecificationName(spec: FontSpecification): string {
|
||||
if (typeof spec === "string") {
|
||||
return spec
|
||||
}
|
||||
|
||||
return spec.name
|
||||
}
|
||||
|
||||
function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpecification) {
|
||||
if (typeof spec === "string") {
|
||||
spec = { name: spec }
|
||||
}
|
||||
|
||||
const defaultIncludeWeights = type === "header" ? [400, 700] : [400, 600]
|
||||
const defaultIncludeItalic = type === "body"
|
||||
const weights = spec.weights ?? defaultIncludeWeights
|
||||
const italic = spec.includeItalic ?? defaultIncludeItalic
|
||||
|
||||
const features: string[] = []
|
||||
if (italic) {
|
||||
features.push("ital")
|
||||
}
|
||||
|
||||
if (weights.length > 1) {
|
||||
const weightSpec = italic
|
||||
? weights
|
||||
.flatMap((w) => [`0,${w}`, `1,${w}`])
|
||||
.sort()
|
||||
.join(";")
|
||||
: weights.join(";")
|
||||
|
||||
features.push(`wght@${weightSpec}`)
|
||||
}
|
||||
|
||||
if (features.length > 0) {
|
||||
return `${spec.name}:${features.join(",")}`
|
||||
}
|
||||
|
||||
return spec.name
|
||||
}
|
||||
|
||||
export function googleFontHref(theme: Theme) {
|
||||
const { code, header, body } = theme.typography
|
||||
return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap`
|
||||
const headerFont = formatFontSpecification("header", header)
|
||||
const bodyFont = formatFontSpecification("body", body)
|
||||
const codeFont = formatFontSpecification("code", code)
|
||||
|
||||
return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap`
|
||||
}
|
||||
|
||||
export function joinStyles(theme: Theme, ...stylesheet: string[]) {
|
||||
|
||||
@@ -3,23 +3,46 @@ sourceMapSupport.install(options)
|
||||
import cfg from "../quartz.config"
|
||||
import { Argv, BuildCtx } from "./util/ctx"
|
||||
import { FilePath, FullSlug } from "./util/path"
|
||||
import { createFileParser, createProcessor } from "./processors/parse"
|
||||
import {
|
||||
createFileParser,
|
||||
createHtmlProcessor,
|
||||
createMarkdownParser,
|
||||
createMdProcessor,
|
||||
} from "./processors/parse"
|
||||
import { options } from "./util/sourcemap"
|
||||
import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
|
||||
|
||||
// only called from worker thread
|
||||
export async function parseFiles(
|
||||
export async function parseMarkdown(
|
||||
buildId: string,
|
||||
argv: Argv,
|
||||
fps: FilePath[],
|
||||
allSlugs: FullSlug[],
|
||||
) {
|
||||
): Promise<[MarkdownContent[], FullSlug[]]> {
|
||||
// this is a hack
|
||||
// we assume markdown parsers can add to `allSlugs`,
|
||||
// but don't actually use them
|
||||
const allSlugs: FullSlug[] = []
|
||||
const ctx: BuildCtx = {
|
||||
buildId,
|
||||
cfg,
|
||||
argv,
|
||||
allSlugs,
|
||||
}
|
||||
const processor = createProcessor(ctx)
|
||||
const parse = createFileParser(ctx, fps)
|
||||
return parse(processor)
|
||||
return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
|
||||
}
|
||||
|
||||
// only called from worker thread
|
||||
export function processHtml(
|
||||
buildId: string,
|
||||
argv: Argv,
|
||||
mds: MarkdownContent[],
|
||||
allSlugs: FullSlug[],
|
||||
): Promise<ProcessedContent[]> {
|
||||
const ctx: BuildCtx = {
|
||||
buildId,
|
||||
cfg,
|
||||
argv,
|
||||
allSlugs,
|
||||
}
|
||||
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user