Compare commits

...

79 Commits

Author SHA1 Message Date
Emile Bangma
e31527ac74 Prefer compatibility 2025-02-01 18:02:00 +00:00
Emile Bangma
9786ead130 Copilot nitpick 2025-02-01 17:57:17 +00:00
Emile Bangma
849e8accfc Remove unused parameter 2025-02-01 17:56:34 +00:00
Emile Bangma
a1c36ec01f SASS styling fixes 2025-02-01 17:32:15 +00:00
Emile Bangma
9b8dec0c7c
Merge branch 'v4' into explorer 2025-02-01 18:20:24 +01:00
dependabot[bot]
7be47742a6
chore(deps): bump the production-dependencies group across 1 directory with 3 updates (#1744)
Some checks failed
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Build and Test / publish-tag (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-31 06:46:45 -05:00
Anton Bulakh
4e4930ef9c
chore(styles): omit sass deprecation warnings (#1737)
update to newer API
2025-01-23 22:19:46 -05:00
Mantvydas Deltuva
01943ff5a0
feat(i18n): Lithuanian (#1733) 2025-01-21 00:20:02 -05:00
Patsagorn Y.
8cf3e3001f
feat(i18n): Thai translations (#1722) 2025-01-16 15:44:33 -05:00
dependabot[bot]
992ac00f7c
chore(deps): bump the production-dependencies group with 7 updates (#1719)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-14 00:02:49 -05:00
dependabot[bot]
09f8670db7
chore(deps): bump the production-dependencies group with 4 updates (#1711)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2025-01-07 20:32:02 -05:00
Aaron Pham
7e828252bb
fix(path): handle lone slash (#1713) 2025-01-07 16:00:09 -05:00
Anton Bulakh
c90dbacab0
chore(build): separate markdown and html handling into two separate stages (#1675) 2025-01-07 15:33:34 -05:00
Anton Bulakh
b7a945e034
fix(tags): Dont consume a space before content tags (#1706) 2025-01-05 01:11:15 -05:00
Akihiro Saiki
dc3323b574
chore: use Google Fonts API v2 to get fonts for ogp (#1705) 2025-01-04 09:45:53 -08:00
Aaron Pham
c98ef7e89f
perf(img): lazy load chunks (#1700) 2025-01-03 11:27:43 -08:00
Aaron Pham
2e6a675edd
perf: reduce bundle size (closes #1607) (#1689) 2025-01-01 12:24:32 -08:00
Jacky Zhao
e3162f7a7e chore: joinSegments fix + tests 2025-01-01 12:11:16 -08:00
moexiami
9466c145b1
fix(path): correct handle URI schemes in joinSegments (#1693) 2025-01-01 10:07:59 -08:00
Anton Bulakh
a934397961
fix(spa): Fix relative alias redirects (#1688) 2024-12-30 18:21:07 -08:00
dependabot[bot]
b2752e7262
chore(deps): bump the production-dependencies group with 2 updates (#1696)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 18:19:22 -08:00
Anton Bulakh
d9e36e60d1
fix(search): Fix super-inconsistent preview widths in search previews (#1677)
Don't know if this was uncovered by my local setup or just nobody
noticed, but page previews were pretty wonky for me
2024-12-30 09:55:53 -08:00
Anton Bulakh
46adb35966
fix(spa): Normalize empty hrefs (#1695)
A final breadcrumb has an empty href, linking to the current page, but
the relative url normalization method missed those, making the link
appear broken in search previews and popovers.

Fixes #1690
2024-12-30 09:55:18 -08:00
Jacky Zhao
e1c9eabef4 fix(css): oops fit-content not no width specification for codeblock title 2024-12-30 09:28:02 -08:00
Anton Bulakh
d88e43010a
fix(tags): Fix TagContent not sorting the page list when opening a tag (#1692) 2024-12-30 09:08:35 -08:00
Ammar Alakkad
ef72f1bf70
Fix ObsidianFlavoredMarkdown source link (#1694) 2024-12-30 08:03:57 -08:00
Jacky Zhao
7d4bed64a9 fix(css): allow proper width on code block title (closes #1683) 2024-12-28 15:17:58 -08:00
Anton Bulakh
99011cb1b0
fix(spa): handle HTML redirects for aliases (#1680) 2024-12-27 07:18:22 -07:00
Jacky Zhao
c91cf97f99
feat(spa): add loading bar (#1684) 2024-12-26 22:18:17 -07:00
Anton Bulakh
05e6f05a50
feat(backlinks): hide by default when empty (#1674)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2024-12-26 21:05:35 -07:00
Anton Bulakh
7533d504dc
fix(tags): Set consistent titles for tags with description pages (#1671) 2024-12-26 17:50:06 -07:00
Anton Bulakh
69a0ddf733
fix(tags): Show tag descriptions when hovering (#1670) 2024-12-26 06:50:54 -07:00
dependabot[bot]
a582505daf
chore(deps): bump the production-dependencies group with 7 updates (#1667)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-23 16:35:08 -05:00
Aaron Pham
965f9e123c
chore(frontmatter): dealias created/modified/updated time (#1664) 2024-12-23 15:00:26 -05:00
Emile Bangma
ff9e60a7fc
fix(search): restore mobile search view (#1576) 2024-12-17 13:07:00 -05:00
dependabot[bot]
367bb0e6ce
chore(deps): bump the production-dependencies group across 1 directory with 14 updates (#1655)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-12-17 03:53:51 -05:00
Emile Bangma
8141cb1587
fix(cssclasses): apply frontmatter classes uniformly between page types (#1645) 2024-12-11 00:43:27 +01:00
Sohum
5ccc2dcbba
fix(head): update open-graph width and height protocol per ogp (#1512)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-12-03 04:52:51 -05:00
hill
11c98f5600
feat(i18n): add zh-TW (#1621) 2024-12-03 04:52:36 -05:00
Francisco Gama T. R.
5a5ef670fe
fix(darkmode): check for null (#1531) 2024-12-03 01:56:59 -05:00
catcodeme
285c0e9768
docs(showcase): 8cat.life (#1617) 2024-12-03 01:50:50 -05:00
Aaron Bull Schaefer
adb326c933
fix(style): ensure images have no background color when linked (#1623) 2024-12-03 01:43:22 -05:00
Bao
0c4281eb53
fix: use time HTML element for date strings (#1622) 2024-12-03 01:41:55 -05:00
Emile Bangma
7ac94e1d84
fix(comments): properly check if comments is in frontmatter (#1627) 2024-11-30 21:03:20 -05:00
plyght
e1d754ef79
Update showcase.md (#1611) 2024-11-22 11:44:40 -08:00
Jules Kreuer
a727b57e46
docs(hosting): add apache config example (#1608) 2024-11-20 19:29:43 -05:00
dependabot[bot]
1a4cfa606f
chore(deps): bump the production-dependencies group with 3 updates (#1600)
Bumps the production-dependencies group with 3 updates: [@clack/prompts](https://github.com/natemoo-re/clack/tree/HEAD/packages/prompts), [satori](https://github.com/vercel/satori) and [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki).


Updates `@clack/prompts` from 0.7.0 to 0.8.1
- [Release notes](https://github.com/natemoo-re/clack/releases)
- [Changelog](https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md)
- [Commits](https://github.com/natemoo-re/clack/commits/@clack/prompts@0.8.1/packages/prompts)

Updates `satori` from 0.10.14 to 0.11.3
- [Release notes](https://github.com/vercel/satori/releases)
- [Commits](https://github.com/vercel/satori/compare/0.10.14...0.11.3)

Updates `shiki` from 1.22.2 to 1.23.1
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.23.1/packages/shiki)

---
updated-dependencies:
- dependency-name: "@clack/prompts"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: satori
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 00:36:08 -05:00
Jacky Zhao
410001b942
docs: add the pond (#1598) 2024-11-18 11:39:44 -08:00
Aaron Pham
f24a147276
fix(comment): negate check (closes #1582) (#1586) 2024-11-14 06:28:06 -05:00
Ben Schlegel
137d55eb1b
feat(open-graph): generate OG images + further OG support (#740)
* Quartz sync: Aug 29, 2023, 10:17 PM

* feat: add basic satori og image generation

* Squashed commit of the following:

commit fa69c2a5656254251b74dbd5545bef000f67af2f
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 19:35:11 2023 +0200

    fix(explorer): increase consistency, explicitly use font-family (#496)

    * fix(explorer): display name for folders without `index` file

    * docs(explorer): add section for folder display names

    * docs(explorer): fix broken wikilink

    * fix(consistency): explicitly set font + label/link fix

    Use consistent styling between folders with `folderClickBehavior: "link"` and `"collapse`

    * Update quartz/components/styles/explorer.scss

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * Update quartz/components/styles/explorer.scss

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    ---------

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

commit 8eb1554b13532a2441b41d2018800c56cfa84ce9
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 18:54:33 2023 +0200

    fix(explorer): display names for folders without frontmatter (#494)

    * fix(explorer): display name for folders without `index` file

    * docs(explorer): add section for folder display names

commit dcdeae4e7bd527945b887ca347b3b4408c03055b
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 18:53:19 2023 +0200

    docs(explorer): update default config + new example (#493)

commit 48452231d5fcd14ef218928bde9ae7e5bc745f4a
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 16:09:18 2023 -0700

    perf: memoize filetree computation (#490)

    * perf: memoize filetree computation

    * format

    * var -> let

commit 16d33fb77193710bede887d6a177d2144b78fb67
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 16:08:54 2023 -0700

    feat: display name for folders, expand explorer a little bit (#489)

    * feat: display name for folders, expand explorer a little bit

    * update docs

commit b029eeadabe0877df6ec11443c68743f1494bc40
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Wed Sep 20 22:55:29 2023 +0200

    feat(explorer): improve accessibility and consistency (+ bug fix) (#488)

    * feat(consistency): use `all: unset` on button

    * style: improve accessibility and consistency for explorer

    * fix: localStorage bug with folder name changes

    * chore: bump quartz version

commit 6a9e6352e88aa9ff18e5b33cf2de442a250bd960
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 13:52:45 2023 -0700

    Revert "feat: Making Quartz available offline by making it a PWA (#465)"

    This reverts commit d6301fae90d9f922618bf0f413e273156731eef7.

commit 70e029d151ccbb9aeab30a0f811b9f529b7f8818
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 13:52:29 2023 -0700

    Revert "docs: wording changes for offline support"

    This reverts commit 52a172d1a4911080444ff797183e29ba8175741e.

commit 0bad3ce7990aa4ef417128f9d74c2947fe5117fd
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 11:58:52 2023 -0700

    docs: document enableToc

commit 52a172d1a4911080444ff797183e29ba8175741e
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 11:40:36 2023 -0700

    docs: wording changes for offline support

commit d6301fae90d9f922618bf0f413e273156731eef7
Author: Adam Brangenberg <adambrangenberg@proton.me>
Date:   Wed Sep 20 20:38:13 2023 +0200

    feat: Making Quartz available offline by making it a PWA (#465)

    * Adding PWA and chaching for offline aviability

    * renamed workbox config to fit Quartz' scheme

    * Documenting new configuration

    * Added missig umami documentation

    * Fixed formatting so the build passes, thank you prettier :)

    * specified caching strategies to improve performance

    * formatting...

    * fixing "404 manifest.json not found" on subdirectories by adding a / to manifestpath

    * turning it into a plugin

    * Removed Workbox-cli and updated @types/node

    * Added Serviceworkercode to offline.ts

    * formatting

    * Removing workbox from docs

    * applied suggestions

    * Removed path.join for sw path

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * Removed path.join for manifest path

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * Removing path module import

    * Added absolute path to manifests start_url and manifest "import" using baseUrl

    * Adding protocol to baseurl

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * Adding protocol to start_url too then

    * formatting...

    * Adding fallback page

    * Documenting offline plugin

    * formatting...

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * merge suggestion

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

    * formatting...

    * Fixing manifest path, all these nits hiding the actual issues .-.

    * Offline fallback page through plugins, most things taken from 404 Plugin

    * adding Offline Plugin to config

    * formatting...

    * Turned offline off as default and removed offline.md

    ---------

    Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

commit 27a6087dd5a25dd5031b86b9917adde6ef4b211a
Author: rwutscher <richard.wutscher@gmail.com>
Date:   Tue Sep 19 21:26:30 2023 +0200

    fix: tag regex no longer includes purely numerical 'tags' (#485)

    * fix: tag regex no longer includes purely numerical 'tags'

    * fix: formatting

    * fix: use guard in findAndReplace() instead of expanding the regex

commit 1bf7e3d8b3966590ebfa3418d6fb2ce6a520c846
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Tue Sep 19 10:22:39 2023 -0700

    fix(nit): make defaultOptions on explorer not a function

commit cc31a40b0cb53cba7f51187cb6d68076c3f54c0f
Author: David Fischer <david@konst.fish>
Date:   Tue Sep 19 18:25:51 2023 +0200

    feat: support changes in system theme (#484)

    * feat: support changes in system theme

    * fix: run prettier

    * fix: add content/.gitkeep

commit 0d3cf2922618774fc397dca8cb92fcf76fb0db02
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Mon Sep 18 23:32:00 2023 +0200

    docs: fix explorer example (#483)

* feat: dynamically generate og images, write to fs as png

* fix: og preview on discord

* feat: use `sharp` to convert to webp, add content headers

* feat: add config for theme (light or dark)

* feat: improve image margins, add font breakpoint

* feat: use config header + body fonts for satori

* perf: memoize fonts

* feat: use default og image if no path exists

* feat: add config option for social images

* feat: support custom og images via frontmatter

* refactor: clean font helpers, rename fonts helper

* refactor: make image generation cleaner

* refactor: move default image to own component

* chore: add todos

* fix: only set width/height header if known

* feat: remove html from description

* feat: make image dimensions configurable

* feat: pass userOpts to image generator

* feat: option for users to provide own image struct (satori)

This allows users to pass their own jsx for generating the default og image

* refactor: rename `defaultImage.tsx` > `socialImage.tsx`

* chore: improve comments + types

* refactor: rename socialImage frontmatter property

* feat: add frontmatter aliases for cover image

* feat: add frontmatter alias for obsidian publish

* docs: add documentation for social images

* feat: add `generateSocialImages` prop to config

* chore: update lock file

* fix: fix type error

* chore: update package.json

* chore: update package-lock.json

* docs: update docs

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

* fix: clean url for use in metadata

* refactor: clean function signature

* feat: pass `fileData` to image generator

* CI: run format

* fix: file system import

* fix: merge paths using `joinSegments`

* fix: get output dir via `ctx.argv.output`

* chore: add explanation to font regex

* Squashed commit of the following:

commit 7164857f6e32aeba3da80112d604244aa8f557f4
Author: Aaron Pham <29749331+aarnphm@users.noreply.github.com>
Date:   Fri Mar 15 21:17:42 2024 -0400

    chore(ofm): remove unused (#999)

    Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

commit 47024022e834e1bb6c70f671cb32597f42aabd94
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Fri Mar 15 18:29:14 2024 -0400

    chore(deps-dev): bump @types/node from 20.11.24 to 20.11.25 (#990)

    Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.24 to 20.11.25.
    - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
    - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

    ---
    updated-dependencies:
    - dependency-name: "@types/node"
      dependency-type: direct:development
      update-type: version-update:semver-patch
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit b98e4be66548e452419a1e4138d9d6d1981f891e
Author: Mara-Li <lili.simonetti@outlook.fr>
Date:   Fri Mar 15 23:28:31 2024 +0100

    feat(i18n): Add French translation for reading time (#998)

    Signed-off-by: Mara-Li <lili.simonetti@outlook.fr>

commit 8be51a0504a7d819a9dab66d854dbef77878520a
Author: catcodeme <1020082805@qq.com>
Date:   Fri Mar 15 14:25:01 2024 +0800

    fix: wikiLink in table (#993)

    * fix: wikiLink in table

    - update regexp to make '\' to group in alias
    - handle alias using block_id

    * style: format with prettier

    * style: add comment for block_ref(without alias) in table

    ---------

    Co-authored-by: hulinjiang <hulinjiang@58.com>

commit 92cc23dc456ffc23285b83728fbc3434bbca5472
Author: Linus Sehn <37184648+linozen@users.noreply.github.com>
Date:   Wed Mar 13 08:59:37 2024 +0100

    feat(plugin): citations (#984)

    * feat: add rehype-citations

    * feat: add citations transformer plugin

    * feat: add rehype-rewrite

    * feat: add csl option and add no-popover to citation links

    * revert: add rehype-rewrite

    04b2692 'feat: add rehype-rewrite'

    * feat: use existing package for html manipulation

    * fix: remove `console.log()`

commit 097abc3cda0d9a6f3cfedfa3c6351648efd8d6b8
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Mon Mar 11 13:41:48 2024 -0700

    chore(deps): bump async-mutex from 0.4.1 to 0.5.0 (#991)

    Bumps [async-mutex](https://github.com/DirtyHairy/async-mutex) from 0.4.1 to 0.5.0.
    - [Changelog](https://github.com/DirtyHairy/async-mutex/blob/master/CHANGELOG.md)
    - [Commits](https://github.com/DirtyHairy/async-mutex/compare/v0.4.1...v0.5.0)

    ---
    updated-dependencies:
    - dependency-name: async-mutex
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit a00324ddfdea9adf6aaec03abf4f076cb756ee7a
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Mon Mar 11 13:41:41 2024 -0700

    chore(deps-dev): bump typescript from 5.3.3 to 5.4.2 (#989)

    Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.3 to 5.4.2.
    - [Release notes](https://github.com/Microsoft/TypeScript/releases)
    - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
    - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.3...v5.4.2)

    ---
    updated-dependencies:
    - dependency-name: typescript
      dependency-type: direct:development
      update-type: version-update:semver-minor
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 9fff6d7d0dbaacad0f9988d4017b72738e6f6c58
Author: Mara-Li <lili.simonetti@outlook.fr>
Date:   Mon Mar 11 17:46:53 2024 +0100

    fix: spelling error (#987)

    I really don't know why I translated this like that into "pas trouvé", and it bugged me a lot. I finally fixed it…

    Signed-off-by: Mara-Li <lili.simonetti@outlook.fr>

commit 0f5a9d7b661a1f8610d7001f80a3fd2c52661e51
Author: Matt Vogel <mainmoniker@googlemail.com>
Date:   Sun Mar 10 12:57:10 2024 -0400

    feat: separated content meta (#929)

    to allow for CSS styling

commit b4236e5142c31829cf809c0fbc8370ac22b6d1ba
Author: kabirgh <15871468+kabirgh@users.noreply.github.com>
Date:   Sun Mar 10 00:42:23 2024 +0000

    feat(perf:fast-rebuilds): Stop mutating resources param in ComponentResources emitter (#977)

    * Stop mutating resources param in ComponentResources emitter

    * Add done rebuilding log for fast rebuilds

    * Move google font loading to Head component

    * Simplify code and fix comment

commit 6e0c10297095a918109a058762beb47efc384a21
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sun Mar 10 01:14:31 2024 +0100

    fix(transclusion): prevent duplicate transclusion if multiple transclusions are present. (#982)

commit 94a54698ab7f29a609ca90033c1384a7ec5f5e65
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sat Mar 9 17:59:55 2024 +0100

    fix(resources): Use full path to font when cdnCache is false (#976)

commit 2e9a0c21db717c324a74f761fb0910b1218fdd72
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sat Mar 9 17:43:40 2024 +0100

    fix(description): first sentence no longer repeats until max length (#981)

commit b30a200bd4ddc64f4fd3d2124fcda0b354847073
Author: Aaron Pham <29749331+aarnphm@users.noreply.github.com>
Date:   Fri Mar 8 12:14:22 2024 -0500

    fix(i18n): make sure to use correct fileData for manual localization (#975)

    Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

commit 6d59aa8201a1fd3abea32ef36206af6471d85435
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Fri Mar 8 10:04:44 2024 +0100

    fix(description): counts characters instead of words (#972)

    * fix(description): make sure description counts characters instead of words

    * ref: removed duplicate ternary

* CI: fix package log post merge

* CI: fix more merge artifacts

* CI: fix package-lock.json

* feat: add new default image template

* feat: use icon.png for image generation

* chore: update satori and sharp version

* feat(image-generator): add new default template

* Update quartz/components/Head.tsx

* Update quartz/components/Head.tsx

* Update quartz/components/Head.tsx

* Update docs/features/social images.md

* Update quartz/components/Head.tsx

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

* feat(og-image): add config option to use default og image for root path

* docs(og-image): add `excludeRoot` config + update preview images

* docs(open-graph): add examples section

* chore: remove unused `socialImage2.tsx` component

* feat(open-graph): add frontmatter aliases for socialImage/cover/image

* fix(open-graph): only load satori font if config option is enabled

* refactor(open-graph): dont use async promise inside `fetchTtf()`

* chore: renaming and finished copywriting

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: update typo

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: update hinting for socialImage

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Emile Bangma <ewjbangma@hotmail.com>
Co-authored-by: Emile Bangma <github@emilebangma.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-12 07:33:35 -05:00
Han-Miru Kim
9188939b1f
feat(math): typst support(closes #1568) (#1569)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2024-11-12 06:42:44 -05:00
Aaron Pham
92676d746e
feat(ofm): support parsing footnotes in table (#1581) 2024-11-12 03:37:59 -05:00
dependabot[bot]
1893196939
chore(deps): bump rehype-citation from 2.2.1 to 2.2.2 in the production-dependencies group (#1583)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-12 00:38:48 -05:00
Aaron Pham
1ab9c91df1
feat(mermaid): improvement navigation (#1575)
* feat(mermaid): custom stuff

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: use mermaid and update clipboard content

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix: explicitly use center div

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-10 18:13:12 -05:00
dependabot[bot]
0ad9111388
chore(deps): bump rlespinasse/github-slug-action from 4.4.1 to 5.0.0 in the ci-dependencies group (#1578)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-10 04:46:14 -05:00
dependabot[bot]
cbeef5541f
chore(deps-dev): bump @types/node from 22.8.7 to 22.9.0 in the production-dependencies group (#1579)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-10 04:46:01 -05:00
Romain Lespinasse
0f04f1262c
ci(dependabot): update GA periodically (#1577)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-10 04:41:56 -05:00
David Fischer
31e0b7c6f8
feat(comments): conditional display via frontmatter (#1566) 2024-11-09 04:44:32 -05:00
Jacky Zhao
a6b2967df8 fix: account for chokidar v4 glob deprecation in source watching 2024-11-07 16:32:26 -08:00
Jacky Zhao
f84a6554f6 fix(style): remove flex end to fix tag alignment in content meta 2024-11-07 15:13:28 -08:00
dependabot[bot]
1d5b6f58d8
chore(deps): bump the production-dependencies group with 4 updates (#1565)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 21:03:30 -05:00
Stephen Tse
74f8c13598
fix: reset z-index instead of inherited unset (#1528)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-04 20:22:36 -05:00
Aaron Pham
56ba2f4fa7
feat(build): add support for parsing inline sass (#1558) 2024-11-01 07:16:12 -04:00
Emile Bangma
314a88d5c6
feat(transformer): allow inline CSS styling (#1551)
* feat(transformer): allow inline CSS styling

* Updated docs

* Default to inline: false

* Removed redundant inline: false
2024-10-30 23:57:39 +01:00
Osman Tunahan ARIKAN
a53772bbd1
feat(i18n): add turkish language (#1554) 2024-10-30 18:33:22 -04:00
dependabot[bot]
1915a198d9
chore(deps): bump the production-dependencies group with 5 updates (#1548)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 20:04:45 -04:00
Thomas Hack
1d2dc167f4
feat(folder): add intermediate folders pages (#1295)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-25 12:52:33 -04:00
dependabot[bot]
3ef2a24f4b
chore(deps): bump the production-dependencies group across 1 directory with 7 updates (#1540) 2024-10-22 17:54:41 -04:00
Emile Bangma
d98a3a088a
Merge pull request #1543 from saberzero1/draft-publish-string
feat(filters): support "true" as valid for frontmatter filters
2024-10-22 22:38:16 +02:00
Emile Bangma
ad52d09567 Feat(filters): Support "true" as valid for draft and publish frontmatter values (b3a02909ba74fff08cd3675707d1f4d782a24e98) 2024-10-22 20:33:54 +00:00
Emile Bangma
9f701e5045
fix(grid): $desktop variable (#1511) 2024-10-17 23:50:48 -04:00
Emile Bangma
67e1beea70
feat(comments): support custom giscus themes (#1526)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-17 18:30:16 -04:00
Emile Bangma
3aa11357aa
fix(toc): regression (#1517) 2024-10-16 17:44:30 -04:00
Jacky Zhao
b3a02909ba fix: make filter checks more strict (closes #1519) 2024-10-15 09:34:46 -07:00
Sohum
1dc208356a
fix(cli): use shell on win32 for update (#1503) (#1504)
If there is no `npm.exe` on the system, but
instead an `npm.cmd`, then node won't find
the `npm` executable when calling `spawnSync`.

This occurs frequently when using node package
managers on Windows.

See the node documentation for `.bat` and `.cmd`
files here.

<https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows>.
2024-10-13 10:46:51 -07:00
Simon Späti
3d0ba32070
docs: Update Name and URL of Simon's SB and adding DE Vault (#1507) 2024-10-13 10:42:29 -07:00
Aaron Pham
c5d97db000
fix(toc): invalid desktop-only styling (#1502)
* fix(toc): invalid desktop-only styling

should display none instead.

* Update toc.scss
2024-10-12 18:33:07 -04:00
Pierre Lairez
0d1f15d37c
feat(ofm): Allow for dashes in custom callout label (#1493)
* [OFM] Allow for dashes in custom callout label

For compatibility with Obsidian's behavior, a custom callout like
[!see-also] is possible. Previously, this was parsed by Quartz as a
callout “see” with metadata “-also”. Instead, this is should be a
callout “see-also” with title “See also” (capitalization + replace
dashes by spaces).

* prettier
2024-10-10 07:15:23 -04:00
dependabot[bot]
11c23a137a
chore(deps): bump the production-dependencies group with 9 updates (#1495)
Bumps the production-dependencies group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [chokidar](https://github.com/paulmillr/chokidar) | `3.6.0` | `4.0.1` |
| [esbuild-sass-plugin](https://github.com/glromeo/esbuild-sass-plugin) | `2.16.1` | `3.3.1` |
| [lightningcss](https://github.com/parcel-bundler/lightningcss) | `1.26.0` | `1.27.0` |
| [preact](https://github.com/preactjs/preact) | `10.24.1` | `10.24.2` |
| [rehype-citation](https://github.com/timlrx/rehype-citation) | `2.1.1` | `2.1.2` |
| [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) | `1.18.0` | `1.22.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.7.4` | `22.7.5` |
| [esbuild](https://github.com/evanw/esbuild) | `0.19.12` | `0.24.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.6.2` | `5.6.3` |


Updates `chokidar` from 3.6.0 to 4.0.1
- [Release notes](https://github.com/paulmillr/chokidar/releases)
- [Commits](https://github.com/paulmillr/chokidar/compare/3.6.0...4.0.1)

Updates `esbuild-sass-plugin` from 2.16.1 to 3.3.1
- [Release notes](https://github.com/glromeo/esbuild-sass-plugin/releases)
- [Commits](https://github.com/glromeo/esbuild-sass-plugin/compare/v2.16.1...v3.3.1)

Updates `lightningcss` from 1.26.0 to 1.27.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/compare/v1.26.0...v1.27.0)

Updates `preact` from 10.24.1 to 10.24.2
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.24.1...10.24.2)

Updates `rehype-citation` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/timlrx/rehype-citation/releases)
- [Commits](https://github.com/timlrx/rehype-citation/compare/v2.1.1...v2.1.2)

Updates `shiki` from 1.18.0 to 1.22.0
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.22.0/packages/shiki)

Updates `@types/node` from 22.7.4 to 22.7.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.19.12 to 0.24.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.19.12...v0.24.0)

Updates `typescript` from 5.6.2 to 5.6.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.2...v5.6.3)

---
updated-dependencies:
- dependency-name: chokidar
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: esbuild-sass-plugin
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: lightningcss
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: rehype-citation
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 14:57:33 -07:00
76 changed files with 4347 additions and 834 deletions

View File

@ -9,3 +9,12 @@ updates:
applies-to: "version-updates"
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
ci-dependencies:
applies-to: "version-updates"
patterns:
- "*"

View File

@ -25,7 +25,7 @@ jobs:
with:
fetch-depth: 1
- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@v4.4.1
uses: rlespinasse/github-slug-action@v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@ -27,7 +27,7 @@ The following sections will go into detail for what methods can be implemented f
- `cfg`: The full Quartz [[configuration]]
- `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is)
- `StaticResources` is defined in `quartz/resources.tsx`. It consists of
- `css`: a list of URLs for stylesheets that should be loaded
- `css`: a list of CSS style definitions that should be loaded. A CSS style is described with the `CSSResource` type which is also defined in `quartz/resources.tsx`. It accepts either a source URL or the inline content of the stylesheet.
- `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script.
## Transformers
@ -85,8 +85,10 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
if (engine === "katex") {
return {
css: [
{
// base css
"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
content: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
},
],
js: [
{
@ -272,7 +274,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!)
const externalResources = pageResources(slug, resources)
const externalResources = pageResources(slug, file.data, resources)
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources,

View File

@ -9,6 +9,7 @@ A backlink for a note is a link from another note to that note. Links in the bac
## Customization
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`.
- Hide when empty: hide `Backlinks` if given page doesn't contain any backlinks (default to `true`). To disable this, use `Component.Backlinks({ hideWhenEmpty: false })`.
- Component: `quartz/components/Backlinks.tsx`
- Style: `quartz/components/styles/backlinks.scss`
- Script: `quartz/components/scripts/search.inline.ts`

View File

@ -63,6 +63,18 @@ type Options = {
category: string
categoryId: string
// Url to folder with custom themes
// defaults to 'https://${cfg.baseUrl}/static/giscus'
themeUrl?: string
// filename for light theme .css file
// defaults to 'light'
lightTheme?: string
// filename for dark theme .css file
// defaults to 'dark'
darkTheme?: string
// how to map pages -> discussions
// defaults to 'url'
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
@ -81,3 +93,35 @@ type Options = {
}
}
```
#### Custom CSS theme
Quartz supports custom theme for Giscus. To use a custom CSS theme, place the `.css` file inside the `quartz/static` folder and set the configuration values.
For example, if you have a light theme `light-theme.css`, a dark theme `dark-theme.css`, and your Quartz site is hosted at `https://example.com/`:
```ts
afterBody: [
Component.Comments({
provider: 'giscus',
options: {
// Other options
themeUrl: "https://example.com/static/giscus", // corresponds to quartz/static/giscus/
lightTheme: "light-theme", // corresponds to light-theme.css in quartz/static/giscus/
darkTheme: "dark-theme", // corresponds to dark-theme.css quartz/static/giscus/
}
}),
],
```
#### Conditionally display comments
Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`.
```
---
title: Comments disabled here!
comments: false
---
```

View File

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

View File

@ -247,6 +247,28 @@ server {
}
```
### Using Apache
Here's an example of how to do this with Apache:
```apache title=".htaccess"
RewriteEngine On
ErrorDocument 404 /404.html
# Rewrite rule for .html extension removal (with directory check)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_URI}.html -f
RewriteRule ^(.*)$ $1.html [L]
# Handle directory requests explicitly
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^(.*)/$ $1/index.html [L]
```
Don't forget to activate brotli / gzip compression.
### Using Caddy
Here's and example of how to do this with Caddy:

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -11,9 +11,13 @@ This plugin adds LaTeX support to Quartz. See [[features/Latex|Latex]] for more
This plugin accepts the following configuration options:
- `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/) or `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html). Defaults to KaTeX.
- `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/), `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html), or `"typst"` for [Typst](https://typst.app/) (a new way to compose LaTeX equation). Defaults to KaTeX.
- `customMacros`: custom macros for all LaTeX blocks. It takes the form of a key-value pair where the key is a new command name and the value is the expansion of the macro. For example: `{"\\R": "\\mathbb{R}"}`
> [!note] Typst support
>
> Currently, typst doesn't support inline-math
## API
- Category: Transformer

View File

@ -31,4 +31,4 @@ This plugin accepts the following configuration options:
- Category: Transformer
- Function name: `Plugin.ObsidianFlavoredMarkdown()`.
- Source: [`quartz/plugins/transformers/toc.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/toc.ts).
- Source: [`quartz/plugins/transformers/ofm.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/ofm.ts)

View File

@ -9,6 +9,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Socratica Toolbox](https://toolbox.socratica.info/)
- [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
- [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Pond](https://turntrout.com/welcome)
- [Pelayo Arbues' Notes](https://pelayoarbues.com/)
- [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
@ -20,13 +21,16 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Simon's Second Brain: Crafted, Curated, Connected, Compounded](https://brain.ssp.sh/)
- [Data Engineering Vault: A Second Brain Knowledge Network](https://vault.ssp.sh/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [🪴Aster's notebook](https://notes.asterhu.com)
- [Gatekeeper Wiki](https://www.gatekeeper.wiki)
- [Ellie's Notes](https://ellie.wtf)
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
- [Eledah's Crystalline](https://blog.eledah.ir/)
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)
- [Zen Browser Docs](https://docs.zen-browser.app)
- [🪴8cat life](https://8cat.life)
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!

2248
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,38 +35,39 @@
"quartz": "./quartz/bootstrap-cli.mjs"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.11",
"@clack/prompts": "^0.9.1",
"@floating-ui/dom": "^1.6.13",
"@myriaddreamin/rehype-typst": "^0.5.4",
"@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0",
"async-mutex": "^0.5.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"chalk": "^5.4.1",
"chokidar": "^4.0.3",
"cli-spinner": "^0.2.10",
"d3": "^7.9.0",
"esbuild-sass-plugin": "^2.16.1",
"esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.2",
"gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.3",
"hast-util-to-jsx-runtime": "^2.3.0",
"hast-util-to-html": "^9.0.4",
"hast-util-to-jsx-runtime": "^2.3.2",
"hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.26.0",
"mdast-util-find-and-replace": "^3.0.1",
"lightningcss": "^1.29.1",
"mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"pixi.js": "^8.4.1",
"preact": "^10.24.1",
"preact-render-to-string": "^6.5.11",
"pixi.js": "^8.7.3",
"preact": "^10.25.4",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.1.1",
"rehype-citation": "^2.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.14.0",
@ -82,15 +83,17 @@
"remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"serve-handler": "^6.1.5",
"shiki": "^1.18.0",
"satori": "^0.12.1",
"serve-handler": "^6.1.6",
"sharp": "^0.33.5",
"shiki": "^1.26.2",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3",
"workerpool": "^9.1.3",
"workerpool": "^9.2.0",
"ws": "^8.18.0",
"yargs": "^17.7.2"
},
@ -99,14 +102,14 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.7.4",
"@types/node": "^22.12.0",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.12",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"esbuild": "^0.19.9",
"prettier": "^3.3.3",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
"esbuild": "^0.24.2",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View File

@ -19,6 +19,7 @@ const config: QuartzConfig = {
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
generateSocialImages: false,
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,

View File

@ -1,7 +1,8 @@
#!/usr/bin/env node
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseFiles } = await import(cacheFile)
const { parseMarkdown, processHtml } = await import(cacheFile)
workerpool.worker({
parseFiles,
parseMarkdown,
processHtml,
})

View File

@ -139,9 +139,9 @@ async function startServing(
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
watcher
.on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData))
.on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData))
.on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
.on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
.on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
return async () => {
await watcher.close()

View File

@ -2,6 +2,7 @@ import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types"
import { SocialImageOptions } from "./util/og"
import { Theme } from "./util/theme"
export type Analytics =
@ -60,11 +61,15 @@ export interface GlobalConfiguration {
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/
baseUrl?: string
/**
* Whether to generate social images (Open Graph and Twitter standard) for link previews
*/
generateSocialImages: boolean | Partial<SocialImageOptions>
theme: Theme
/**
* Allow to translate the date in the language of your choice.
* Also used for UI translation (default: en-US)
* Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* The first part is the language (en) and the second part is the script/region (US)
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2

View File

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

View File

@ -4,14 +4,28 @@ import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
const Backlinks: QuartzComponent = ({
interface BacklinksOptions {
hideWhenEmpty: boolean
}
const defaultOptions: BacklinksOptions = {
hideWhenEmpty: true,
}
export default ((opts?: Partial<BacklinksOptions>) => {
const options: BacklinksOptions = { ...defaultOptions, ...opts }
const Backlinks: QuartzComponent = ({
fileData,
allFiles,
displayClass,
cfg,
}: QuartzComponentProps) => {
}: QuartzComponentProps) => {
const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
return null
}
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
@ -30,7 +44,9 @@ const Backlinks: QuartzComponent = ({
</ul>
</div>
)
}
}
Backlinks.css = style
export default (() => Backlinks) satisfies QuartzComponentConstructor
Backlinks.css = style
return Backlinks
}) satisfies QuartzComponentConstructor

View File

@ -10,6 +10,9 @@ type Options = {
repoId: string
category: string
categoryId: string
themeUrl?: string
lightTheme?: string
darkTheme?: string
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict?: boolean
reactionsEnabled?: boolean
@ -22,7 +25,15 @@ function boolToStringBool(b: boolean): string {
}
export default ((opts: Options) => {
const Comments: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
// check if comments should be displayed according to frontmatter
const disableComment: boolean =
typeof fileData.frontmatter?.comments !== "undefined" &&
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
if (disableComment) {
return <></>
}
return (
<div
class={classNames(displayClass, "giscus")}
@ -34,6 +45,11 @@ export default ((opts: Options) => {
data-strict={boolToStringBool(opts.options.strict ?? true)}
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
data-input-position={opts.options.inputPosition ?? "bottom"}
data-light-theme={opts.options.lightTheme ?? "light"}
data-dark-theme={opts.options.darkTheme ?? "dark"}
data-theme-url={
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
}
></div>
)
}

View File

@ -1,4 +1,4 @@
import { formatDate, getDate } from "./Date"
import { Date, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time"
import { classNames } from "../util/lang"
@ -30,7 +30,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const segments: (string | JSX.Element)[] = []
if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
}
// Display reading time if enabled
@ -39,14 +39,12 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
minutes: Math.ceil(minutes),
})
segments.push(displayedTime)
segments.push(<span>{displayedTime}</span>)
}
const segmentsElements = segments.map((segment) => <span>{segment}</span>)
return (
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
{segmentsElements}
{segments}
</p>
)
} else {

View File

@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
}
export function Date({ date, locale }: Props) {
return <>{formatDate(date, locale)}</>
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
}

View File

@ -1,5 +1,5 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss"
import style from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
@ -46,7 +46,7 @@ export default ((userOpts?: Partial<Options>) => {
let jsonTree: string
let lastBuildId: string = ""
function constructFileTree(allFiles: QuartzPluginData[], currentFilePath: string) {
function constructFileTree(allFiles: QuartzPluginData[]) {
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
@ -81,7 +81,7 @@ export default ((userOpts?: Partial<Options>) => {
}: QuartzComponentProps) => {
if (ctx.buildId !== lastBuildId) {
lastBuildId = ctx.buildId
constructFileTree(allFiles, (fileData.filePath ?? "").replaceAll(" ", "-"))
constructFileTree(allFiles)
}
return (
<div class={classNames(displayClass, "explorer")}>
@ -150,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
)
}
Explorer.css = explorerStyle
Explorer.css = style
Explorer.afterDOMLoaded = script
return Explorer
}) satisfies QuartzComponentConstructor

View File

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

View File

@ -46,13 +46,9 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
return (
<li class="section-li">
<div class="section">
<div>
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
{page.dates && <Date date={getDate(cfg, page)!} locale={cfg.locale} />}
</p>
)}
</div>
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">

View File

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

View File

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

View File

@ -38,7 +38,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
const classes = cssClasses.join(" ")
if (tag === "/") {
const tags = [
...new Set(
@ -50,8 +50,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
tagItemMap.set(tag, allPagesWithTag(tag))
}
return (
<div class={classes}>
<article>
<div class="popover-hint">
<article class={classes}>
<p>{content}</p>
</article>
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
@ -93,7 +93,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
</>
)}
</p>
<PageList limit={options.numPages} {...listProps} sort={opts?.sort} />
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
</div>
</div>
)
@ -110,11 +110,11 @@ export default ((opts?: Partial<TagContentOptions>) => {
return (
<div class={classes}>
<article>{content}</article>
<article class="popover-hint">{content}</article>
<div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div>
<PageList {...listProps} />
<PageList {...listProps} sort={options?.sort} />
</div>
</div>
</div>

View File

@ -8,6 +8,10 @@ import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n"
// @ts-ignore
import mermaidScript from "./scripts/mermaid.inline"
import mermaidStyle from "./styles/mermaid.inline.scss"
import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents {
head: QuartzComponent
@ -23,13 +27,19 @@ interface RenderComponents {
const headerRegex = new RegExp(/h[1-6]/)
export function pageResources(
baseDir: FullSlug | RelativeURL,
fileData: QuartzPluginData,
staticResources: StaticResources,
): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
const resources: StaticResources = {
css: [
{
content: joinSegments(baseDir, "index.css"),
},
...staticResources.css,
],
js: [
{
src: joinSegments(baseDir, "prescript.js"),
@ -43,14 +53,28 @@ export function pageResources(
script: contentIndexScript,
},
...staticResources.js,
{
],
}
if (fileData.hasMermaidDiagram) {
resources.js.push({
script: mermaidScript,
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
resources.css.push({ content: mermaidStyle, inline: true })
}
// NOTE: we have to put this last to make sure spa.inline.ts is the last item.
resources.js.push({
src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
},
],
}
})
return resources
}
export function renderPage(

View File

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

View File

@ -13,7 +13,7 @@ const changeTheme = (e: CustomEventMap["themechange"]) => {
{
giscus: {
setConfig: {
theme: theme,
theme: getThemeUrl(getThemeName(theme)),
},
},
},
@ -21,12 +21,36 @@ const changeTheme = (e: CustomEventMap["themechange"]) => {
)
}
const getThemeName = (theme: string) => {
if (theme !== "dark" && theme !== "light") {
return theme
}
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return theme
}
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
return theme === "dark" ? darkGiscus : lightGiscus
}
const getThemeUrl = (theme: string) => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return `https://giscus.app/themes/${theme}.css`
}
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
}
type GiscusElement = Omit<HTMLElement, "dataset"> & {
dataset: DOMStringMap & {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: string
themeUrl: string
lightTheme: string
darkTheme: string
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict: string
reactionsEnabled: string
@ -57,7 +81,7 @@ document.addEventListener("nav", () => {
const theme = document.documentElement.getAttribute("saved-theme")
if (theme) {
giscusScript.setAttribute("data-theme", theme)
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
}
giscusContainer.appendChild(giscusScript)

View File

@ -27,9 +27,10 @@ document.addEventListener("nav", () => {
// Darkmode toggle
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
if (themeButton) {
themeButton.addEventListener("click", switchTheme)
window.addCleanup(() => themeButton.removeEventListener("click", switchTheme))
}
// Listen for changes in prefers-color-scheme
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
colorSchemeMediaQuery.addEventListener("change", themeChange)

View File

@ -38,7 +38,7 @@ function toggleExplorer(this: HTMLElement) {
// Prevent scroll under
if (document.querySelector("#mobile-explorer")) {
// Disable scrolling one the page when the explorer is opened on mobile
// Disable scrolling on the page when the explorer is opened on mobile
const bodySelector = document.querySelector("#quartz-body")
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
}
@ -149,11 +149,14 @@ function toggleExplorerFolders() {
/\/index$/g,
"",
)
const listToReplace = document.querySelectorAll(".folder-outer:has(> ul[data-folderul]")
const allFolders = document.querySelectorAll(".folder-outer")
listToReplace.forEach((element) => {
if (element.children.length > 0) {
if (currentFile.includes(element.firstElementChild?.getAttribute("data-folderul") ?? "")) {
allFolders.forEach((element) => {
const folderUl = Array.from(element.children).find((child) =>
child.matches("ul[data-folderul]"),
)
if (folderUl) {
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
if (!element.classList.contains("open")) {
element.classList.add("open")
}

View File

@ -580,7 +580,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
function hideGlobalGraph() {
container?.classList.remove("active")
if (sidebar) {
sidebar.style.zIndex = "unset"
sidebar.style.zIndex = ""
}
}

View File

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

View File

@ -1,5 +1,6 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
const p = new DOMParser()
async function mouseEnterHandler(
@ -37,7 +38,7 @@ async function mouseEnterHandler(
targetUrl.hash = ""
targetUrl.search = ""
const response = await fetch(`${targetUrl}`).catch((err) => {
const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err)
})

View File

@ -178,7 +178,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
searchBar.value = "" // clear the input when we dismiss the search
}
if (sidebar) {
sidebar.style.zIndex = "unset"
sidebar.style.zIndex = ""
}
if (results) {
removeAllChildren(results)

View File

@ -1,5 +1,6 @@
import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
@ -42,10 +43,24 @@ function notifyNav(url: FullSlug) {
const cleanupFns: Set<(...args: any[]) => void> = new Set()
window.addCleanup = (fn) => cleanupFns.add(fn)
function startLoading() {
const loadingBar = document.createElement("div")
loadingBar.className = "navigation-progress"
loadingBar.style.width = "0"
if (!document.body.contains(loadingBar)) {
document.body.appendChild(loadingBar)
}
setTimeout(() => {
loadingBar.style.width = "80%"
}, 100)
}
let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
startLoading()
p = p || new DOMParser()
const contents = await fetch(`${url}`)
const contents = await fetchCanonical(url)
.then((res) => {
const contentType = res.headers.get("content-type")
if (contentType?.startsWith("text/html")) {
@ -104,6 +119,7 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!isBack) {
history.pushState({}, "", url)
}
notifyNav(getFullSlug(window))
delete announcer.dataset.persist
}

View File

@ -24,3 +24,22 @@ export function removeAllChildren(node: HTMLElement) {
node.removeChild(node.firstChild)
}
}
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
// containing the URL it's redirecting to.
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
// with a DOMParser effectively twice (here and later in the SPA code), even if
// way less robust - we only care about our own generated redirects after all.
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
export async function fetchCanonical(url: URL): Promise<Response> {
const res = await fetch(`${url}`)
if (!res.headers.get("content-type")?.startsWith("text/html")) {
return res
}
// reading the body can only be done once, so we need to clone the response
// to allow the caller to read it if it's was not a redirect
const text = await res.clone().text()
const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res
}

View File

@ -37,7 +37,7 @@
display: none;
}
height: auto;
@media all and ($desktop) {
@media all and not ($desktop) {
height: 250px;
}
}

View File

@ -3,7 +3,7 @@
color: var(--gray);
&[show-comma="true"] {
> span:not(:last-child) {
> *:not(:last-child) {
margin-right: 8px;
&::after {

View File

@ -29,6 +29,9 @@
.explorer {
display: flex;
height: 100%;
flex-direction: column;
overflow-y: hidden;
@media all and ($mobile) {
order: -1;
@ -56,10 +59,6 @@
}
}
height: 100%;
flex-direction: column;
overflow-y: hidden;
&.desktop-only {
@media all and not ($mobile) {
display: flex;

View File

@ -65,7 +65,7 @@
height: 80vh;
width: 80vw;
@media all and ($desktop) {
@media all and not ($desktop) {
width: 90%;
}
}

View File

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

View File

@ -64,7 +64,7 @@
margin-left: auto;
margin-right: auto;
@media all and ($desktop) {
@media all and not ($desktop) {
width: 90%;
}
@ -106,7 +106,7 @@
flex: 0 0 min(30%, 450px);
}
@media all and not ($tablet) {
@media all and not ($mobile) {
&[data-preview] {
& .result-card > p.preview {
display: none;
@ -132,7 +132,7 @@
border-radius: 5px;
}
@media all and ($tablet) {
@media all and ($mobile) {
& > #preview-container {
display: none !important;
}
@ -151,6 +151,7 @@
}
& > #preview-container {
flex-grow: 1;
display: block;
overflow: hidden;
font-family: inherit;

View File

@ -1,12 +1,20 @@
@use "../../styles/variables.scss" as *;
.toc {
display: flex;
flex-direction: column;
&.desktop-only {
display: flex;
max-height: 40%;
}
}
@media all and not ($mobile) {
.toc {
display: flex;
}
}
button#toc {
background-color: transparent;
border: none;

View File

@ -14,12 +14,16 @@ import uk from "./locales/uk-UA"
import ru from "./locales/ru-RU"
import ko from "./locales/ko-KR"
import zh from "./locales/zh-CN"
import zhTw from "./locales/zh-TW"
import vi from "./locales/vi-VN"
import pt from "./locales/pt-BR"
import hu from "./locales/hu-HU"
import fa from "./locales/fa-IR"
import pl from "./locales/pl-PL"
import cs from "./locales/cs-CZ"
import tr from "./locales/tr-TR"
import th from "./locales/th-TH"
import lt from "./locales/lt-LT"
export const TRANSLATIONS = {
"en-US": enUs,
@ -58,12 +62,16 @@ export const TRANSLATIONS = {
"ru-RU": ru,
"ko-KR": ko,
"zh-CN": zh,
"zh-TW": zhTw,
"vi-VN": vi,
"pt-BR": pt,
"hu-HU": hu,
"fa-IR": fa,
"pl-PL": pl,
"cs-CZ": cs,
"tr-TR": tr,
"th-TH": th,
"lt-LT": lt,
} as const
export const defaultTranslation = "en-US"

View File

@ -0,0 +1,104 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Be Pavadinimo",
description: "Aprašymas Nepateiktas",
},
components: {
callout: {
note: "Pastaba",
abstract: "Santrauka",
info: "Informacija",
todo: "Darbų sąrašas",
tip: "Patarimas",
success: "Sėkmingas",
question: "Klausimas",
warning: "Įspėjimas",
failure: "Nesėkmingas",
danger: "Pavojus",
bug: "Klaida",
example: "Pavyzdys",
quote: "Citata",
},
backlinks: {
title: "Atgalinės Nuorodos",
noBacklinksFound: "Atgalinių Nuorodų Nerasta",
},
themeToggle: {
lightMode: "Šviesus Režimas",
darkMode: "Tamsus Režimas",
},
explorer: {
title: "Naršyklė",
},
footer: {
createdWith: "Sukurta Su",
},
graph: {
title: "Grafiko Vaizdas",
},
recentNotes: {
title: "Naujausi Užrašai",
seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`,
linkToOriginal: "Nuoroda į originalą",
},
search: {
title: "Paieška",
searchBarPlaceholder: "Ieškoti",
},
tableOfContents: {
title: "Turinys",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
},
},
pages: {
rss: {
recentNotes: "Naujausi užrašai",
lastFewNotes: ({ count }) =>
count === 1
? "Paskutinis 1 užrašas"
: count < 10
? `Paskutiniai ${count} užrašai`
: `Paskutiniai ${count} užrašų`,
},
error: {
title: "Nerasta",
notFound:
"Arba šis puslapis yra pasiekiamas tik tam tikriems vartotojams, arba tokio puslapio nėra.",
home: "Grįžti į pagrindinį puslapį",
},
folderContent: {
folder: "Aplankas",
itemsUnderFolder: ({ count }) =>
count === 1
? "1 elementas šiame aplanke."
: count < 10
? `${count} elementai šiame aplanke.`
: `${count} elementų šiame aplanke.`,
},
tagContent: {
tag: "Žyma",
tagIndex: "Žymų indeksas",
itemsUnderTag: ({ count }) =>
count === 1
? "1 elementas su šia žyma."
: count < 10
? `${count} elementai su šia žyma.`
: `${count} elementų su šia žyma.`,
showingFirst: ({ count }) =>
count < 10 ? `Rodomos pirmosios ${count} žymos.` : `Rodomos pirmosios ${count} žymų.`,
totalTags: ({ count }) =>
count === 1
? "Rasta iš viso 1 žyma."
: count < 10
? `Rasta iš viso ${count} žymos.`
: `Rasta iš viso ${count} žymų.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,82 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "ไม่มีชื่อ",
description: "ไม่ได้ระบุคำอธิบายย่อ",
},
components: {
callout: {
note: "หมายเหตุ",
abstract: "บทคัดย่อ",
info: "ข้อมูล",
todo: "ต้องทำเพิ่มเติม",
tip: "คำแนะนำ",
success: "เรียบร้อย",
question: "คำถาม",
warning: "คำเตือน",
failure: "ข้อผิดพลาด",
danger: "อันตราย",
bug: "บั๊ก",
example: "ตัวอย่าง",
quote: "คำพูกยกมา",
},
backlinks: {
title: "หน้าที่กล่าวถึง",
noBacklinksFound: "ไม่มีหน้าที่โยงมาหน้านี้",
},
themeToggle: {
lightMode: "โหมดสว่าง",
darkMode: "โหมดมืด",
},
explorer: {
title: "รายการหน้า",
},
footer: {
createdWith: "สร้างด้วย",
},
graph: {
title: "มุมมองกราฟ",
},
recentNotes: {
title: "บันทึกล่าสุด",
seeRemainingMore: ({ remaining }) => `ดูเพิ่มอีก ${remaining} รายการ →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `รวมข้ามเนื้อหาจาก ${targetSlug}`,
linkToOriginal: "ดูหน้าต้นทาง",
},
search: {
title: "ค้นหา",
searchBarPlaceholder: "ค้นหาบางอย่าง",
},
tableOfContents: {
title: "สารบัญ",
},
contentMeta: {
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
},
},
pages: {
rss: {
recentNotes: "บันทึกล่าสุด",
lastFewNotes: ({ count }) => `${count} บันทึกล่าสุด`,
},
error: {
title: "ไม่มีหน้านี้",
notFound: "หน้านี้อาจตั้งค่าเป็นส่วนตัวหรือยังไม่ถูกสร้าง",
home: "กลับหน้าหลัก",
},
folderContent: {
folder: "โฟลเดอร์",
itemsUnderFolder: ({ count }) => `มี ${count} รายการในโฟลเดอร์นี้`,
},
tagContent: {
tag: "แท็ก",
tagIndex: "แท็กทั้งหมด",
itemsUnderTag: ({ count }) => `มี ${count} รายการในแท็กนี้`,
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
},
},
} as const satisfies Translation

View File

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

View File

@ -0,0 +1,82 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "無題",
description: "無描述",
},
components: {
callout: {
note: "筆記",
abstract: "摘要",
info: "提示",
todo: "待辦",
tip: "提示",
success: "成功",
question: "問題",
warning: "警告",
failure: "失敗",
danger: "危險",
bug: "錯誤",
example: "範例",
quote: "引用",
},
backlinks: {
title: "反向連結",
noBacklinksFound: "無法找到反向連結",
},
themeToggle: {
lightMode: "亮色模式",
darkMode: "暗色模式",
},
explorer: {
title: "探索",
},
footer: {
createdWith: "Created with",
},
graph: {
title: "關係圖譜",
},
recentNotes: {
title: "最近的筆記",
seeRemainingMore: ({ remaining }) => `查看更多 ${remaining} 篇筆記 →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `包含 ${targetSlug}`,
linkToOriginal: "指向原始筆記的連結",
},
search: {
title: "搜尋",
searchBarPlaceholder: "搜尋些什麼",
},
tableOfContents: {
title: "目錄",
},
contentMeta: {
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
},
},
pages: {
rss: {
recentNotes: "最近的筆記",
lastFewNotes: ({ count }) => `最近的 ${count} 條筆記`,
},
error: {
title: "無法找到",
notFound: "私人筆記或筆記不存在。",
home: "返回首頁",
},
folderContent: {
folder: "資料夾",
itemsUnderFolder: ({ count }) => `此資料夾下有 ${count} 條筆記。`,
},
tagContent: {
tag: "標籤",
tagIndex: "標籤索引",
itemsUnderTag: ({ count }) => `此標籤下有 ${count} 條筆記。`,
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
},
},
} as const satisfies Translation

View File

@ -37,7 +37,6 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const externalResources = pageResources(path, resources)
const notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({
slug,
@ -45,6 +44,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
description: notFound,
frontmatter: { title: notFound, tags: [] },
})
const externalResources = pageResources(path, vfile.data, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: vfile.data,

View File

@ -106,7 +106,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
containsIndex = true
}
const externalResources = pageResources(pathToRoot(slug), resources)
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,

View File

@ -76,12 +76,11 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => {
const slug = data.slug
const folderName = path.dirname(slug ?? "") as SimpleSlug
if (slug && folderName !== "." && folderName !== "tags") {
return [folderName]
}
return []
return data.slug
? _getFolders(data.slug).filter(
(folderName) => folderName !== "." && folderName !== "tags",
)
: []
}),
)
@ -107,8 +106,8 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = folderDescriptions[folder]
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
@ -133,3 +132,14 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
},
}
}
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}

View File

@ -105,14 +105,17 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
const tag = slug.slice("tags/".length)
if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file]
if (file.data.frontmatter?.title === tag) {
file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
}
}
}
}
for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = tagDescriptions[tag]
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,

View File

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

View File

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

View File

@ -71,6 +71,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
if (cssclasses) data.cssclasses = cssclasses
const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"])
const created = coalesceAliases(data, ["created", "date"])
if (created) data.created = created
const modified = coalesceAliases(data, [
"modified",
"lastmod",
"updated",
"last-modified",
])
if (modified) data.modified = modified
const published = coalesceAliases(data, ["published", "publishDate", "date"])
if (published) data.published = published
if (socialImage) data.socialImage = socialImage
// fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
}
@ -87,12 +103,17 @@ declare module "vfile" {
} & Partial<{
tags: string[]
aliases: string[]
modified: string
created: string
published: string
description: string
publish: boolean
draft: boolean
publish: boolean | string
draft: boolean | string
lang: string
enableToc: string
cssclasses: string[]
socialImage: string
comments: boolean | string
}>
}
}

View File

@ -48,11 +48,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
created ||= st.birthtimeMs
modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date as MaybeDate
modified ||= file.data.frontmatter.lastmod as MaybeDate
modified ||= file.data.frontmatter.updated as MaybeDate
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
published ||= file.data.frontmatter.publishDate as MaybeDate
created ||= file.data.frontmatter.created as MaybeDate
modified ||= file.data.frontmatter.modified as MaybeDate
published ||= file.data.frontmatter.published as MaybeDate
} else if (source === "git") {
if (!repo) {
// Get a reference to the main git repo.

View File

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

View File

@ -1,12 +1,20 @@
import { QuartzTransformerPlugin } from "../types"
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
import {
Root,
Html,
BlockContent,
PhrasingContent,
DefinitionContent,
Paragraph,
Code,
} from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import rehypeRaw from "rehype-raw"
import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { splitAnchor } from "../../util/path"
import { JSResource } from "../../util/resources"
import { JSResource, CSSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts"
// @ts-ignore
@ -14,7 +22,6 @@ import checkboxScript from "../../components/scripts/checkbox.inline.ts"
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
import { capitalize } from "../../util/lang"
import { PluggableList } from "unified"
@ -114,19 +121,19 @@ export const wikilinkRegex = new RegExp(
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm)
// matches any wikilink, only used for escaping wikilinks inside tables
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\])/g)
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g)
const highlightRegex = new RegExp(/==([^=]+)==/g)
const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\|?(.+?)?\]([+-]?)/)
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm)
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
// #(...) -> capturing group, tag itself must start with #
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp(
/(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
)
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
@ -153,7 +160,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
src = src.toString()
}
src = src.replace(commentRegex, "")
src = (src as string).replace(commentRegex, "")
}
// pre-transform blockquotes
@ -162,7 +169,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
src = src.toString()
}
src = src.replace(calloutLineRegex, (value) => {
src = (src as string).replace(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
@ -175,7 +182,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
}
// replace all wikilinks inside a table first
src = src.replace(tableRegex, (value) => {
src = (src as string).replace(tableRegex, (value) => {
// escape all aliases and headers in wikilinks inside a table
return value.replace(tableWikilinkRegex, (_value, raw) => {
// const [raw]: (string | undefined)[] = capture
@ -189,7 +196,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
})
// replace all other wikilinks
src = src.replace(wikilinkRegex, (value, ...capture) => {
src = (src as string).replace(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
@ -279,6 +286,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
// internal link
const url = fp + anchor
return {
type: "link",
url,
@ -430,7 +438,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(typeString) : titleContent + " ",
value: useDefaultTitle
? capitalize(typeString).replace(/-/g, " ")
: titleContent + " ",
},
...restOfTitle,
],
@ -507,12 +517,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.mermaid) {
plugins.push(() => {
return (tree: Root, _file) => {
return (tree: Root, file) => {
visit(tree, "code", (node: Code) => {
if (node.lang === "mermaid") {
file.data.hasMermaidDiagram = true
node.data = {
hProperties: {
className: ["mermaid"],
"data-clipboard": JSON.stringify(node.value),
},
}
}
@ -657,10 +669,138 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
})
}
if (opts.mermaid) {
plugins.push(() => {
return (tree: HtmlRoot, _file) => {
visit(tree, "element", (node: Element, _idx, parent) => {
if (
node.tagName === "code" &&
((node.properties?.className ?? []) as string[])?.includes("mermaid")
) {
parent!.children = [
{
type: "element",
tagName: "button",
properties: {
className: ["expand-button"],
"aria-label": "Expand mermaid diagram",
"aria-hidden": "true",
"data-view-component": true,
},
children: [
{
type: "element",
tagName: "svg",
properties: {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "currentColor",
},
children: [
{
type: "element",
tagName: "path",
properties: {
fillRule: "evenodd",
d: "M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z",
},
children: [],
},
],
},
],
},
node,
{
type: "element",
tagName: "div",
properties: { id: "mermaid-container" },
children: [
{
type: "element",
tagName: "div",
properties: { id: "mermaid-space" },
children: [
{
type: "element",
tagName: "div",
properties: { className: ["mermaid-header"] },
children: [
{
type: "element",
tagName: "button",
properties: {
className: ["close-button"],
"aria-label": "close button",
},
children: [
{
type: "element",
tagName: "svg",
properties: {
"aria-hidden": "true",
xmlns: "http://www.w3.org/2000/svg",
width: 24,
height: 24,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
children: [
{
type: "element",
tagName: "line",
properties: {
x1: 18,
y1: 6,
x2: 6,
y2: 18,
},
children: [],
},
{
type: "element",
tagName: "line",
properties: {
x1: 6,
y1: 6,
x2: 18,
y2: 18,
},
children: [],
},
],
},
],
},
],
},
{
type: "element",
tagName: "div",
properties: { className: ["mermaid-content"] },
children: [],
},
],
},
],
},
]
}
})
}
})
}
return plugins
},
externalResources() {
const js: JSResource[] = []
const css: CSSResource[] = []
if (opts.enableCheckbox) {
js.push({
@ -678,34 +818,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
})
}
if (opts.mermaid) {
js.push({
script: `
let mermaidImport = undefined
document.addEventListener('nav', async () => {
if (document.querySelector("code.mermaid")) {
mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs')
const mermaid = mermaidImport.default
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: darkMode ? 'dark' : 'default'
})
await mermaid.run({
querySelector: '.mermaid'
})
}
});
`,
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
}
return { js }
return { js, css }
},
}
}
@ -714,5 +827,6 @@ declare module "vfile" {
interface DataMap {
blocks: Record<string, Element>
htmlAst: HtmlRoot
hasMermaidDiagram: boolean | undefined
}
}

View File

@ -1,11 +1,13 @@
import { Node, Parent } from "hast"
import { Root as HtmlRoot } from "hast"
import { Root as MdRoot } from "mdast"
import { Data, VFile } from "vfile"
export type QuartzPluginData = Data
export type ProcessedContent = [Node, VFile]
export type MarkdownContent = [MdRoot, VFile]
export type ProcessedContent = [HtmlRoot, VFile]
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
const root: Parent = { type: "root", children: [] }
const root: HtmlRoot = { type: "root", children: [] }
const vfile = new VFile("")
vfile.data = vfileData
return [root, vfile]

View File

@ -4,18 +4,20 @@ import remarkRehype from "remark-rehype"
import { Processor, unified } from "unified"
import { Root as MDRoot } from "remark-parse/lib"
import { Root as HTMLRoot } from "hast"
import { ProcessedContent } from "../plugins/vfile"
import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
import { PerfTimer } from "../util/perf"
import { read } from "to-vfile"
import { FilePath, QUARTZ, slugifyFilePath } from "../util/path"
import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path"
import path from "path"
import workerpool, { Promise as WorkerPromise } from "workerpool"
import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
export type QuartzProcessor = Processor<MDRoot, MDRoot, HTMLRoot>
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
export function createMdProcessor(ctx: BuildCtx): QuartzMdProcessor {
const transformers = ctx.cfg.plugins.transformers
return (
@ -24,14 +26,20 @@ export function createProcessor(ctx: BuildCtx): QuartzProcessor {
.use(remarkParse)
// MD AST -> MD AST transforms
.use(
transformers
.filter((p) => p.markdownPlugins)
.flatMap((plugin) => plugin.markdownPlugins!(ctx)),
transformers.flatMap((plugin) => plugin.markdownPlugins?.(ctx) ?? []),
) as unknown as QuartzMdProcessor
// ^ sadly the typing of `use` is not smart enough to infer the correct type from our plugin list
)
}
export function createHtmlProcessor(ctx: BuildCtx): QuartzHtmlProcessor {
const transformers = ctx.cfg.plugins.transformers
return (
unified()
// MD AST -> HTML AST
.use(remarkRehype, { allowDangerousHtml: true })
// HTML AST -> HTML AST transforms
.use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
.use(transformers.flatMap((plugin) => plugin.htmlPlugins?.(ctx) ?? []))
)
}
@ -75,8 +83,8 @@ async function transpileWorkerScript() {
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
const { argv, cfg } = ctx
return async (processor: QuartzProcessor) => {
const res: ProcessedContent[] = []
return async (processor: QuartzMdProcessor) => {
const res: MarkdownContent[] = []
for (const fp of fps) {
try {
const perf = new PerfTimer()
@ -100,10 +108,32 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
res.push([newAst, file])
if (argv.verbose) {
console.log(`[process] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
console.log(`[markdown] ${fp} -> ${file.data.slug} (${perf.timeSince()})`)
}
} catch (err) {
trace(`\nFailed to process \`${fp}\``, err as Error)
trace(`\nFailed to process markdown \`${fp}\``, err as Error)
}
}
return res
}
}
export function createMarkdownParser(ctx: BuildCtx, mdContent: MarkdownContent[]) {
return async (processor: QuartzHtmlProcessor) => {
const res: ProcessedContent[] = []
for (const [ast, file] of mdContent) {
try {
const perf = new PerfTimer()
const newAst = await processor.run(ast as MDRoot, file)
res.push([newAst, file])
if (ctx.argv.verbose) {
console.log(`[html] ${file.data.slug} (${perf.timeSince()})`)
}
} catch (err) {
trace(`\nFailed to process html \`${file.data.filePath}\``, err as Error)
}
}
@ -113,6 +143,7 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
const clamp = (num: number, min: number, max: number) =>
Math.min(Math.max(Math.round(num), min), max)
export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<ProcessedContent[]> {
const { argv } = ctx
const perf = new PerfTimer()
@ -126,9 +157,8 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
log.start(`Parsing input files using ${concurrency} threads`)
if (concurrency === 1) {
try {
const processor = createProcessor(ctx)
const parse = createFileParser(ctx, fps)
res = await parse(processor)
const mdRes = await createFileParser(ctx, fps)(createMdProcessor(ctx))
res = await createMarkdownParser(ctx, mdRes)(createHtmlProcessor(ctx))
} catch (error) {
log.end()
throw error
@ -140,17 +170,27 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
maxWorkers: concurrency,
workerType: "thread",
})
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
for (const chunk of chunks(fps, CHUNK_SIZE)) {
childPromises.push(pool.exec("parseFiles", [ctx.buildId, argv, chunk, ctx.allSlugs]))
const errorHandler = (err: any) => {
console.error(`${err}`.replace(/^error:\s*/i, ""))
process.exit(1)
}
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch((err) => {
const errString = err.toString().slice("Error:".length)
console.error(errString)
process.exit(1)
})
const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
for (const chunk of chunks(fps, CHUNK_SIZE)) {
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
}
const mdResults: [MarkdownContent[], FullSlug[]][] =
await WorkerPromise.all(mdPromises).catch(errorHandler)
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
for (const [_, extraSlugs] of mdResults) {
ctx.allSlugs.push(...extraSlugs)
}
for (const [mdChunk, _] of mdResults) {
childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
}
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)
res = results.flat()
await pool.terminate()
}

View File

@ -0,0 +1,99 @@
/*! MIT License
* Copyright (c) 2018 GitHub Inc.
* https://github.com/primer/primitives/blob/main/LICENSE
*/
main {
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
--color-prettylights-syntax-storage-modifier-import: #c9d1d9;
--color-prettylights-syntax-entity-tag: #7ee787;
--color-prettylights-syntax-keyword: #ff7b72;
--color-prettylights-syntax-string: #a5d6ff;
--color-prettylights-syntax-variable: #ffa657;
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
--color-prettylights-syntax-invalid-illegal-bg: #8e1519;
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
--color-prettylights-syntax-carriage-return-bg: #b62324;
--color-prettylights-syntax-string-regexp: #7ee787;
--color-prettylights-syntax-markup-list: #f2cc60;
--color-prettylights-syntax-markup-heading: #1f6feb;
--color-prettylights-syntax-markup-italic: #c9d1d9;
--color-prettylights-syntax-markup-bold: #c9d1d9;
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
--color-prettylights-syntax-markup-deleted-bg: #67060c;
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
--color-prettylights-syntax-markup-inserted-bg: #033a16;
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
--color-btn-text: #d4d4d4; /* --darkgray */
--color-btn-bg: #161618; /* --light */
--color-btn-border: rgb(240, 246, 252 / 10%); /* --dark */
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: #30363d;
--color-btn-hover-border: #8b949e;
--color-btn-active-bg: hsl(212deg 12% 18% / 100%);
--color-btn-active-border: #6e7681;
--color-btn-selected-bg: #161b22;
--color-btn-primary-text: #fff;
--color-btn-primary-bg: #84a59d; /* --tertiary */
--color-btn-primary-border: rgb(240, 246, 252 / 10%); /* --dark */
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: #7b97aa; /* --secondary */
--color-btn-primary-hover-border: rgb(240, 246, 252 / 10%); /* --dark */
--color-btn-primary-selected-bg: #7b97aa; /* --secondary */
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: rgba(33, 32, 32, 0.5);
--color-btn-primary-disabled-bg: rgb(35 134 54 / 60%);
--color-btn-primary-disabled-border: rgb(240 246 252 / 10%);
--color-action-list-item-default-hover-bg: rgb(177 186 196 / 12%);
--color-segmented-control-bg: rgb(110 118 129 / 10%);
--color-segmented-control-button-bg: #0d1117;
--color-segmented-control-button-selected-border: #6e7681;
--color-fg-default: #ebebec; /* --dark */
--color-fg-muted: #d4d4d4; /* --darkgray */
--color-fg-subtle: #d4d4d4; /* --darkgray */
--color-canvas-default: #0d1117;
--color-canvas-overlay: #161b22;
--color-canvas-inset: #010409;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-neutral-muted: rgb(110 118 129 / 40%);
--color-accent-fg: #2f81f7;
--color-accent-emphasis: #1f6feb;
--color-accent-muted: rgb(56 139 253 / 40%);
--color-accent-subtle: rgb(56 139 253 / 10%);
--color-success-fg: #3fb950;
--color-attention-fg: #d29922;
--color-attention-muted: rgb(187 128 9 / 40%);
--color-attention-subtle: rgb(187 128 9 / 15%);
--color-danger-fg: #f85149;
--color-danger-muted: rgb(248 81 73 / 40%);
--color-danger-subtle: rgb(248 81 73 / 10%);
--color-primer-shadow-inset: 0 0 transparent;
--color-scale-gray-7: #21262d;
--color-scale-blue-8: #0c2d6b;
/*! Extensions from @primer/css/alerts/flash.scss */
--color-social-reaction-bg-hover: var(--color-scale-gray-7);
--color-social-reaction-bg-reacted-hover: var(--color-scale-blue-8);
}
main .pagination-loader-container {
background-image: url("https://github.com/images/modules/pulls/progressive-disclosure-line-dark.svg");
}
main .gsc-loading-image {
background-image: url("https://github.githubassets.com/images/mona-loading-dark.gif");
}

View File

@ -0,0 +1,99 @@
/*! MIT License
* Copyright (c) 2018 GitHub Inc.
* https://github.com/primer/primitives/blob/main/LICENSE
*/
main {
--color-prettylights-syntax-comment: #6e7781;
--color-prettylights-syntax-constant: #0550ae;
--color-prettylights-syntax-entity: #8250df;
--color-prettylights-syntax-storage-modifier-import: #24292f;
--color-prettylights-syntax-entity-tag: #116329;
--color-prettylights-syntax-keyword: #cf222e;
--color-prettylights-syntax-string: #0a3069;
--color-prettylights-syntax-variable: #953800;
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
--color-prettylights-syntax-carriage-return-bg: #cf222e;
--color-prettylights-syntax-string-regexp: #116329;
--color-prettylights-syntax-markup-list: #3b2300;
--color-prettylights-syntax-markup-heading: #0550ae;
--color-prettylights-syntax-markup-italic: #24292f;
--color-prettylights-syntax-markup-bold: #24292f;
--color-prettylights-syntax-markup-deleted-text: #82071e;
--color-prettylights-syntax-markup-deleted-bg: #ffebe9;
--color-prettylights-syntax-markup-inserted-text: #116329;
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
--color-prettylights-syntax-markup-changed-text: #953800;
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
--color-prettylights-syntax-meta-diff-range: #8250df;
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
--color-btn-text: #4e4e4e; /* --darkgray */
--color-btn-bg: #faf8f8; /* --light */
--color-btn-border: rgb(43, 43, 43 / 15%); /* --dark */
--color-btn-shadow: 0 1px 0 rgb(31 35 40 / 4%);
--color-btn-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 25%);
--color-btn-hover-bg: #f3f4f6;
--color-btn-hover-border: rgb(43, 43, 43 / 15%); /* --dark */
--color-btn-active-bg: hsl(220deg 14% 93% / 100%);
--color-btn-active-border: rgb(31 35 40 / 15%);
--color-btn-selected-bg: hsl(220deg 14% 94% / 100%);
--color-btn-primary-text: #fff;
--color-btn-primary-bg: #84a59d; /* --tertiary */
--color-btn-primary-border: rgb(43, 43, 43 / 15%); /* --dark */
--color-btn-primary-shadow: 0 1px 0 rgb(31 35 40 / 10%);
--color-btn-primary-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
--color-btn-primary-hover-bg: #284b63; /* --secondary */
--color-btn-primary-hover-border: rgb(43, 43, 43 / 15%); /* --dark */
--color-btn-primary-selected-bg: #284b63; /* --secondary */
--color-btn-primary-selected-shadow: inset 0 1px 0 rgb(0 45 17 / 20%);
--color-btn-primary-disabled-text: rgb(255 255 255 / 80%);
--color-btn-primary-disabled-bg: #94d3a2;
--color-btn-primary-disabled-border: rgb(31 35 40 / 15%);
--color-action-list-item-default-hover-bg: rgb(208 215 222 / 32%);
--color-segmented-control-bg: #eaeef2;
--color-segmented-control-button-bg: #fff;
--color-segmented-control-button-selected-border: #8c959f;
--color-fg-default: #2b2b2b; /* --dark */
--color-fg-muted: #4e4e4e; /* --darkgray */
--color-fg-subtle: #4e4e4e; /* --darkgray */
--color-canvas-default: #fff;
--color-canvas-overlay: #fff;
--color-canvas-inset: #f6f8fa;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: hsl(210deg 18% 87% / 100%);
--color-neutral-muted: rgb(175 184 193 / 20%);
--color-accent-fg: #0969da;
--color-accent-emphasis: #0969da;
--color-accent-muted: rgb(84 174 255 / 40%);
--color-accent-subtle: #ddf4ff;
--color-success-fg: #1a7f37;
--color-attention-fg: #9a6700;
--color-attention-muted: rgb(212 167 44 / 40%);
--color-attention-subtle: #fff8c5;
--color-danger-fg: #d1242f;
--color-danger-muted: rgb(255 129 130 / 40%);
--color-danger-subtle: #ffebe9;
--color-primer-shadow-inset: inset 0 1px 0 rgb(208 215 222 / 20%);
--color-scale-gray-1: #eaeef2;
--color-scale-blue-1: #b6e3ff;
/*! Extensions from @primer/css/alerts/flash.scss */
--color-social-reaction-bg-hover: var(--color-scale-gray-1);
--color-social-reaction-bg-reacted-hover: var(--color-scale-blue-1);
}
main .pagination-loader-container {
background-image: url("https://github.com/images/modules/pulls/progressive-disclosure-line.svg");
}
main .gsc-loading-image {
background-image: url("https://github.githubassets.com/images/mona-loading-default.gif");
}

View File

@ -1,3 +1,5 @@
@use "sass:map";
@use "./variables.scss" as *;
@use "./syntax.scss";
@use "./callouts.scss";
@ -85,7 +87,7 @@ a {
line-height: 1.4rem;
&:has(> img) {
background-color: none;
background-color: transparent;
border-radius: 0;
padding: 0;
}
@ -121,7 +123,7 @@ a {
}
.page {
max-width: calc(#{map-get($breakpoints, desktop)} + 300px);
max-width: calc(#{map.get($breakpoints, desktop)} + 300px);
margin: 0 auto;
& article {
& > h1 {
@ -151,27 +153,28 @@ a {
& > #quartz-body {
display: grid;
grid-template-columns: #{map-get($desktopGrid, templateColumns)};
grid-template-rows: #{map-get($desktopGrid, templateRows)};
column-gap: #{map-get($desktopGrid, columnGap)};
row-gap: #{map-get($desktopGrid, rowGap)};
grid-template-areas: #{map-get($desktopGrid, templateAreas)};
@media all and ($desktop) {
grid-template-columns: #{map-get($tabletGrid, templateColumns)};
grid-template-rows: #{map-get($tabletGrid, templateRows)};
column-gap: #{map-get($tabletGrid, columnGap)};
row-gap: #{map-get($tabletGrid, rowGap)};
grid-template-areas: #{map-get($tabletGrid, templateAreas)};
grid-template-columns: #{map.get($desktopGrid, templateColumns)};
grid-template-rows: #{map.get($desktopGrid, templateRows)};
column-gap: #{map.get($desktopGrid, columnGap)};
row-gap: #{map.get($desktopGrid, rowGap)};
grid-template-areas: #{map.get($desktopGrid, templateAreas)};
@media all and ($tablet) {
grid-template-columns: #{map.get($tabletGrid, templateColumns)};
grid-template-rows: #{map.get($tabletGrid, templateRows)};
column-gap: #{map.get($tabletGrid, columnGap)};
row-gap: #{map.get($tabletGrid, rowGap)};
grid-template-areas: #{map.get($tabletGrid, templateAreas)};
}
@media all and ($mobile) {
grid-template-columns: #{map-get($mobileGrid, templateColumns)};
grid-template-rows: #{map-get($mobileGrid, templateRows)};
column-gap: #{map-get($mobileGrid, columnGap)};
row-gap: #{map-get($mobileGrid, rowGap)};
grid-template-areas: #{map-get($mobileGrid, templateAreas)};
grid-template-columns: #{map.get($mobileGrid, templateColumns)};
grid-template-rows: #{map.get($mobileGrid, templateRows)};
column-gap: #{map.get($mobileGrid, columnGap)};
row-gap: #{map.get($mobileGrid, rowGap)};
grid-template-areas: #{map.get($mobileGrid, templateAreas)};
}
@media all and ($desktop) {
@media all and not ($desktop) {
padding: 0 1rem;
}
@media all and ($mobile) {
@ -212,7 +215,7 @@ a {
margin-left: inherit;
margin-right: inherit;
}
@media all and ($desktop) {
@media all and not ($desktop) {
position: initial;
height: unset;
width: 100%;
@ -254,10 +257,11 @@ a {
min-width: 100%;
margin-left: auto;
margin-right: auto;
@media all and ($desktop) {
@media all and ($tablet) {
margin-right: 0;
}
@media all and ($mobile) {
margin-right: 0;
margin-left: 0;
}
}
@ -387,7 +391,7 @@ figure[data-rehype-pretty-code-figure] {
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
border: 1px solid var(--lightgray);
width: max-content;
width: fit-content;
border-radius: 5px;
margin-bottom: -0.5rem;
color: var(--darkgray);
@ -511,6 +515,7 @@ img {
max-width: 100%;
border-radius: 5px;
margin: 1rem 0;
content-visibility: auto;
}
p > img + em {
@ -586,3 +591,14 @@ iframe.pdf {
width: 100%;
border-radius: 5px;
}
.navigation-progress {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 3px;
background: var(--secondary);
transition: width 0.2s ease;
z-index: 9999;
}

View File

@ -1,3 +1,5 @@
@use "sass:map";
/**
* Layout breakpoints
* $mobile: screen width below this value will use mobile styles
@ -10,11 +12,11 @@ $breakpoints: (
desktop: 1200px,
);
$mobile: "(max-width: #{map-get($breakpoints, mobile)})";
$tablet: "(min-width: #{map-get($breakpoints, mobile)}) and (max-width: #{map-get($breakpoints, desktop)})";
$desktop: "(max-width: #{map-get($breakpoints, desktop)})";
$mobile: "(max-width: #{map.get($breakpoints, mobile)})";
$tablet: "(min-width: #{map.get($breakpoints, mobile)}) and (max-width: #{map.get($breakpoints, desktop)})";
$desktop: "(min-width: #{map.get($breakpoints, desktop)})";
$pageWidth: #{map-get($breakpoints, mobile)};
$pageWidth: #{map.get($breakpoints, mobile)};
$sidePanelWidth: 320px; //380px;
$topSpacing: 6rem;
$boldWeight: 700;

View File

@ -6,3 +6,12 @@ export const escapeHTML = (unsafe: string) => {
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;")
}
export const unescapeHTML = (html: string) => {
return html
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#039;", "'")
}

202
quartz/util/og.tsx Normal file
View File

@ -0,0 +1,202 @@
import { FontWeight, SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
import { JSXInternal } from "preact/src/jsx"
import { ThemeKey } from "./theme"
/**
* Get an array of `FontOptions` (for satori) given google font names
* @param headerFontName name of google font used for header
* @param bodyFontName name of google font used for body
* @returns FontOptions for header and body
*/
export async function getSatoriFont(headerFontName: string, bodyFontName: string) {
const headerWeight = 700 as FontWeight
const bodyWeight = 400 as FontWeight
// Fetch fonts
const headerFont = await fetchTtf(headerFontName, headerWeight)
const bodyFont = await fetchTtf(bodyFontName, bodyWeight)
// Convert fonts to satori font format and return
const fonts: SatoriOptions["fonts"] = [
{ name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
{ name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
]
return fonts
}
/**
* Get the `.ttf` file of a google font
* @param fontName name of google font
* @param weight what font weight to fetch font
* @returns `.ttf` file of google font
*/
async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
try {
// Get css file from google fonts
const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
)
const css = await cssResponse.text()
// Extract .ttf url from css file
const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
const match = urlRegex.exec(css)
if (!match) {
throw new Error("Could not fetch font")
}
// Retrieve font data as ArrayBuffer
const fontResponse = await fetch(match[1])
// fontData is an ArrayBuffer containing the .ttf file data (get match[1] due to google fonts response format, always contains link twice, but second entry is the "raw" link)
const fontData = await fontResponse.arrayBuffer()
return fontData
} catch (error) {
throw new Error(`Error fetching font: ${error}`)
}
}
export type SocialImageOptions = {
/**
* What color scheme to use for image generation (uses colors from config theme)
*/
colorScheme: ThemeKey
/**
* Height to generate image with in pixels (should be around 630px)
*/
height: number
/**
* Width to generate image with in pixels (should be around 1200px)
*/
width: number
/**
* Whether to use the auto generated image for the root path ("/", when set to false) or the default og image (when set to true).
*/
excludeRoot: boolean
/**
* JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
* @param cfg global quartz config
* @param userOpts options that can be set by user
* @param title title of current page
* @param description description of current page
* @param fonts global font that can be used for styling
* @param fileData full fileData of current page
* @returns prepared jsx to be used for generating image
*/
imageStructure: (
cfg: GlobalConfiguration,
userOpts: UserOpts,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData,
) => JSXInternal.Element
}
export type UserOpts = Omit<SocialImageOptions, "imageStructure">
export type ImageOptions = {
/**
* what title to use as header in image
*/
title: string
/**
* what description to use as body in image
*/
description: string
/**
* what fileName to use when writing to disk
*/
fileName: string
/**
* what directory to store image in
*/
fileDir: string
/**
* what file extension to use (should be `webp` unless you also change sharp conversion)
*/
fileExt: string
/**
* header + body font to be used when generating satori image (as promise to work around sync in component)
*/
fontsPromise: Promise<SatoriOptions["fonts"]>
/**
* `GlobalConfiguration` of quartz (used for theme/typography)
*/
cfg: GlobalConfiguration
/**
* full file data of current page
*/
fileData: QuartzPluginData
}
// This is the default template for generated social image.
export const defaultImage: SocialImageOptions["imageStructure"] = (
cfg: GlobalConfiguration,
{ colorScheme }: UserOpts,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
_fileData: QuartzPluginData,
) => {
// How many characters are allowed before switching to smaller font
const fontBreakPoint = 22
const useSmallerFont = title.length > fontBreakPoint
// Setup to access image
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
gap: "2rem",
paddingTop: "1.5rem",
paddingBottom: "1.5rem",
paddingLeft: "5rem",
paddingRight: "5rem",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
width: "100%",
flexDirection: "row",
gap: "2.5rem",
}}
>
<img src={iconPath} width={135} height={135} />
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
fontFamily: fonts[0].name,
}}
>
{title}
</p>
</div>
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: 44,
lineClamp: 3,
fontFamily: fonts[1].name,
}}
>
{description}
</p>
</div>
)
}

View File

@ -158,6 +158,29 @@ describe("transforms", () => {
path.isRelativeURL,
)
})
test("joinSegments", () => {
assert.strictEqual(path.joinSegments("a", "b"), "a/b")
assert.strictEqual(path.joinSegments("a/", "b"), "a/b")
assert.strictEqual(path.joinSegments("a", "b/"), "a/b/")
assert.strictEqual(path.joinSegments("a/", "b/"), "a/b/")
// preserve leading and trailing slashes
assert.strictEqual(path.joinSegments("/a", "b"), "/a/b")
assert.strictEqual(path.joinSegments("/a/", "b"), "/a/b")
assert.strictEqual(path.joinSegments("/a", "b/"), "/a/b/")
assert.strictEqual(path.joinSegments("/a/", "b/"), "/a/b/")
// lone slash
assert.strictEqual(path.joinSegments("/a/", "b", "/"), "/a/b/")
assert.strictEqual(path.joinSegments("a/", "b" + "/"), "a/b/")
// works with protocol specifiers
assert.strictEqual(path.joinSegments("https://example.com", "a"), "https://example.com/a")
assert.strictEqual(path.joinSegments("https://example.com/", "a"), "https://example.com/a")
assert.strictEqual(path.joinSegments("https://example.com", "a/"), "https://example.com/a/")
assert.strictEqual(path.joinSegments("https://example.com/", "a/"), "https://example.com/a/")
})
})
describe("link strategies", () => {

View File

@ -108,10 +108,10 @@ const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) =>
el.setAttribute(attr, rebased.pathname + rebased.hash)
}
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
el.querySelectorAll('[href=""], [href^="./"], [href^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "href", destination),
)
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
el.querySelectorAll('[src=""], [src^="./"], [src^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "src", destination),
)
}
@ -183,10 +183,26 @@ export function slugTag(tag: string) {
}
export function joinSegments(...args: string[]): string {
return args
.filter((segment) => segment !== "")
if (args.length === 0) {
return ""
}
let joined = args
.filter((segment) => segment !== "" && segment !== "/")
.map((segment) => stripSlashes(segment))
.join("/")
.replace(/\/\/+/g, "/")
// if the first segment starts with a slash, add it back
if (args[0].startsWith("/")) {
joined = "/" + joined
}
// if the last segment is a folder, add a trailing slash
if (args[args.length - 1].endsWith("/")) {
joined = joined + "/"
}
return joined
}
export function getAllSegmentPrefixes(tags: string): string[] {

View File

@ -16,6 +16,12 @@ export type JSResource = {
}
)
export type CSSResource = {
content: string
inline?: boolean
spaPreserve?: boolean
}
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
const scriptType = resource.moduleType ?? "application/javascript"
const spaPreserve = preserve ?? resource.spaPreserve
@ -36,7 +42,24 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
}
}
export function CSSResourceToStyleElement(resource: CSSResource, preserve?: boolean): JSX.Element {
const spaPreserve = preserve ?? resource.spaPreserve
if (resource.inline ?? false) {
return <style>{resource.content}</style>
} else {
return (
<link
key={resource.content}
href={resource.content}
rel="stylesheet"
type="text/css"
spa-preserve={spaPreserve}
/>
)
}
}
export interface StaticResources {
css: string[]
css: CSSResource[]
js: JSResource[]
}

View File

@ -3,23 +3,46 @@ sourceMapSupport.install(options)
import cfg from "../quartz.config"
import { Argv, BuildCtx } from "./util/ctx"
import { FilePath, FullSlug } from "./util/path"
import { createFileParser, createProcessor } from "./processors/parse"
import {
createFileParser,
createHtmlProcessor,
createMarkdownParser,
createMdProcessor,
} from "./processors/parse"
import { options } from "./util/sourcemap"
import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
// only called from worker thread
export async function parseFiles(
export async function parseMarkdown(
buildId: string,
argv: Argv,
fps: FilePath[],
allSlugs: FullSlug[],
) {
): Promise<[MarkdownContent[], FullSlug[]]> {
// this is a hack
// we assume markdown parsers can add to `allSlugs`,
// but don't actually use them
const allSlugs: FullSlug[] = []
const ctx: BuildCtx = {
buildId,
cfg,
argv,
allSlugs,
}
const processor = createProcessor(ctx)
const parse = createFileParser(ctx, fps)
return parse(processor)
return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
}
// only called from worker thread
export function processHtml(
buildId: string,
argv: Argv,
mds: MarkdownContent[],
allSlugs: FullSlug[],
): Promise<ProcessedContent[]> {
const ctx: BuildCtx = {
buildId,
cfg,
argv,
allSlugs,
}
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
}