mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-05-18 14:34:23 +02:00
Compare commits
67 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e98d97a271 | ||
![]() |
c33f96e572 | ||
![]() |
0b348a0532 | ||
![]() |
59d4b4eddd | ||
![]() |
adf442036b | ||
![]() |
dc2c4dca08 | ||
![]() |
6ba9c7c02a | ||
![]() |
8d5b13ee03 | ||
![]() |
4d07ac93b4 | ||
![]() |
32d3fc0ce8 | ||
![]() |
00e860d8e6 | ||
![]() |
2acdec323f | ||
![]() |
9e58857746 | ||
![]() |
4bd714b7be | ||
![]() |
78e13bcb40 | ||
![]() |
7d49dff074 | ||
![]() |
cdebd05dc9 | ||
![]() |
2a9290b3df | ||
![]() |
771c05ff18 | ||
![]() |
6dd772bf00 | ||
![]() |
c238dd16d9 | ||
![]() |
b34d521293 | ||
![]() |
bfd72347cf | ||
![]() |
091cc1b05e | ||
![]() |
e9b60c7285 | ||
![]() |
b1a920e5c0 | ||
![]() |
61770d3e50 | ||
![]() |
9db66d500e | ||
![]() |
ee8c1dc968 | ||
![]() |
bb24cd13c7 | ||
![]() |
d61fb266c7 | ||
![]() |
685c06ce2e | ||
![]() |
3ae89a1d16 | ||
![]() |
4d6e7ccba9 | ||
![]() |
f334e78ed6 | ||
![]() |
c5304b35c0 | ||
![]() |
99f353968e | ||
![]() |
ec4700d522 | ||
![]() |
d6f69e830c | ||
![]() |
9ee6fe15fd | ||
![]() |
a21f588c48 | ||
![]() |
2119025513 | ||
![]() |
f70e562432 | ||
![]() |
9ff6c7a3f5 | ||
![]() |
7ca9dd9a70 | ||
![]() |
b397dae951 | ||
![]() |
23b691f38c | ||
![]() |
c18e6cd5bb | ||
![]() |
fe2e16d937 | ||
![]() |
722b4321db | ||
![]() |
9d8d238912 | ||
![]() |
141f053b0d | ||
![]() |
3027eced6c | ||
![]() |
aaa5c8e8e4 | ||
![]() |
4e74d11b1a | ||
![]() |
457b77dd48 | ||
![]() |
3ce6aa49bf | ||
![]() |
9316ddf2f5 | ||
![]() |
fbca56f278 | ||
![]() |
eccad3da5d | ||
![]() |
bcde2abcb2 | ||
![]() |
25979ab216 | ||
![]() |
9818e1ad57 | ||
![]() |
771110a72a | ||
![]() |
dc6a9f3b12 | ||
![]() |
c0b73ddaa4 | ||
![]() |
e86544064c |
43
.github/workflows/build-preview.yaml
vendored
Normal file
43
.github/workflows/build-preview.yaml
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
name: Build Preview Deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-preview:
|
||||
if: ${{ github.repository == 'jackyzha0/quartz' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Preview
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Check types and style
|
||||
run: npm run check
|
||||
|
||||
- name: Build Quartz
|
||||
run: npx quartz build -d docs -v
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: preview-build
|
||||
path: public
|
37
.github/workflows/deploy-preview.yaml
vendored
Normal file
37
.github/workflows/deploy-preview.yaml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Upload Preview Deployment
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build Preview Deployment"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
deployments: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: ${{ github.repository == 'jackyzha0/quartz' && github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Preview to Cloudflare Pages
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
id: preview-build-artifact
|
||||
with:
|
||||
name: preview-build
|
||||
path: build
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: AdrianGonz97/refined-cf-pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
projectName: quartz
|
||||
deploymentName: Branch Preview
|
||||
directory: ${{ steps.preview-build-artifact.outputs.download-path }}
|
4
.github/workflows/docker-build-push.yaml
vendored
4
.github/workflows/docker-build-push.yaml
vendored
@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Inject slug/short variables
|
||||
uses: rlespinasse/github-slug-action@v5.0.0
|
||||
uses: rlespinasse/github-slug-action@v5.1.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
@ -37,7 +37,7 @@ jobs:
|
||||
network=host
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.8.1
|
||||
uses: sigstore/cosign-installer@v3.8.2
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
|
@ -41,11 +41,12 @@ This part of the configuration concerns anything that can affect the whole site.
|
||||
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details.
|
||||
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
|
||||
- `theme`: configure how the site looks.
|
||||
- `cdnCaching`: If `true` (default), use Google CDN to cache the fonts. This will generally will be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained.
|
||||
- `cdnCaching`: if `true` (default), use Google CDN to cache the fonts. This will generally be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained.
|
||||
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
|
||||
- `header`: Font to use for headers
|
||||
- `code`: Font for inline and block quotes.
|
||||
- `body`: Font for everything
|
||||
- `title`: font for the title of the site (optional, same as `header` by default)
|
||||
- `header`: font to use for headers
|
||||
- `code`: font for inline and block quotes
|
||||
- `body`: font for everything
|
||||
- `colors`: controls the theming of the site.
|
||||
- `light`: page background
|
||||
- `lightgray`: borders
|
||||
|
@ -19,7 +19,6 @@ Component.Breadcrumbs({
|
||||
spacerSymbol: "❯", // symbol between crumbs
|
||||
rootName: "Home", // name of first/root element
|
||||
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
||||
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
|
||||
showCurrentPage: true, // whether to display the current page in the breadcrumbs
|
||||
})
|
||||
```
|
||||
|
@ -131,7 +131,8 @@ Using this example, the display names of all `FileNodes` (folders + files) will
|
||||
```ts title="quartz.layout.ts"
|
||||
Component.Explorer({
|
||||
mapFn: (node) => {
|
||||
return (node.displayName = node.displayName.toUpperCase())
|
||||
node.displayName = node.displayName.toUpperCase()
|
||||
return node
|
||||
},
|
||||
})
|
||||
```
|
||||
@ -145,8 +146,12 @@ Note that this example filters on the title but you can also do it via slug or a
|
||||
Component.Explorer({
|
||||
filterFn: (node) => {
|
||||
// set containing names of everything you want to filter out
|
||||
const omit = new Set(["authoring content", "tags", "hosting"])
|
||||
return !omit.has(node.data.title.toLowerCase())
|
||||
const omit = new Set(["authoring content", "tags", "advanced"])
|
||||
|
||||
// can also use node.slug or by anything on node.data
|
||||
// note that node.data is only present for files that exist on disk
|
||||
// (e.g. implicit folder nodes that have no associated index.md)
|
||||
return !omit.has(node.displayName.toLowerCase())
|
||||
},
|
||||
})
|
||||
```
|
||||
@ -159,7 +164,7 @@ You can access the tags of a file by `node.data.tags`.
|
||||
Component.Explorer({
|
||||
filterFn: (node) => {
|
||||
// exclude files with the tag "explorerexclude"
|
||||
return node.data.tags.includes("explorerexclude") !== true
|
||||
return node.data.tags?.includes("explorerexclude") !== true
|
||||
},
|
||||
})
|
||||
```
|
||||
|
44
docs/features/reader mode.md
Normal file
44
docs/features/reader mode.md
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Reader Mode
|
||||
tags:
|
||||
- component
|
||||
---
|
||||
|
||||
Reader Mode is a feature that allows users to focus on the content by hiding the sidebars and other UI elements. When enabled, it provides a clean, distraction-free reading experience.
|
||||
|
||||
## Configuration
|
||||
|
||||
Reader Mode is enabled by default. To disable it, you can remove the component from your layout configuration in `quartz.layout.ts`:
|
||||
|
||||
```ts
|
||||
// Remove or comment out this line
|
||||
Component.ReaderMode(),
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The Reader Mode toggle appears as a button with a book icon. When clicked:
|
||||
|
||||
- Sidebars are hidden
|
||||
- Hovering over the content area reveals the sidebars temporarily
|
||||
|
||||
Unlike Dark Mode, Reader Mode state is not persisted between page reloads but is maintained during SPA navigation within the site.
|
||||
|
||||
## Customization
|
||||
|
||||
You can customize the appearance of Reader Mode through CSS variables and styles. The component uses the following classes:
|
||||
|
||||
- `.readermode`: The toggle button
|
||||
- `.readerIcon`: The book icon
|
||||
- `[reader-mode="on"]`: Applied to the root element when Reader Mode is active
|
||||
|
||||
Example customization in your custom CSS:
|
||||
|
||||
```scss
|
||||
.readermode {
|
||||
// Customize the button
|
||||
svg {
|
||||
stroke: var(--custom-color);
|
||||
}
|
||||
}
|
||||
```
|
@ -31,7 +31,7 @@ If you prefer instructions in a video format you can try following Nicole van de
|
||||
|
||||
## 🔧 Features
|
||||
|
||||
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
|
||||
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks|wikilinks, transclusions]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
|
||||
- Hot-reload on configuration edits and incremental rebuilds for content edits
|
||||
- Simple JSX layouts and [[creating components|page components]]
|
||||
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
|
||||
|
@ -60,3 +60,34 @@ The `DesktopOnly` component is the counterpart to `MobileOnly`. It makes its chi
|
||||
```typescript
|
||||
Component.DesktopOnly(Component.TableOfContents())
|
||||
```
|
||||
|
||||
## `ConditionalRender` Component
|
||||
|
||||
The `ConditionalRender` component is a wrapper that conditionally renders its child component based on a provided condition function. This is useful for creating dynamic layouts where components should only appear under certain conditions.
|
||||
|
||||
```typescript
|
||||
type ConditionalRenderConfig = {
|
||||
component: QuartzComponent
|
||||
condition: (props: QuartzComponentProps) => boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```typescript
|
||||
Component.ConditionalRender({
|
||||
component: Component.Search(),
|
||||
condition: (props) => props.displayClass !== "fullpage",
|
||||
})
|
||||
```
|
||||
|
||||
The example above would only render the Search component when the page is not in fullpage mode.
|
||||
|
||||
```typescript
|
||||
Component.ConditionalRender({
|
||||
component: Component.Breadcrumbs(),
|
||||
condition: (page) => page.fileData.slug !== "index",
|
||||
})
|
||||
```
|
||||
|
||||
The example above would hide breadcrumbs on the root `index.md` page.
|
||||
|
@ -62,7 +62,7 @@ The following properties can be used to customize your link previews:
|
||||
| `socialDescription` | `description` | Description to be used for preview. |
|
||||
| `socialImage` | `image`, `cover` | Link to preview image. |
|
||||
|
||||
The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`.
|
||||
The `socialImage` property should contain a link to an image either relative to `quartz/static`, or a full URL. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. Alternatively, you can use a fully qualified URL like `"https://example.com/cover.png"`.
|
||||
|
||||
> [!info] Info
|
||||
>
|
||||
|
19
docs/plugins/Favicon.md
Normal file
19
docs/plugins/Favicon.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Favicon
|
||||
tags:
|
||||
- plugin/emitter
|
||||
---
|
||||
|
||||
This plugin emits a `favicon.ico` into the `public` folder. It creates the favicon from `icon.png` located in the `quartz/static` folder.
|
||||
The plugin resizes `icon.png` to 48x48px to make it as small as possible.
|
||||
|
||||
> [!note]
|
||||
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
|
||||
|
||||
This plugin has no configuration options.
|
||||
|
||||
## API
|
||||
|
||||
- Category: Emitter
|
||||
- Function name: `Plugin.Favicon()`.
|
||||
- Source: [`quartz/plugins/emitters/favicon.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/emitters/favicon.ts).
|
@ -34,6 +34,13 @@ npx quartz sync --no-pull
|
||||
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
|
||||
> You may have an outdated version of `git`. Updating `git` should fix this issue.
|
||||
|
||||
> [!warning]- `fatal: The remote end hung up unexpectedly`
|
||||
> It might be due to Git's default buffer size. You can fix it by increasing the buffer with this command:
|
||||
>
|
||||
> ```bash
|
||||
> git config http.postBuffer 524288000
|
||||
> ```
|
||||
|
||||
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
|
||||
|
||||
> [!hint] Flags and options
|
||||
|
1
index.d.ts
vendored
1
index.d.ts
vendored
@ -8,6 +8,7 @@ interface CustomEventMap {
|
||||
prenav: CustomEvent<{}>
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
readermodechange: CustomEvent<{ mode: "on" | "off" }>
|
||||
}
|
||||
|
||||
type ContentIndex = Record<FullSlug, ContentDetails>
|
||||
|
791
package-lock.json
generated
791
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@ -35,11 +35,12 @@
|
||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@myriaddreamin/rehype-typst": "^0.5.4",
|
||||
"@clack/prompts": "^0.10.1",
|
||||
"@floating-ui/dom": "^1.7.0",
|
||||
"@myriaddreamin/rehype-typst": "^0.6.0",
|
||||
"@napi-rs/simple-git": "0.1.19",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@webgpu/types": "^0.1.60",
|
||||
"ansi-truncate": "^1.2.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chalk": "^5.4.1",
|
||||
@ -56,23 +57,23 @@
|
||||
"hast-util-to-string": "^3.0.1",
|
||||
"is-absolute-url": "^4.0.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lightningcss": "^1.29.2",
|
||||
"lightningcss": "^1.29.3",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mdast-util-to-hast": "^13.2.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromorph": "^0.4.5",
|
||||
"minimatch": "^10.0.1",
|
||||
"pixi.js": "^8.8.1",
|
||||
"preact": "^10.26.4",
|
||||
"pixi.js": "^8.9.2",
|
||||
"preact": "^10.26.5",
|
||||
"preact-render-to-string": "^6.5.13",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"pretty-bytes": "^7.0.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.2.2",
|
||||
"rehype-citation": "^2.3.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
@ -81,13 +82,13 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"remark-smartypants": "^3.0.2",
|
||||
"rfdc": "^1.4.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"satori": "^0.12.1",
|
||||
"satori": "^0.12.2",
|
||||
"serve-handler": "^6.1.6",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.34.1",
|
||||
"shiki": "^1.26.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"to-vfile": "^8.0.0",
|
||||
@ -96,21 +97,21 @@
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.3",
|
||||
"workerpool": "^9.2.0",
|
||||
"ws": "^8.18.1",
|
||||
"ws": "^8.18.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/node": "^22.15.7",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"esbuild": "^0.25.1",
|
||||
"esbuild": "^0.25.3",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ const config: QuartzConfig = {
|
||||
locale: "en-US",
|
||||
baseUrl: "quartz.jzhao.xyz",
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "created",
|
||||
defaultDateType: "modified",
|
||||
theme: {
|
||||
fontOrigin: "googleFonts",
|
||||
cdnCaching: true,
|
||||
@ -57,7 +57,7 @@ const config: QuartzConfig = {
|
||||
transformers: [
|
||||
Plugin.FrontMatter(),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["git", "frontmatter", "filesystem"],
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}),
|
||||
Plugin.SyntaxHighlighting({
|
||||
theme: {
|
||||
@ -86,6 +86,7 @@ const config: QuartzConfig = {
|
||||
}),
|
||||
Plugin.Assets(),
|
||||
Plugin.Static(),
|
||||
Plugin.Favicon(),
|
||||
Plugin.NotFoundPage(),
|
||||
// Comment out CustomOgImages to speed up build time
|
||||
Plugin.CustomOgImages(),
|
||||
|
@ -17,7 +17,10 @@ export const sharedPageComponents: SharedLayout = {
|
||||
// components for pages that display a single page (e.g. a single note)
|
||||
export const defaultContentPageLayout: PageLayout = {
|
||||
beforeBody: [
|
||||
Component.Breadcrumbs(),
|
||||
Component.ConditionalRender({
|
||||
component: Component.Breadcrumbs(),
|
||||
condition: (page) => page.fileData.slug !== "index",
|
||||
}),
|
||||
Component.ArticleTitle(),
|
||||
Component.ContentMeta(),
|
||||
Component.TagList(),
|
||||
@ -32,6 +35,7 @@ export const defaultContentPageLayout: PageLayout = {
|
||||
grow: true,
|
||||
},
|
||||
{ Component: Component.Darkmode() },
|
||||
{ Component: Component.ReaderMode() },
|
||||
],
|
||||
}),
|
||||
Component.Explorer(),
|
||||
|
@ -589,7 +589,8 @@ export async function handleSync(argv) {
|
||||
await popContentFolder(contentFolder)
|
||||
if (argv.push) {
|
||||
console.log("Pushing your changes")
|
||||
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], {
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
|
||||
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, currentBranch], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
|
||||
import { classNames } from "../util/lang"
|
||||
import { trieFromAllFiles } from "../util/ctx"
|
||||
|
||||
type CrumbData = {
|
||||
displayName: string
|
||||
@ -22,10 +22,6 @@ interface BreadcrumbOptions {
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Whether to display breadcrumbs on root `index.md`
|
||||
*/
|
||||
hideOnRoot: boolean
|
||||
/**
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
@ -36,7 +32,6 @@ const defaultOptions: BreadcrumbOptions = {
|
||||
spacerSymbol: "❯",
|
||||
rootName: "Home",
|
||||
resolveFrontmatterTitle: true,
|
||||
hideOnRoot: true,
|
||||
showCurrentPage: true,
|
||||
}
|
||||
|
||||
@ -48,78 +43,37 @@ function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: Simpl
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
// computed index of folder name to its associated file data
|
||||
let folderIndex: Map<string, QuartzPluginData> | undefined
|
||||
|
||||
const Breadcrumbs: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
// Hide crumbs on root if enabled
|
||||
if (options.hideOnRoot && fileData.slug === "index") {
|
||||
return <></>
|
||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const slugParts = fileData.slug!.split("/")
|
||||
const pathNodes = trie.ancestryChain(slugParts)
|
||||
|
||||
if (!pathNodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Format entry for root element
|
||||
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
|
||||
const crumbs: CrumbData[] = [firstEntry]
|
||||
|
||||
if (!folderIndex && options.resolveFrontmatterTitle) {
|
||||
folderIndex = new Map()
|
||||
// construct the index for the first time
|
||||
for (const file of allFiles) {
|
||||
const folderParts = file.slug?.split("/")
|
||||
if (folderParts?.at(-1) === "index") {
|
||||
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split slug into hierarchy/parts
|
||||
const slugParts = fileData.slug?.split("/")
|
||||
if (slugParts) {
|
||||
// is tag breadcrumb?
|
||||
const isTagPath = slugParts[0] === "tags"
|
||||
|
||||
// full path until current part
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < slugParts.length - 1; i++) {
|
||||
let curPathSegment = slugParts[i]
|
||||
|
||||
// Try to resolve frontmatter folder title
|
||||
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
|
||||
if (currentFile) {
|
||||
const title = currentFile.frontmatter!.title
|
||||
if (title !== "index") {
|
||||
curPathSegment = title
|
||||
}
|
||||
}
|
||||
|
||||
// Add current slug to full path
|
||||
currentPath = joinSegments(currentPath, slugParts[i])
|
||||
const includeTrailingSlash = !isTagPath || i < slugParts.length - 1
|
||||
|
||||
// Format and add current crumb
|
||||
const crumb = formatCrumb(
|
||||
curPathSegment,
|
||||
fileData.slug!,
|
||||
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
|
||||
)
|
||||
crumbs.push(crumb)
|
||||
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
|
||||
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
|
||||
if (idx === 0) {
|
||||
crumb.displayName = options.rootName
|
||||
}
|
||||
|
||||
// Add current file to crumb (can directly use frontmatter title)
|
||||
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||
crumbs.push({
|
||||
displayName: fileData.frontmatter!.title,
|
||||
path: "",
|
||||
})
|
||||
// For last node (current page), set empty path
|
||||
if (idx === pathNodes.length - 1) {
|
||||
crumb.path = ""
|
||||
}
|
||||
|
||||
return crumb
|
||||
})
|
||||
|
||||
if (!options.showCurrentPage) {
|
||||
crumbs.pop()
|
||||
}
|
||||
|
||||
return (
|
||||
|
22
quartz/components/ConditionalRender.tsx
Normal file
22
quartz/components/ConditionalRender.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
type ConditionalRenderConfig = {
|
||||
component: QuartzComponent
|
||||
condition: (props: QuartzComponentProps) => boolean
|
||||
}
|
||||
|
||||
export default ((config: ConditionalRenderConfig) => {
|
||||
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
if (config.condition(props)) {
|
||||
return <config.component {...props} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
|
||||
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
|
||||
ConditionalRender.css = config.component.css
|
||||
|
||||
return ConditionalRender
|
||||
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>
|
@ -1,7 +1,7 @@
|
||||
import { i18n } from "../i18n"
|
||||
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
|
||||
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
|
||||
import { googleFontHref } from "../util/theme"
|
||||
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
||||
@ -45,6 +45,9 @@ export default (() => {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
|
||||
{cfg.theme.typography.title && (
|
||||
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { JSX } from "preact"
|
||||
import { randomIdNonSecure } from "../util/random"
|
||||
|
||||
const OverflowList = ({
|
||||
children,
|
||||
@ -13,8 +12,9 @@ const OverflowList = ({
|
||||
)
|
||||
}
|
||||
|
||||
let numExplorers = 0
|
||||
export default () => {
|
||||
const id = randomIdNonSecure()
|
||||
const id = `list-${numExplorers++}`
|
||||
|
||||
return {
|
||||
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
||||
|
@ -7,6 +7,26 @@ import { GlobalConfiguration } from "../cfg"
|
||||
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
|
||||
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
export function byDateAndAlphabeticalFolderFirst(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort folders first
|
||||
const f1IsFolder = isFolderPath(f1.slug ?? "")
|
||||
@ -38,7 +58,7 @@ type Props = {
|
||||
} & QuartzComponentProps
|
||||
|
||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||
const sorter = sort ?? byDateAndAlphabetical(cfg)
|
||||
const sorter = sort ?? byDateAndAlphabeticalFolderFirst(cfg)
|
||||
let list = allFiles.sort(sorter)
|
||||
if (limit) {
|
||||
list = list.slice(0, limit)
|
||||
|
@ -17,6 +17,7 @@ PageTitle.css = `
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
font-family: var(--titleFont);
|
||||
}
|
||||
`
|
||||
|
||||
|
38
quartz/components/ReaderMode.tsx
Normal file
38
quartz/components/ReaderMode.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// @ts-ignore
|
||||
import readerModeScript from "./scripts/readermode.inline"
|
||||
import styles from "./styles/readermode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "readermode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="readerIcon"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
width="64px"
|
||||
height="64px"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={i18n(cfg.locale).components.readerMode.title}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.readerMode.title}</title>
|
||||
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
|
||||
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
ReaderMode.beforeDOMLoaded = readerModeScript
|
||||
ReaderMode.css = styles
|
||||
|
||||
export default (() => ReaderMode) satisfies QuartzComponentConstructor
|
@ -53,17 +53,15 @@ export default ((opts?: Partial<Options>) => {
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
|
||||
<OverflowList>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</OverflowList>
|
||||
</div>
|
||||
<OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,15 +1,14 @@
|
||||
import { pathToRoot, slugTag } from "../util/path"
|
||||
import { FullSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
|
||||
return (
|
||||
<li>
|
||||
<a href={linkDest} class="internal tag-link">
|
||||
|
@ -4,6 +4,7 @@ import FolderContent from "./pages/FolderContent"
|
||||
import NotFound from "./pages/404"
|
||||
import ArticleTitle from "./ArticleTitle"
|
||||
import Darkmode from "./Darkmode"
|
||||
import ReaderMode from "./ReaderMode"
|
||||
import Head from "./Head"
|
||||
import PageTitle from "./PageTitle"
|
||||
import ContentMeta from "./ContentMeta"
|
||||
@ -21,6 +22,7 @@ import RecentNotes from "./RecentNotes"
|
||||
import Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
@ -28,6 +30,7 @@ export {
|
||||
TagContent,
|
||||
FolderContent,
|
||||
Darkmode,
|
||||
ReaderMode,
|
||||
Head,
|
||||
PageTitle,
|
||||
ContentMeta,
|
||||
@ -46,4 +49,5 @@ export {
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Flex,
|
||||
ConditionalRender,
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { trieFromAllFiles } from "../../util/ctx"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
@ -25,31 +26,11 @@ const defaultOptions: FolderContentOptions = {
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
let trie: FileTrieNode<
|
||||
QuartzPluginData & {
|
||||
slug: string
|
||||
title: string
|
||||
filePath: string
|
||||
}
|
||||
>
|
||||
|
||||
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
|
||||
if (!trie) {
|
||||
trie = new FileTrieNode([])
|
||||
allFiles.forEach((file) => {
|
||||
if (file.frontmatter) {
|
||||
trie.add({
|
||||
...file,
|
||||
slug: file.slug!,
|
||||
title: file.frontmatter.title,
|
||||
filePath: file.filePath!,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const folder = trie.findNode(fileData.slug!.split("/"))
|
||||
if (!folder) {
|
||||
return null
|
||||
@ -71,7 +52,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
if (child.data?.dates) {
|
||||
// compare all dates and assign to maybeDates if its more recent or its not set
|
||||
if (!maybeDates) {
|
||||
maybeDates = child.data.dates
|
||||
maybeDates = { ...child.data.dates }
|
||||
} else {
|
||||
if (child.data.dates.created > maybeDates.created) {
|
||||
maybeDates.created = child.data.dates.created
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||
import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
@ -74,10 +74,13 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
? contentPage?.description
|
||||
: htmlToJsx(contentPage.filePath!, root)
|
||||
|
||||
const tagListingPage = `/tags/${tag}` as FullSlug
|
||||
const href = resolveRelative(fileData.slug!, tagListingPage)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<a class="internal tag-link" href={`../tags/${tag}`}>
|
||||
<a class="internal tag-link" href={href}>
|
||||
{tag}
|
||||
</a>
|
||||
</h2>
|
||||
@ -112,8 +115,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article class="popover-hint">{content}</article>
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
|
@ -75,7 +75,7 @@ function renderTranscludes(
|
||||
const classNames = (node.properties?.className ?? []) as string[]
|
||||
if (classNames.includes("transclude")) {
|
||||
const inner = node.children[0] as Element
|
||||
const transcludeTarget = inner.properties["data-slug"] as FullSlug
|
||||
const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
|
||||
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
return
|
||||
|
@ -1,25 +1,10 @@
|
||||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!content) return
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
|
||||
outerBlock.style.maxHeight = height + "px"
|
||||
|
||||
// walk and adjust height of all parents
|
||||
let current = outerBlock
|
||||
let parent = outerBlock.parentElement
|
||||
while (parent) {
|
||||
if (!parent.classList.contains("callout")) {
|
||||
return
|
||||
}
|
||||
|
||||
const collapsed = parent.classList.contains("is-collapsed")
|
||||
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
|
||||
parent.style.maxHeight = height + "px"
|
||||
|
||||
current = parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
@ -27,15 +12,15 @@ function setupCallout() {
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
const title = div.firstElementChild
|
||||
if (!title) continue
|
||||
const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
|
||||
const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!title || !content) continue
|
||||
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
const height = collapsed ? title.scrollHeight : div.scrollHeight
|
||||
div.style.maxHeight = height + "px"
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,11 +23,18 @@ let currentExplorerState: Array<FolderState>
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
const nearestExplorer = this.closest(".explorer") as HTMLElement
|
||||
if (!nearestExplorer) return
|
||||
nearestExplorer.classList.toggle("collapsed")
|
||||
const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
|
||||
nearestExplorer.setAttribute(
|
||||
"aria-expanded",
|
||||
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
|
||||
if (!explorerCollapsed) {
|
||||
// Stop <html> from being scrollable when mobile explorer is open
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
} else {
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
@ -270,12 +277,25 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
if (mobileExplorer.checkVisibility()) {
|
||||
explorer.classList.add("collapsed")
|
||||
explorer.setAttribute("aria-expanded", "false")
|
||||
|
||||
// Allow <html> to be scrollable when mobile explorer is collapsed
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
|
||||
mobileExplorer.classList.remove("hide-until-loaded")
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
// Desktop explorer opens by default, and it stays open when the window is resized
|
||||
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
|
||||
const explorer = document.querySelector(".explorer")
|
||||
if (explorer && !explorer.classList.contains("collapsed")) {
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
|
@ -68,6 +68,30 @@ type TweenNode = {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
// workaround for pixijs webgpu issue: https://github.com/pixijs/pixijs/issues/11389
|
||||
async function determineGraphicsAPI(): Promise<"webgpu" | "webgl"> {
|
||||
const adapter = await navigator.gpu?.requestAdapter().catch(() => null)
|
||||
const device = adapter && (await adapter.requestDevice().catch(() => null))
|
||||
if (!device) {
|
||||
return "webgl"
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas")
|
||||
const gl =
|
||||
(canvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
|
||||
(canvas.getContext("webgl") as WebGLRenderingContext | null)
|
||||
|
||||
// we have to return webgl so pixijs automatically falls back to canvas
|
||||
if (!gl) {
|
||||
return "webgl"
|
||||
}
|
||||
|
||||
const webglMaxTextures = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)
|
||||
const webgpuMaxTextures = device.limits.maxSampledTexturesPerShaderStage
|
||||
|
||||
return webglMaxTextures === webgpuMaxTextures ? "webgpu" : "webgl"
|
||||
}
|
||||
|
||||
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
const slug = simplifySlug(fullSlug)
|
||||
const visited = getVisited()
|
||||
@ -349,6 +373,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
tweens.forEach((tween) => tween.stop())
|
||||
tweens.clear()
|
||||
|
||||
const pixiPreference = await determineGraphicsAPI()
|
||||
const app = new Application()
|
||||
await app.init({
|
||||
width,
|
||||
@ -357,7 +382,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
autoStart: false,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
preference: "webgpu",
|
||||
preference: pixiPreference,
|
||||
resolution: window.devicePixelRatio,
|
||||
eventMode: "static",
|
||||
})
|
||||
|
@ -3,61 +3,70 @@ import { normalizeRelativeURLs } from "../../util/path"
|
||||
import { fetchCanonical } from "./util"
|
||||
|
||||
const p = new DOMParser()
|
||||
let activeAnchor: HTMLAnchorElement | null = null
|
||||
|
||||
async function mouseEnterHandler(
|
||||
this: HTMLAnchorElement,
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = this
|
||||
const link = (activeAnchor = this)
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
strategy: "fixed",
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
|
||||
})
|
||||
}
|
||||
|
||||
const hasAlreadyBeenFetched = () =>
|
||||
[...link.children].some((child) => child.classList.contains("popover"))
|
||||
function showPopover(popoverElement: HTMLElement) {
|
||||
clearActivePopover()
|
||||
popoverElement.classList.add("active-popover")
|
||||
setPosition(popoverElement as HTMLElement)
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (hasAlreadyBeenFetched()) {
|
||||
return setPosition(link.lastChild as HTMLElement)
|
||||
if (hash !== "") {
|
||||
const targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const thisUrl = new URL(document.location.href)
|
||||
thisUrl.hash = ""
|
||||
thisUrl.search = ""
|
||||
const targetUrl = new URL(link.href)
|
||||
const hash = decodeURIComponent(targetUrl.hash)
|
||||
targetUrl.hash = ""
|
||||
targetUrl.search = ""
|
||||
const popoverId = `popover-${link.pathname}`
|
||||
const prevPopoverElement = document.getElementById(popoverId)
|
||||
|
||||
// dont refetch if there's already a popover
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
showPopover(prevPopoverElement as HTMLElement)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetchCanonical(targetUrl).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
// bailout if another popover exists
|
||||
if (hasAlreadyBeenFetched()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!response) return
|
||||
const [contentType] = response.headers.get("Content-Type")!.split(";")
|
||||
const [contentTypeCategory, typeInfo] = contentType.split("/")
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.id = popoverId
|
||||
popoverElement.classList.add("popover")
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
popoverInner.dataset.contentType = contentType ?? undefined
|
||||
popoverElement.appendChild(popoverInner)
|
||||
|
||||
switch (contentTypeCategory) {
|
||||
case "image":
|
||||
@ -82,30 +91,43 @@ async function mouseEnterHandler(
|
||||
const contents = await response.text()
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
// strip all IDs from elements to prevent duplicates
|
||||
html.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"))
|
||||
// prepend all IDs inside popovers to prevent duplicates
|
||||
html.querySelectorAll("[id]").forEach((el) => {
|
||||
const targetID = `popover-internal-${el.id}`
|
||||
el.id = targetID
|
||||
})
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
elts.forEach((elt) => popoverInner.appendChild(elt))
|
||||
}
|
||||
|
||||
setPosition(popoverElement)
|
||||
link.appendChild(popoverElement)
|
||||
|
||||
if (hash !== "") {
|
||||
const heading = popoverInner.querySelector(hash) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
|
||||
}
|
||||
if (!!document.getElementById(popoverId)) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(popoverElement)
|
||||
if (activeAnchor !== this) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopover(popoverElement)
|
||||
}
|
||||
|
||||
function clearActivePopover() {
|
||||
activeAnchor = null
|
||||
const allPopoverElements = document.querySelectorAll(".popover")
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
|
||||
link.addEventListener("mouseleave", clearActivePopover)
|
||||
window.addCleanup(() => {
|
||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||
link.removeEventListener("mouseleave", clearActivePopover)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
25
quartz/components/scripts/readermode.inline.ts
Normal file
25
quartz/components/scripts/readermode.inline.ts
Normal file
@ -0,0 +1,25 @@
|
||||
let isReaderMode = false
|
||||
|
||||
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
|
||||
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
|
||||
detail: { mode },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchReaderMode = () => {
|
||||
isReaderMode = !isReaderMode
|
||||
const newMode = isReaderMode ? "on" : "off"
|
||||
document.documentElement.setAttribute("reader-mode", newMode)
|
||||
emitReaderModeChangeEvent(newMode)
|
||||
}
|
||||
|
||||
for (const readerModeButton of document.getElementsByClassName("readermode")) {
|
||||
readerModeButton.addEventListener("click", switchReaderMode)
|
||||
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
|
||||
})
|
@ -147,8 +147,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
const container = searchElement.querySelector(".search-container") as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (!sidebar) return
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement | null
|
||||
|
||||
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
||||
if (!searchButton) return
|
||||
@ -180,7 +179,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
function hideSearch() {
|
||||
container.classList.remove("active")
|
||||
searchBar.value = "" // clear the input when we dismiss the search
|
||||
sidebar.style.zIndex = ""
|
||||
if (sidebar) sidebar.style.zIndex = ""
|
||||
removeAllChildren(results)
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
@ -192,7 +191,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
|
||||
function showSearch(searchTypeNew: SearchType) {
|
||||
searchType = searchTypeNew
|
||||
sidebar.style.zIndex = "1"
|
||||
if (sidebar) sidebar.style.zIndex = "1"
|
||||
container.classList.add("active")
|
||||
searchBar.focus()
|
||||
}
|
||||
@ -301,9 +300,11 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = resolveUrl(slug).toString()
|
||||
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${
|
||||
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`
|
||||
}`
|
||||
itemTile.innerHTML = `
|
||||
<h3 class="card-title">${title}</h3>
|
||||
${htmlTags}
|
||||
<p class="card-description">${content}</p>
|
||||
`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
|
@ -1,13 +1,13 @@
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
|
||||
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
|
||||
const windowHeight = entry.rootBounds?.height
|
||||
if (windowHeight && tocEntryElement) {
|
||||
if (windowHeight && tocEntryElements.length > 0) {
|
||||
if (entry.boundingClientRect.y < windowHeight) {
|
||||
tocEntryElement.classList.add("in-view")
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
|
||||
} else {
|
||||
tocEntryElement.classList.remove("in-view")
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,10 +8,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > ul {
|
||||
& > ul.overflow {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& > li {
|
||||
& > a {
|
||||
|
@ -6,7 +6,7 @@
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0 10px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
@ -118,6 +118,7 @@ button.desktop-explorer {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
@ -198,6 +199,7 @@ button.desktop-explorer {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
backface-visibility: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
@ -261,22 +263,8 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.mobile-no-scroll {
|
||||
@media all and ($mobile) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
@ -16,9 +16,12 @@
|
||||
|
||||
.popover {
|
||||
z-index: 999;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
|
||||
& > .popover-inner {
|
||||
position: relative;
|
||||
@ -35,7 +38,10 @@
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
& > .popover-inner[data-content-type] {
|
||||
@ -75,7 +81,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
a:hover .popover,
|
||||
.active-popover,
|
||||
.popover:hover {
|
||||
animation: dropin 0.3s ease;
|
||||
animation-fill-mode: forwards;
|
||||
|
34
quartz/components/styles/readermode.scss
Normal file
34
quartz/components/styles/readermode.scss
Normal file
@ -0,0 +1,34 @@
|
||||
.readermode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
stroke: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[reader-mode="on"] {
|
||||
& .sidebar.left,
|
||||
& .sidebar.right {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
@ -133,11 +133,13 @@
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
& > #preview-container {
|
||||
flex-direction: column;
|
||||
|
||||
& > .preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[data-preview] > #results-container {
|
||||
&[data-preview] > .results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 0 0 100%;
|
||||
@ -204,6 +206,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
& > p.card-description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul.tags {
|
||||
margin-top: 0.45rem;
|
||||
margin-bottom: 0;
|
||||
|
@ -3,18 +3,11 @@
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow-y: hidden;
|
||||
min-height: 4rem;
|
||||
flex: 0 1 auto;
|
||||
min-height: 1.4rem;
|
||||
flex: 0 0.5 auto;
|
||||
&:has(button.toc-header.collapsed) {
|
||||
flex: 0 1 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
.toc-header {
|
||||
display: flex;
|
||||
flex: 0 1 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,23 +38,23 @@ button.toc-header {
|
||||
}
|
||||
}
|
||||
|
||||
.toc-content {
|
||||
ul.toc-content.overflow {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,9 @@ export default {
|
||||
explorer: {
|
||||
title: "المستعرض",
|
||||
},
|
||||
readerMode: {
|
||||
title: "وضع القارئ",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "أُنشئ باستخدام",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Mode clar",
|
||||
darkMode: "Mode fosc",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Mode lector",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorador",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Světlý režim",
|
||||
darkMode: "Tmavý režim",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Režim čtečky",
|
||||
},
|
||||
explorer: {
|
||||
title: "Procházet",
|
||||
},
|
||||
|
@ -26,8 +26,11 @@ export default {
|
||||
noBacklinksFound: "Keine Backlinks gefunden",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Light Mode",
|
||||
darkMode: "Dark Mode",
|
||||
lightMode: "Heller Modus",
|
||||
darkMode: "Dunkler Modus",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Lesemodus",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
|
@ -31,6 +31,9 @@ export interface Translation {
|
||||
lightMode: string
|
||||
darkMode: string
|
||||
}
|
||||
readerMode: {
|
||||
title: string
|
||||
}
|
||||
explorer: {
|
||||
title: string
|
||||
}
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Light mode",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Reader mode",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Light mode",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Reader mode",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Modo claro",
|
||||
darkMode: "Modo oscuro",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modo lector",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorador",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "حالت روشن",
|
||||
darkMode: "حالت تاریک",
|
||||
},
|
||||
readerMode: {
|
||||
title: "حالت خواندن",
|
||||
},
|
||||
explorer: {
|
||||
title: "مطالب",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Vaalea tila",
|
||||
darkMode: "Tumma tila",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Lukijatila",
|
||||
},
|
||||
explorer: {
|
||||
title: "Selain",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Mode clair",
|
||||
darkMode: "Mode sombre",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Mode lecture",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorateur",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Világos mód",
|
||||
darkMode: "Sötét mód",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Olvasó mód",
|
||||
},
|
||||
explorer: {
|
||||
title: "Fájlböngésző",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Tema chiaro",
|
||||
darkMode: "Tema scuro",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modalità lettura",
|
||||
},
|
||||
explorer: {
|
||||
title: "Esplora",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "ライトモード",
|
||||
darkMode: "ダークモード",
|
||||
},
|
||||
readerMode: {
|
||||
title: "リーダーモード",
|
||||
},
|
||||
explorer: {
|
||||
title: "エクスプローラー",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "라이트 모드",
|
||||
darkMode: "다크 모드",
|
||||
},
|
||||
readerMode: {
|
||||
title: "리더 모드",
|
||||
},
|
||||
explorer: {
|
||||
title: "탐색기",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Šviesus Režimas",
|
||||
darkMode: "Tamsus Režimas",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modalità lettore",
|
||||
},
|
||||
explorer: {
|
||||
title: "Naršyklė",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Lys modus",
|
||||
darkMode: "Mørk modus",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Læsemodus",
|
||||
},
|
||||
explorer: {
|
||||
title: "Utforsker",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Lichte modus",
|
||||
darkMode: "Donkere modus",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Leesmodus",
|
||||
},
|
||||
explorer: {
|
||||
title: "Verkenner",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Trzyb jasny",
|
||||
darkMode: "Tryb ciemny",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Tryb czytania",
|
||||
},
|
||||
explorer: {
|
||||
title: "Przeglądaj",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Tema claro",
|
||||
darkMode: "Tema escuro",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modo leitor",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorador",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Modul luminos",
|
||||
darkMode: "Modul întunecat",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modul de citire",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorator",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Светлый режим",
|
||||
darkMode: "Тёмный режим",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Режим чтения",
|
||||
},
|
||||
explorer: {
|
||||
title: "Проводник",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "โหมดสว่าง",
|
||||
darkMode: "โหมดมืด",
|
||||
},
|
||||
readerMode: {
|
||||
title: "โหมดอ่าน",
|
||||
},
|
||||
explorer: {
|
||||
title: "รายการหน้า",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Açık mod",
|
||||
darkMode: "Koyu mod",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Okuma modu",
|
||||
},
|
||||
explorer: {
|
||||
title: "Gezgin",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Світлий режим",
|
||||
darkMode: "Темний режим",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Режим читання",
|
||||
},
|
||||
explorer: {
|
||||
title: "Провідник",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "Sáng",
|
||||
darkMode: "Tối",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Chế độ đọc",
|
||||
},
|
||||
explorer: {
|
||||
title: "Trong bài này",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "亮色模式",
|
||||
darkMode: "暗色模式",
|
||||
},
|
||||
readerMode: {
|
||||
title: "阅读模式",
|
||||
},
|
||||
explorer: {
|
||||
title: "探索",
|
||||
},
|
||||
|
@ -29,6 +29,9 @@ export default {
|
||||
lightMode: "亮色模式",
|
||||
darkMode: "暗色模式",
|
||||
},
|
||||
readerMode: {
|
||||
title: "閱讀模式",
|
||||
},
|
||||
explorer: {
|
||||
title: "探索",
|
||||
},
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { VFile } from "vfile"
|
||||
import path from "path"
|
||||
|
||||
async function* processFile(ctx: BuildCtx, file: VFile) {
|
||||
const ogSlug = simplifySlug(file.data.slug!)
|
||||
|
||||
for (const slug of file.data.aliases ?? []) {
|
||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||
for (const aliasTarget of file.data.aliases ?? []) {
|
||||
const aliasTargetSlug = (
|
||||
isRelativeURL(aliasTarget)
|
||||
? path.normalize(path.join(ogSlug, "..", aliasTarget))
|
||||
: aliasTarget
|
||||
) as FullSlug
|
||||
|
||||
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug)
|
||||
yield write({
|
||||
ctx,
|
||||
content: `
|
||||
@ -23,7 +30,7 @@ async function* processFile(ctx: BuildCtx, file: VFile) {
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
slug,
|
||||
slug: aliasTargetSlug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
@ -9,7 +9,12 @@ import styles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { googleFontHref, joinStyles, processGoogleFonts } from "../../util/theme"
|
||||
import {
|
||||
googleFontHref,
|
||||
googleFontSubsetHref,
|
||||
joinStyles,
|
||||
processGoogleFonts,
|
||||
} from "../../util/theme"
|
||||
import { Features, transform } from "lightningcss"
|
||||
import { transform as transpile } from "esbuild"
|
||||
import { write } from "./helpers"
|
||||
@ -83,89 +88,102 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
|
||||
if (cfg.analytics?.provider === "google") {
|
||||
const tagId = cfg.analytics.tagId
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const gtagScript = document.createElement("script")
|
||||
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}"
|
||||
gtagScript.defer = true
|
||||
document.head.appendChild(gtagScript)
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag("js", new Date());
|
||||
gtag("config", "${tagId}", { send_page_view: false });
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
gtag("event", "page_view", {
|
||||
page_title: document.title,
|
||||
page_location: location.href,
|
||||
const gtagScript = document.createElement('script');
|
||||
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
|
||||
gtagScript.defer = true;
|
||||
gtagScript.onload = () => {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${tagId}', { send_page_view: false });
|
||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
||||
document.addEventListener('nav', () => {
|
||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
||||
});
|
||||
});`)
|
||||
};
|
||||
|
||||
document.head.appendChild(gtagScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "plausible") {
|
||||
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const plausibleScript = document.createElement("script")
|
||||
plausibleScript.src = "${plausibleHost}/js/script.manual.js"
|
||||
plausibleScript.setAttribute("data-domain", location.hostname)
|
||||
plausibleScript.defer = true
|
||||
document.head.appendChild(plausibleScript)
|
||||
const plausibleScript = document.createElement('script');
|
||||
plausibleScript.src = '${plausibleHost}/js/script.manual.js';
|
||||
plausibleScript.setAttribute('data-domain', location.hostname);
|
||||
plausibleScript.defer = true;
|
||||
plausibleScript.onload = () => {
|
||||
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
|
||||
plausible('pageview');
|
||||
document.addEventListener('nav', () => {
|
||||
plausible('pageview');
|
||||
});
|
||||
};
|
||||
|
||||
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
plausible("pageview")
|
||||
})
|
||||
document.head.appendChild(plausibleScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "umami") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
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.defer = true
|
||||
document.head.appendChild(umamiScript)
|
||||
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", "true");
|
||||
umamiScript.defer = true;
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
umami.track();
|
||||
})
|
||||
document.head.appendChild(umamiScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "goatcounter") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const goatcounterScript = document.createElement("script")
|
||||
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
|
||||
goatcounterScript.defer = true
|
||||
goatcounterScript.setAttribute("data-goatcounter",
|
||||
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
|
||||
document.head.appendChild(goatcounterScript)
|
||||
const goatcounterScript = document.createElement('script');
|
||||
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
|
||||
goatcounterScript.defer = true;
|
||||
goatcounterScript.setAttribute(
|
||||
'data-goatcounter',
|
||||
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count"
|
||||
);
|
||||
goatcounterScript.onload = () => {
|
||||
window.goatcounter = { no_onload: true };
|
||||
goatcounter.count({ path: location.pathname });
|
||||
document.addEventListener('nav', () => {
|
||||
goatcounter.count({ path: location.pathname });
|
||||
});
|
||||
};
|
||||
|
||||
window.goatcounter = { no_onload: true }
|
||||
document.addEventListener("nav", () => {
|
||||
goatcounter.count({ path: location.pathname })
|
||||
})
|
||||
document.head.appendChild(goatcounterScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "posthog") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const posthogScript = document.createElement("script")
|
||||
const posthogScript = document.createElement("script");
|
||||
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('${cfg.analytics.apiKey}', {
|
||||
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
|
||||
capture_pageview: false,
|
||||
})\`
|
||||
document.head.appendChild(posthogScript)
|
||||
posthogScript.onload = () => {
|
||||
posthog.capture('$pageview', { path: location.pathname });
|
||||
|
||||
document.addEventListener('nav', () => {
|
||||
posthog.capture('$pageview', { path: location.pathname });
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
posthog.capture('$pageview', { path: location.pathname })
|
||||
})
|
||||
document.head.appendChild(posthogScript);
|
||||
`)
|
||||
} 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?spa"
|
||||
tinylyticsScript.defer = true
|
||||
document.head.appendChild(tinylyticsScript)
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
window.tinylytics.triggerUpdate()
|
||||
})
|
||||
const tinylyticsScript = document.createElement('script');
|
||||
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
|
||||
tinylyticsScript.defer = true;
|
||||
tinylyticsScript.onload = () => {
|
||||
window.tinylytics.triggerUpdate();
|
||||
document.addEventListener('nav', () => {
|
||||
window.tinylytics.triggerUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(tinylyticsScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "cabin") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
@ -211,9 +229,16 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
|
||||
// let the user do it themselves in css
|
||||
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
|
||||
// when cdnCaching is true, we link to google fonts in Head.tsx
|
||||
const response = await fetch(googleFontHref(ctx.cfg.configuration.theme))
|
||||
const theme = ctx.cfg.configuration.theme
|
||||
const response = await fetch(googleFontHref(theme))
|
||||
googleFontsStyleSheet = await response.text()
|
||||
|
||||
if (theme.typography.title) {
|
||||
const title = ctx.cfg.configuration.pageTitle
|
||||
const response = await fetch(googleFontSubsetHref(theme, title))
|
||||
googleFontsStyleSheet += `\n${await response.text()}`
|
||||
}
|
||||
|
||||
if (!cfg.baseUrl) {
|
||||
throw new Error(
|
||||
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
|
||||
|
22
quartz/plugins/emitters/favicon.ts
Normal file
22
quartz/plugins/emitters/favicon.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import sharp from "sharp"
|
||||
import { joinSegments, QUARTZ, FullSlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
|
||||
export const Favicon: QuartzEmitterPlugin = () => ({
|
||||
name: "Favicon",
|
||||
async *emit({ argv }) {
|
||||
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
||||
|
||||
const faviconContent = sharp(iconPath).resize(48, 48).toFormat("png")
|
||||
|
||||
yield write({
|
||||
ctx: { argv } as BuildCtx,
|
||||
slug: "favicon" as FullSlug,
|
||||
ext: ".ico",
|
||||
content: faviconContent,
|
||||
})
|
||||
},
|
||||
async *partialEmit() {},
|
||||
})
|
@ -5,6 +5,7 @@ export { ContentIndex as ContentIndex } from "./contentIndex"
|
||||
export { AliasRedirects } from "./aliases"
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
export { Favicon } from "./favicon"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
export { NotFoundPage } from "./404"
|
||||
export { CNAME } from "./cname"
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { i18n } from "../../i18n"
|
||||
import { unescapeHTML } from "../../util/escape"
|
||||
import { FullSlug, getFileExtension } from "../../util/path"
|
||||
import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
|
||||
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
|
||||
import sharp from "sharp"
|
||||
import satori, { SatoriOptions } from "satori"
|
||||
@ -10,6 +10,8 @@ import { Readable } from "stream"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import fs from "node:fs/promises"
|
||||
import chalk from "chalk"
|
||||
|
||||
const defaultOptions: SocialImageOptions = {
|
||||
colorScheme: "lightMode",
|
||||
@ -28,15 +30,34 @@ async function generateSocialImage(
|
||||
userOpts: SocialImageOptions,
|
||||
): Promise<Readable> {
|
||||
const { width, height } = userOpts
|
||||
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
|
||||
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
||||
let iconBase64: string | undefined = undefined
|
||||
try {
|
||||
const iconData = await fs.readFile(iconPath)
|
||||
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`
|
||||
} catch (err) {
|
||||
console.warn(chalk.yellow(`Warning: Could not find icon at ${iconPath}`))
|
||||
}
|
||||
|
||||
const imageComponent = userOpts.imageStructure({
|
||||
cfg,
|
||||
userOpts,
|
||||
title,
|
||||
description,
|
||||
fonts,
|
||||
fileData,
|
||||
iconBase64,
|
||||
})
|
||||
|
||||
const svg = await satori(imageComponent, {
|
||||
width,
|
||||
height,
|
||||
fonts,
|
||||
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
||||
if (languageCode === "emoji") {
|
||||
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}`
|
||||
return await loadEmoji(getIconCode(segment))
|
||||
}
|
||||
|
||||
return languageCode
|
||||
},
|
||||
})
|
||||
@ -124,13 +145,19 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
|
||||
additionalHead: [
|
||||
(pageData) => {
|
||||
const isRealFile = pageData.filePath !== undefined
|
||||
const userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
||||
let userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
||||
|
||||
if (userDefinedOgImagePath) {
|
||||
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
|
||||
? userDefinedOgImagePath
|
||||
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
|
||||
}
|
||||
|
||||
const generatedOgImagePath = isRealFile
|
||||
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
|
||||
: undefined
|
||||
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
|
||||
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
|
||||
|
||||
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
|
||||
return (
|
||||
<>
|
||||
|
@ -114,6 +114,10 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
|
||||
|
||||
if (socialImage) data.socialImage = socialImage
|
||||
|
||||
// Remove duplicate slugs
|
||||
const uniqueSlugs = [...new Set(allSlugs)]
|
||||
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
||||
|
||||
// fill in frontmatter
|
||||
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { Repository } from "@napi-rs/simple-git"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import chalk from "chalk"
|
||||
import path from "path"
|
||||
|
||||
export interface Options {
|
||||
priority: ("frontmatter" | "git" | "filesystem")[]
|
||||
@ -35,13 +35,25 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
||||
return [
|
||||
() => {
|
||||
let repo: Repository | undefined = undefined
|
||||
let repositoryWorkdir: string
|
||||
if (opts.priority.includes("git")) {
|
||||
try {
|
||||
repo = Repository.discover(ctx.argv.directory)
|
||||
repositoryWorkdir = repo.workdir() ?? ctx.argv.directory
|
||||
} catch (e) {
|
||||
console.log(
|
||||
chalk.yellow(`\nWarning: couldn't find git repository for ${ctx.argv.directory}`),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return async (_tree, file) => {
|
||||
let created: MaybeDate = undefined
|
||||
let modified: MaybeDate = undefined
|
||||
let published: MaybeDate = undefined
|
||||
|
||||
const fp = file.data.relativePath!
|
||||
const fullFp = path.posix.join(ctx.argv.directory, fp)
|
||||
const fullFp = file.data.filePath!
|
||||
for (const source of opts.priority) {
|
||||
if (source === "filesystem") {
|
||||
const st = await fs.promises.stat(fullFp)
|
||||
@ -51,21 +63,14 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
||||
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.
|
||||
// It's either the same as the workdir,
|
||||
// or 1+ level higher in case of a submodule/subtree setup
|
||||
repo = Repository.discover(ctx.argv.directory)
|
||||
}
|
||||
|
||||
} else if (source === "git" && repo) {
|
||||
try {
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(fullFp)
|
||||
const relativePath = path.relative(repositoryWorkdir, fullFp)
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(relativePath)
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: ${file.data
|
||||
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
|
||||
`\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -191,7 +191,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||
|
||||
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
|
||||
const blockRef = Boolean(rawHeader?.match(/^#?\^/)) ? "^" : ""
|
||||
const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : ""
|
||||
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
|
||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||
@ -222,7 +222,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
let [rawFp, rawHeader, rawAlias] = capture
|
||||
const fp = rawFp?.trim() ?? ""
|
||||
const anchor = rawHeader?.trim() ?? ""
|
||||
const alias = rawAlias?.slice(1).trim()
|
||||
const alias: string | undefined = rawAlias?.slice(1).trim()
|
||||
|
||||
// embed cases
|
||||
if (value.startsWith("!")) {
|
||||
@ -464,6 +464,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
})
|
||||
}
|
||||
|
||||
// For the rest of the MD callout elements other than the title, wrap them with
|
||||
// two nested HTML <div>s (use some hacked mdhast component to achieve this) of
|
||||
// class `callout-content` and `callout-content-inner` respectively for
|
||||
// grid-based collapsible animation.
|
||||
if (calloutContent.length > 0) {
|
||||
node.children = [
|
||||
node.children[0],
|
||||
{
|
||||
data: { hProperties: { className: ["callout-content"] }, hName: "div" },
|
||||
type: "blockquote",
|
||||
children: [
|
||||
{
|
||||
data: {
|
||||
hProperties: { className: ["callout-content-inner"] },
|
||||
hName: "div",
|
||||
},
|
||||
type: "blockquote",
|
||||
children: [...calloutContent],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// replace first line of blockquote with title and rest of the paragraph text
|
||||
node.children.splice(0, 1, ...blockquoteContent)
|
||||
|
||||
@ -485,21 +509,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
|
||||
"data-callout-metadata": calloutMetaData,
|
||||
},
|
||||
}
|
||||
|
||||
// Add callout-content class to callout body if it has one.
|
||||
if (calloutContent.length > 0) {
|
||||
const contentData: BlockContent | DefinitionContent = {
|
||||
data: {
|
||||
hProperties: {
|
||||
className: "callout-content",
|
||||
},
|
||||
hName: "div",
|
||||
},
|
||||
type: "blockquote",
|
||||
children: [...calloutContent],
|
||||
}
|
||||
node.children = [node.children[0], contentData]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -172,7 +172,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
||||
workerType: "thread",
|
||||
})
|
||||
const errorHandler = (err: any) => {
|
||||
console.error(`${err}`.replace(/^error:\s*/i, ""))
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@ -201,7 +201,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
|
||||
|
||||
const markdownToHtmlPromises: WorkerPromise<ProcessedContent[]>[] = []
|
||||
processedFiles = 0
|
||||
for (const [mdChunk, _] of mdResults) {
|
||||
for (const mdChunk of mdResults) {
|
||||
markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk]))
|
||||
}
|
||||
const results: ProcessedContent[][] = await Promise.all(
|
||||
|
@ -65,6 +65,21 @@ ul,
|
||||
}
|
||||
}
|
||||
|
||||
article {
|
||||
> mjx-container.MathJax,
|
||||
blockquote > div > mjx-container.MathJax {
|
||||
display: flex;
|
||||
> svg {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
blockquote > div > mjx-container.MathJax > svg {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: $semiBoldWeight;
|
||||
}
|
||||
@ -223,6 +238,7 @@ a {
|
||||
padding: 0;
|
||||
& > * {
|
||||
flex: 1;
|
||||
max-height: 24rem;
|
||||
}
|
||||
& > .toc {
|
||||
display: none;
|
||||
@ -546,8 +562,8 @@ video {
|
||||
}
|
||||
|
||||
div:has(> .overflow) {
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
ul.overflow,
|
||||
@ -562,7 +578,7 @@ ol.overflow {
|
||||
clear: both;
|
||||
|
||||
& > li.overflow-end {
|
||||
height: 1rem;
|
||||
height: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
@ -7,11 +7,19 @@
|
||||
border-radius: 5px;
|
||||
padding: 0 1rem;
|
||||
overflow-y: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
& > .callout-content > :first-child {
|
||||
margin-top: 0;
|
||||
& > .callout-content {
|
||||
display: grid;
|
||||
transition: grid-template-rows 0.3s ease;
|
||||
|
||||
& > .callout-content-inner {
|
||||
overflow: hidden;
|
||||
|
||||
& > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
--callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { QuartzConfig } from "../cfg"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { FileTrieNode } from "./fileTrie"
|
||||
import { FilePath, FullSlug } from "./path"
|
||||
|
||||
export interface Argv {
|
||||
@ -13,13 +15,36 @@ export interface Argv {
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
export type BuildTimeTrieData = QuartzPluginData & {
|
||||
slug: string
|
||||
title: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface BuildCtx {
|
||||
buildId: string
|
||||
argv: Argv
|
||||
cfg: QuartzConfig
|
||||
allSlugs: FullSlug[]
|
||||
allFiles: FilePath[]
|
||||
trie?: FileTrieNode<BuildTimeTrieData>
|
||||
incremental: boolean
|
||||
}
|
||||
|
||||
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg">
|
||||
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
|
||||
const trie = new FileTrieNode<BuildTimeTrieData>([])
|
||||
allFiles.forEach((file) => {
|
||||
if (file.frontmatter) {
|
||||
trie.add({
|
||||
...file,
|
||||
slug: file.slug!,
|
||||
title: file.frontmatter.title,
|
||||
filePath: file.filePath!,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return trie
|
||||
}
|
||||
|
||||
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">
|
||||
|
@ -25,14 +25,23 @@ function toCodePoint(unicodeSurrogates: string) {
|
||||
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()))
|
||||
type EmojiMap = {
|
||||
codePointToName: Record<string, string>
|
||||
nameToBase64: Record<string, string>
|
||||
}
|
||||
|
||||
let emojimap: EmojiMap | undefined = undefined
|
||||
export async function loadEmoji(code: string) {
|
||||
if (!emojimap) {
|
||||
const data = await import("./emojimap.json")
|
||||
emojimap = data
|
||||
}
|
||||
|
||||
const name = emojimap.codePointToName[`U+${code.toUpperCase()}`]
|
||||
if (!name) throw new Error(`codepoint ${code} not found in map`)
|
||||
|
||||
const b64 = emojimap.nameToBase64[name]
|
||||
if (!b64) throw new Error(`name ${name} not found in map`)
|
||||
|
||||
return b64
|
||||
}
|
||||
|
3190
quartz/util/emojimap.json
Normal file
3190
quartz/util/emojimap.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -330,4 +330,86 @@ describe("FileTrie", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pathToNode", () => {
|
||||
test("should return root node for empty path", () => {
|
||||
const data = { title: "Root", slug: "index", filePath: "index.md" }
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain([])
|
||||
assert.deepStrictEqual(path, [trie])
|
||||
})
|
||||
|
||||
test("should return root node for index path", () => {
|
||||
const data = { title: "Root", slug: "index", filePath: "index.md" }
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain(["index"])
|
||||
assert.deepStrictEqual(path, [trie])
|
||||
})
|
||||
|
||||
test("should return path to first level node", () => {
|
||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain(["test"])
|
||||
assert.deepStrictEqual(path, [trie, trie.children[0]])
|
||||
})
|
||||
|
||||
test("should return path to nested node", () => {
|
||||
const data = {
|
||||
title: "Nested",
|
||||
slug: "folder/subfolder/test",
|
||||
filePath: "folder/subfolder/test.md",
|
||||
}
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain(["folder", "subfolder", "test"])
|
||||
assert.deepStrictEqual(path, [
|
||||
trie,
|
||||
trie.children[0],
|
||||
trie.children[0].children[0],
|
||||
trie.children[0].children[0].children[0],
|
||||
])
|
||||
})
|
||||
|
||||
test("should return undefined for non-existent path", () => {
|
||||
const data = { title: "Test", slug: "test", filePath: "test.md" }
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain(["nonexistent"])
|
||||
assert.strictEqual(path, undefined)
|
||||
})
|
||||
|
||||
test("should return file data for intermediate folders", () => {
|
||||
const data1 = {
|
||||
title: "Root",
|
||||
slug: "index",
|
||||
filePath: "index.md",
|
||||
}
|
||||
const data2 = {
|
||||
title: "Test",
|
||||
slug: "folder/subfolder/test",
|
||||
filePath: "folder/subfolder/test.md",
|
||||
}
|
||||
const data3 = {
|
||||
title: "Folder Index",
|
||||
slug: "folder/index",
|
||||
filePath: "folder/index.md",
|
||||
}
|
||||
|
||||
trie.add(data1)
|
||||
trie.add(data2)
|
||||
trie.add(data3)
|
||||
const path = trie.ancestryChain(["folder", "subfolder"])
|
||||
assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
|
||||
assert.strictEqual(path[1].data, data3)
|
||||
})
|
||||
|
||||
test("should return path for partial path", () => {
|
||||
const data = {
|
||||
title: "Nested",
|
||||
slug: "folder/subfolder/test",
|
||||
filePath: "folder/subfolder/test.md",
|
||||
}
|
||||
trie.add(data)
|
||||
const path = trie.ancestryChain(["folder"])
|
||||
assert.deepStrictEqual(path, [trie, trie.children[0]])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -97,6 +97,24 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
||||
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
|
||||
}
|
||||
|
||||
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
|
||||
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
|
||||
return [this]
|
||||
}
|
||||
|
||||
const child = this.children.find((c) => c.slugSegment === path[0])
|
||||
if (!child) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const childPath = child.ancestryChain(path.slice(1))
|
||||
if (!childPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return [this, ...childPath]
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||
*/
|
||||
|
@ -35,7 +35,7 @@ export class QuartzLogger {
|
||||
const truncated = truncate(output, columns)
|
||||
process.stdout.write(truncated)
|
||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
|
||||
}, 20)
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import chalk from "chalk"
|
||||
|
||||
const defaultHeaderWeight = [700]
|
||||
const defaultBodyWeight = [400]
|
||||
|
||||
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
|
||||
// Get all weights for header and body fonts
|
||||
const headerWeights: FontWeight[] = (
|
||||
@ -134,21 +135,12 @@ export type SocialImageOptions = {
|
||||
excludeRoot: boolean
|
||||
/**
|
||||
* JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
|
||||
* @param cfg global quartz config
|
||||
* @param userOpts options that can be set by user
|
||||
* @param title title of current page
|
||||
* @param description description of current page
|
||||
* @param fonts global font that can be used for styling
|
||||
* @param fileData full fileData of current page
|
||||
* @returns prepared jsx to be used for generating image
|
||||
*/
|
||||
imageStructure: (
|
||||
cfg: GlobalConfiguration,
|
||||
userOpts: UserOpts,
|
||||
title: string,
|
||||
description: string,
|
||||
fonts: SatoriOptions["fonts"],
|
||||
fileData: QuartzPluginData,
|
||||
options: ImageOptions & {
|
||||
userOpts: UserOpts
|
||||
iconBase64?: string
|
||||
},
|
||||
) => JSXInternal.Element
|
||||
}
|
||||
|
||||
@ -178,17 +170,17 @@ export type ImageOptions = {
|
||||
}
|
||||
|
||||
// This is the default template for generated social image.
|
||||
export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
cfg: GlobalConfiguration,
|
||||
{ colorScheme }: UserOpts,
|
||||
title: string,
|
||||
description: string,
|
||||
_fonts: SatoriOptions["fonts"],
|
||||
fileData: QuartzPluginData,
|
||||
) => {
|
||||
export const defaultImage: SocialImageOptions["imageStructure"] = ({
|
||||
cfg,
|
||||
userOpts,
|
||||
title,
|
||||
description,
|
||||
fileData,
|
||||
iconBase64,
|
||||
}) => {
|
||||
const { colorScheme } = userOpts
|
||||
const fontBreakPoint = 32
|
||||
const useSmallerFont = title.length > fontBreakPoint
|
||||
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
|
||||
|
||||
// Format date if available
|
||||
const rawDate = getDate(cfg, fileData)
|
||||
@ -226,14 +218,16 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={iconPath}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
{iconBase64 && (
|
||||
<img
|
||||
src={iconBase64}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import test, { describe } from "node:test"
|
||||
import * as path from "./path"
|
||||
import assert from "node:assert"
|
||||
import { FullSlug, TransformOptions } from "./path"
|
||||
import { FullSlug, TransformOptions, SimpleSlug } from "./path"
|
||||
|
||||
describe("typeguards", () => {
|
||||
test("isSimpleSlug", () => {
|
||||
@ -38,6 +38,17 @@ describe("typeguards", () => {
|
||||
assert(!path.isRelativeURL("./abc/def.md"))
|
||||
})
|
||||
|
||||
test("isAbsoluteURL", () => {
|
||||
assert(path.isAbsoluteURL("https://example.com"))
|
||||
assert(path.isAbsoluteURL("http://example.com"))
|
||||
assert(path.isAbsoluteURL("ftp://example.com/a/b/c"))
|
||||
assert(path.isAbsoluteURL("http://host/%25"))
|
||||
assert(path.isAbsoluteURL("file://host/twoslashes?more//slashes"))
|
||||
|
||||
assert(!path.isAbsoluteURL("example.com/abc/def"))
|
||||
assert(!path.isAbsoluteURL("abc"))
|
||||
})
|
||||
|
||||
test("isFullSlug", () => {
|
||||
assert(path.isFullSlug("index"))
|
||||
assert(path.isFullSlug("abc/def"))
|
||||
@ -303,3 +314,50 @@ describe("link strategies", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRelative", () => {
|
||||
test("from index", () => {
|
||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "index" as FullSlug), "./")
|
||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc" as FullSlug), "./abc")
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("index" as FullSlug, "abc/def" as FullSlug),
|
||||
"./abc/def",
|
||||
)
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("index" as FullSlug, "abc/def/ghi" as FullSlug),
|
||||
"./abc/def/ghi",
|
||||
)
|
||||
})
|
||||
|
||||
test("from nested page", () => {
|
||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "index" as FullSlug), "../")
|
||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "abc" as FullSlug), "../abc")
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("abc/def" as FullSlug, "abc/def" as FullSlug),
|
||||
"../abc/def",
|
||||
)
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("abc/def" as FullSlug, "ghi/jkl" as FullSlug),
|
||||
"../ghi/jkl",
|
||||
)
|
||||
})
|
||||
|
||||
test("with index paths", () => {
|
||||
assert.strictEqual(path.resolveRelative("abc/index" as FullSlug, "index" as FullSlug), "../")
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("abc/def/index" as FullSlug, "index" as FullSlug),
|
||||
"../../",
|
||||
)
|
||||
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc/index" as FullSlug), "./abc/")
|
||||
assert.strictEqual(
|
||||
path.resolveRelative("abc/def" as FullSlug, "abc/index" as FullSlug),
|
||||
"../abc/",
|
||||
)
|
||||
})
|
||||
|
||||
test("with simple slugs", () => {
|
||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "" as SimpleSlug), "../")
|
||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi" as SimpleSlug), "../ghi")
|
||||
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi/" as SimpleSlug), "../ghi/")
|
||||
})
|
||||
})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import type { Element as HastElement } from "hast"
|
||||
import { clone } from "./clone"
|
||||
|
||||
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||
|
||||
export const QUARTZ = "quartz"
|
||||
@ -39,6 +40,15 @@ export function isRelativeURL(s: string): s is RelativeURL {
|
||||
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
|
||||
}
|
||||
|
||||
export function isAbsoluteURL(s: string): boolean {
|
||||
try {
|
||||
new URL(s)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function getFullSlug(window: Window): FullSlug {
|
||||
const res = window.document.body.dataset.slug! as FullSlug
|
||||
return res
|
||||
|
@ -25,6 +25,7 @@ export type FontSpecification =
|
||||
|
||||
export interface Theme {
|
||||
typography: {
|
||||
title?: FontSpecification
|
||||
header: FontSpecification
|
||||
body: FontSpecification
|
||||
code: FontSpecification
|
||||
@ -48,7 +49,10 @@ export function getFontSpecificationName(spec: FontSpecification): string {
|
||||
return spec.name
|
||||
}
|
||||
|
||||
function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpecification) {
|
||||
function formatFontSpecification(
|
||||
type: "title" | "header" | "body" | "code",
|
||||
spec: FontSpecification,
|
||||
) {
|
||||
if (typeof spec === "string") {
|
||||
spec = { name: spec }
|
||||
}
|
||||
@ -82,12 +86,19 @@ function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpe
|
||||
}
|
||||
|
||||
export function googleFontHref(theme: Theme) {
|
||||
const { code, header, body } = theme.typography
|
||||
const { header, body, code } = theme.typography
|
||||
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`
|
||||
return `https://fonts.googleapis.com/css2?family=${headerFont}&family=${bodyFont}&family=${codeFont}&display=swap`
|
||||
}
|
||||
|
||||
export function googleFontSubsetHref(theme: Theme, text: string) {
|
||||
const title = theme.typography.title || theme.typography.header
|
||||
const titleFont = formatFontSpecification("title", title)
|
||||
|
||||
return `https://fonts.googleapis.com/css2?family=${titleFont}&text=${encodeURIComponent(text)}&display=swap`
|
||||
}
|
||||
|
||||
export interface GoogleFontFile {
|
||||
@ -96,6 +107,13 @@ export interface GoogleFontFile {
|
||||
extension: string
|
||||
}
|
||||
|
||||
const fontMimeMap: Record<string, string> = {
|
||||
truetype: "ttf",
|
||||
woff: "woff",
|
||||
woff2: "woff2",
|
||||
opentype: "otf",
|
||||
}
|
||||
|
||||
export async function processGoogleFonts(
|
||||
stylesheet: string,
|
||||
baseUrl: string,
|
||||
@ -103,14 +121,16 @@ export async function processGoogleFonts(
|
||||
processedStylesheet: string
|
||||
fontFiles: GoogleFontFile[]
|
||||
}> {
|
||||
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
|
||||
const fontSourceRegex =
|
||||
/url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g
|
||||
const fontFiles: GoogleFontFile[] = []
|
||||
let processedStylesheet = stylesheet
|
||||
|
||||
let match
|
||||
while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
|
||||
const url = match[1]
|
||||
const [filename, extension] = url.split("/").pop()!.split(".")
|
||||
const filename = match[2]
|
||||
const extension = fontMimeMap[match[3].toLowerCase()]
|
||||
const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
|
||||
|
||||
processedStylesheet = processedStylesheet.replace(url, staticUrl)
|
||||
@ -135,6 +155,7 @@ ${stylesheet.join("\n\n")}
|
||||
--highlight: ${theme.colors.lightMode.highlight};
|
||||
--textHighlight: ${theme.colors.lightMode.textHighlight};
|
||||
|
||||
--titleFont: "${getFontSpecificationName(theme.typography.title || theme.typography.header)}", ${DEFAULT_SANS_SERIF};
|
||||
--headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF};
|
||||
--bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF};
|
||||
--codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO};
|
||||
|
Loading…
x
Reference in New Issue
Block a user