This commit is contained in:
duskflower 2025-05-08 18:20:38 +02:00
commit b15d4357de
Signed by: duskflower
SSH key fingerprint: SHA256:KMvoLo1YrV954Mgsyn660L2/IBpDK+wteufqsOcjF+U
60 changed files with 899 additions and 455 deletions

43
.github/workflows/build-preview.yaml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Build Preview Deployment
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
jobs:
build-preview:
if: ${{ github.repository == 'jackyzha0/quartz' }}
runs-on: ubuntu-latest
name: Build Preview
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- name: Check types and style
run: npm run check
- name: Build Quartz
run: npx quartz build -d docs -v
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: preview-build
path: public

37
.github/workflows/deploy-preview.yaml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Upload Preview Deployment
on:
workflow_run:
workflows: ["Build Preview Deployment"]
types:
- completed
permissions:
actions: read
deployments: write
contents: read
pull-requests: write
jobs:
deploy-preview:
if: ${{ github.repository == 'jackyzha0/quartz' && github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
name: Deploy Preview to Cloudflare Pages
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
id: preview-build-artifact
with:
name: preview-build
path: build
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Deploy to Cloudflare Pages
uses: AdrianGonz97/refined-cf-pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
githubToken: ${{ secrets.GITHUB_TOKEN }}
projectName: quartz
deploymentName: Branch Preview
directory: ${{ steps.preview-build-artifact.outputs.download-path }}

View file

@ -37,7 +37,7 @@ jobs:
network=host network=host
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.8.1 uses: sigstore/cosign-installer@v3.8.2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'

View file

@ -0,0 +1,44 @@
---
title: Reader Mode
tags:
- component
---
Reader Mode is a feature that allows users to focus on the content by hiding the sidebars and other UI elements. When enabled, it provides a clean, distraction-free reading experience.
## Configuration
Reader Mode is enabled by default. To disable it, you can remove the component from your layout configuration in `quartz.layout.ts`:
```ts
// Remove or comment out this line
Component.ReaderMode(),
```
## Usage
The Reader Mode toggle appears as a button with a book icon. When clicked:
- Sidebars are hidden
- Hovering over the content area reveals the sidebars temporarily
Unlike Dark Mode, Reader Mode state is not persisted between page reloads but is maintained during SPA navigation within the site.
## Customization
You can customize the appearance of Reader Mode through CSS variables and styles. The component uses the following classes:
- `.readermode`: The toggle button
- `.readerIcon`: The book icon
- `[reader-mode="on"]`: Applied to the root element when Reader Mode is active
Example customization in your custom CSS:
```scss
.readermode {
// Customize the button
svg {
stroke: var(--custom-color);
}
}
```

19
docs/plugins/Favicon.md Normal file
View file

@ -0,0 +1,19 @@
---
title: Favicon
tags:
- plugin/emitter
---
This plugin emits a `favicon.ico` into the `public` folder. It creates the favicon from `icon.png` located in the `quartz/static` folder.
The plugin resizes `icon.png` to 48x48px to make it as small as possible.
> [!note]
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.
## API
- Category: Emitter
- Function name: `Plugin.Favicon()`.
- Source: [`quartz/plugins/emitters/favicon.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/emitters/favicon.ts).

View file

@ -34,6 +34,13 @@ npx quartz sync --no-pull
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase` > [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
> You may have an outdated version of `git`. Updating `git` should fix this issue. > You may have an outdated version of `git`. Updating `git` should fix this issue.
> [!warning]- `fatal: The remote end hung up unexpectedly`
> It might be due to Git's default buffer size. You can fix it by increasing the buffer with this command:
>
> ```bash
> git config http.postBuffer 524288000
> ```
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository. In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
> [!hint] Flags and options > [!hint] Flags and options

1
index.d.ts vendored
View file

@ -8,6 +8,7 @@ interface CustomEventMap {
prenav: CustomEvent<{}> prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }> themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }>
} }
type ContentIndex = Record<FullSlug, ContentDetails> type ContentIndex = Record<FullSlug, ContentDetails>

674
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -35,11 +35,12 @@
"quartz": "./quartz/bootstrap-cli.mjs" "quartz": "./quartz/bootstrap-cli.mjs"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.10.0", "@clack/prompts": "^0.10.1",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.7.0",
"@myriaddreamin/rehype-typst": "^0.5.4", "@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.19", "@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0", "@tweenjs/tween.js": "^25.0.0",
"@webgpu/types": "^0.1.60",
"ansi-truncate": "^1.2.0", "ansi-truncate": "^1.2.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"chalk": "^5.4.1", "chalk": "^5.4.1",
@ -62,10 +63,10 @@
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5", "micromorph": "^0.4.5",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"pixi.js": "^8.9.1", "pixi.js": "^8.9.2",
"preact": "^10.26.4", "preact": "^10.26.5",
"preact-render-to-string": "^6.5.13", "preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^7.0.0",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
@ -81,13 +82,13 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1", "rfdc": "^1.4.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"satori": "^0.12.2", "satori": "^0.12.2",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"sharp": "^0.33.5", "sharp": "^0.34.1",
"shiki": "^1.26.2", "shiki": "^1.26.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^8.0.0", "to-vfile": "^8.0.0",
@ -96,21 +97,21 @@
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.3", "vfile": "^6.0.3",
"workerpool": "^9.2.0", "workerpool": "^9.2.0",
"ws": "^8.18.1", "ws": "^8.18.2",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.14", "@types/node": "^22.15.7",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.0", "@types/ws": "^8.18.1",
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"esbuild": "^0.25.2", "esbuild": "^0.25.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"tsx": "^4.19.3", "tsx": "^4.19.4",
"typescript": "^5.8.2" "typescript": "^5.8.3"
} }
} }

View file

@ -88,6 +88,7 @@ const config: QuartzConfig = {
}), }),
Plugin.Assets(), Plugin.Assets(),
Plugin.Static(), Plugin.Static(),
Plugin.Favicon(),
Plugin.NotFoundPage(), Plugin.NotFoundPage(),
// Comment out CustomOgImages to speed up build time // Comment out CustomOgImages to speed up build time
Plugin.CustomOgImages(), Plugin.CustomOgImages(),

View file

@ -34,6 +34,7 @@ export const defaultContentPageLayout: PageLayout = {
grow: true, grow: true,
}, },
{ Component: Component.Darkmode() }, { Component: Component.Darkmode() },
{ Component: Component.ReaderMode() },
], ],
}), }),
Component.Explorer(), Component.Explorer(),

View file

@ -589,7 +589,8 @@ export async function handleSync(argv) {
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
if (argv.push) { if (argv.push) {
console.log("Pushing your changes") console.log("Pushing your changes")
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, currentBranch], {
stdio: "inherit", stdio: "inherit",
}) })
if (res.status !== 0) { if (res.status !== 0) {

View file

@ -0,0 +1,38 @@
// @ts-ignore
import readerModeScript from "./scripts/readermode.inline"
import styles from "./styles/readermode.scss"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
return (
<button class={classNames(displayClass, "readermode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
version="1.1"
class="readerIcon"
fill="currentColor"
stroke="currentColor"
stroke-width="0.2"
stroke-linecap="round"
stroke-linejoin="round"
width="64px"
height="64px"
viewBox="0 0 24 24"
aria-label={i18n(cfg.locale).components.readerMode.title}
>
<title>{i18n(cfg.locale).components.readerMode.title}</title>
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
</g>
</svg>
</button>
)
}
ReaderMode.beforeDOMLoaded = readerModeScript
ReaderMode.css = styles
export default (() => ReaderMode) satisfies QuartzComponentConstructor

View file

@ -4,6 +4,7 @@ import FolderContent from "./pages/FolderContent"
import NotFound from "./pages/404" import NotFound from "./pages/404"
import ArticleTitle from "./ArticleTitle" import ArticleTitle from "./ArticleTitle"
import Darkmode from "./Darkmode" import Darkmode from "./Darkmode"
import ReaderMode from "./ReaderMode"
import Head from "./Head" import Head from "./Head"
import PageTitle from "./PageTitle" import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta" import ContentMeta from "./ContentMeta"
@ -29,6 +30,7 @@ export {
TagContent, TagContent,
FolderContent, FolderContent,
Darkmode, Darkmode,
ReaderMode,
Head, Head,
PageTitle, PageTitle,
ContentMeta, ContentMeta,

View file

@ -115,8 +115,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article class="popover-hint">{content}</article> <article class={classes}>{content}</article>
<div class="page-listing"> <div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p> <p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div> <div>

View file

@ -75,7 +75,7 @@ function renderTranscludes(
const classNames = (node.properties?.className ?? []) as string[] const classNames = (node.properties?.className ?? []) as string[]
if (classNames.includes("transclude")) { if (classNames.includes("transclude")) {
const inner = node.children[0] as Element const inner = node.children[0] as Element
const transcludeTarget = inner.properties["data-slug"] as FullSlug const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget) const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) { if (!page) {
return return

View file

@ -1,25 +1,10 @@
function toggleCallout(this: HTMLElement) { function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement! const outerBlock = this.parentElement!
outerBlock.classList.toggle("is-collapsed") outerBlock.classList.toggle("is-collapsed")
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
if (!content) return
const collapsed = outerBlock.classList.contains("is-collapsed") const collapsed = outerBlock.classList.contains("is-collapsed")
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
outerBlock.style.maxHeight = height + "px"
// walk and adjust height of all parents
let current = outerBlock
let parent = outerBlock.parentElement
while (parent) {
if (!parent.classList.contains("callout")) {
return
}
const collapsed = parent.classList.contains("is-collapsed")
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
parent.style.maxHeight = height + "px"
current = parent
parent = parent.parentElement
}
} }
function setupCallout() { function setupCallout() {
@ -27,15 +12,15 @@ function setupCallout() {
`callout is-collapsible`, `callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) { for (const div of collapsible) {
const title = div.firstElementChild const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
if (!title) continue const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
if (!title || !content) continue
title.addEventListener("click", toggleCallout) title.addEventListener("click", toggleCallout)
window.addCleanup(() => title.removeEventListener("click", toggleCallout)) window.addCleanup(() => title.removeEventListener("click", toggleCallout))
const collapsed = div.classList.contains("is-collapsed") const collapsed = div.classList.contains("is-collapsed")
const height = collapsed ? title.scrollHeight : div.scrollHeight content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
div.style.maxHeight = height + "px"
} }
} }

View file

@ -23,11 +23,18 @@ let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
const nearestExplorer = this.closest(".explorer") as HTMLElement const nearestExplorer = this.closest(".explorer") as HTMLElement
if (!nearestExplorer) return if (!nearestExplorer) return
nearestExplorer.classList.toggle("collapsed") const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
nearestExplorer.setAttribute( nearestExplorer.setAttribute(
"aria-expanded", "aria-expanded",
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true", nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
) )
if (!explorerCollapsed) {
// Stop <html> from being scrollable when mobile explorer is open
document.documentElement.classList.add("mobile-no-scroll")
} else {
document.documentElement.classList.remove("mobile-no-scroll")
}
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
@ -270,12 +277,25 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
if (mobileExplorer.checkVisibility()) { if (mobileExplorer.checkVisibility()) {
explorer.classList.add("collapsed") explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false") explorer.setAttribute("aria-expanded", "false")
// Allow <html> to be scrollable when mobile explorer is collapsed
document.documentElement.classList.remove("mobile-no-scroll")
} }
mobileExplorer.classList.remove("hide-until-loaded") mobileExplorer.classList.remove("hide-until-loaded")
} }
}) })
window.addEventListener("resize", function () {
// Desktop explorer opens by default, and it stays open when the window is resized
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
const explorer = document.querySelector(".explorer")
if (explorer && !explorer.classList.contains("collapsed")) {
document.documentElement.classList.add("mobile-no-scroll")
return
}
})
function setFolderState(folderElement: HTMLElement, collapsed: boolean) { function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
} }

View file

@ -68,6 +68,30 @@ type TweenNode = {
stop: () => void stop: () => void
} }
// workaround for pixijs webgpu issue: https://github.com/pixijs/pixijs/issues/11389
async function determineGraphicsAPI(): Promise<"webgpu" | "webgl"> {
const adapter = await navigator.gpu?.requestAdapter().catch(() => null)
const device = adapter && (await adapter.requestDevice().catch(() => null))
if (!device) {
return "webgl"
}
const canvas = document.createElement("canvas")
const gl =
(canvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
(canvas.getContext("webgl") as WebGLRenderingContext | null)
// we have to return webgl so pixijs automatically falls back to canvas
if (!gl) {
return "webgl"
}
const webglMaxTextures = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)
const webgpuMaxTextures = device.limits.maxSampledTexturesPerShaderStage
return webglMaxTextures === webgpuMaxTextures ? "webgpu" : "webgl"
}
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
@ -349,6 +373,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
tweens.forEach((tween) => tween.stop()) tweens.forEach((tween) => tween.stop())
tweens.clear() tweens.clear()
const pixiPreference = await determineGraphicsAPI()
const app = new Application() const app = new Application()
await app.init({ await app.init({
width, width,
@ -357,7 +382,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
autoStart: false, autoStart: false,
autoDensity: true, autoDensity: true,
backgroundAlpha: 0, backgroundAlpha: 0,
preference: "webgpu", preference: pixiPreference,
resolution: window.devicePixelRatio, resolution: window.devicePixelRatio,
eventMode: "static", eventMode: "static",
}) })

View file

@ -1,18 +1,15 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path" import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical } from "./util"
import { randomIdNonSecure } from "../../util/random"
const p = new DOMParser() const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null
async function mouseEnterHandler( async function mouseEnterHandler(
this: HTMLAnchorElement, this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
) { ) {
clearActivePopover() const link = (activeAnchor = this)
const link = this
const id = randomIdNonSecure()
if (link.dataset.noPopover === "true") { if (link.dataset.noPopover === "true") {
return return
} }
@ -27,44 +24,49 @@ async function mouseEnterHandler(
}) })
} }
const prevPopoverElement = document.getElementById(`popover-${id}`) function showPopover(popoverElement: HTMLElement) {
const hasAlreadyBeenFetched = () => !!document.getElementById(`popover-${id}`) clearActivePopover()
popoverElement.classList.add("active-popover")
setPosition(popoverElement as HTMLElement)
// dont refetch if there's already a popover if (hash !== "") {
if (hasAlreadyBeenFetched()) { const targetAnchor = `#popover-internal-${hash.slice(1)}`
setPosition(prevPopoverElement as HTMLElement) const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
prevPopoverElement?.classList.add("active-popover") if (heading) {
return // leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
} }
const thisUrl = new URL(document.location.href)
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href) const targetUrl = new URL(link.href)
const hash = decodeURIComponent(targetUrl.hash) const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = "" targetUrl.hash = ""
targetUrl.search = "" targetUrl.search = ""
const popoverId = `popover-${link.pathname}`
const prevPopoverElement = document.getElementById(popoverId)
// dont refetch if there's already a popover
if (!!document.getElementById(popoverId)) {
showPopover(prevPopoverElement as HTMLElement)
return
}
const response = await fetchCanonical(targetUrl).catch((err) => { const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err) console.error(err)
}) })
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
if (!response) return if (!response) return
const [contentType] = response.headers.get("Content-Type")!.split(";") const [contentType] = response.headers.get("Content-Type")!.split(";")
const [contentTypeCategory, typeInfo] = contentType.split("/") const [contentTypeCategory, typeInfo] = contentType.split("/")
const popoverElement = document.createElement("div") const popoverElement = document.createElement("div")
popoverElement.id = popoverId
popoverElement.classList.add("popover") popoverElement.classList.add("popover")
const popoverInner = document.createElement("div") const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner") popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
popoverInner.dataset.contentType = contentType ?? undefined popoverInner.dataset.contentType = contentType ?? undefined
popoverElement.appendChild(popoverInner)
switch (contentTypeCategory) { switch (contentTypeCategory) {
case "image": case "image":
@ -100,35 +102,29 @@ async function mouseEnterHandler(
elts.forEach((elt) => popoverInner.appendChild(elt)) elts.forEach((elt) => popoverInner.appendChild(elt))
} }
setPosition(popoverElement) if (!!document.getElementById(popoverId)) {
popoverElement.id = `popover-${id}` return
popoverElement.classList.add("active-popover")
document.body.appendChild(popoverElement)
if (hash !== "") {
const targetAnchor = `#popover-internal-${hash.slice(1)}`
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
} }
document.body.appendChild(popoverElement)
if (activeAnchor !== this) {
return
}
showPopover(popoverElement)
} }
function clearActivePopover() { function clearActivePopover() {
activeAnchor = null
const allPopoverElements = document.querySelectorAll(".popover") const allPopoverElements = document.querySelectorAll(".popover")
if (allPopoverElements) { allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
allPopoverElements.forEach((popoverElement) =>
popoverElement.classList.remove("active-popover"),
)
}
} }
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[] const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
for (const link of links) { for (const link of links) {
link.addEventListener("mouseleave", clearActivePopover)
link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseleave", clearActivePopover)
window.addCleanup(() => { window.addCleanup(() => {
link.removeEventListener("mouseenter", mouseEnterHandler) link.removeEventListener("mouseenter", mouseEnterHandler)
link.removeEventListener("mouseleave", clearActivePopover) link.removeEventListener("mouseleave", clearActivePopover)

View file

@ -0,0 +1,25 @@
let isReaderMode = false
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
detail: { mode },
})
document.dispatchEvent(event)
}
document.addEventListener("nav", () => {
const switchReaderMode = () => {
isReaderMode = !isReaderMode
const newMode = isReaderMode ? "on" : "off"
document.documentElement.setAttribute("reader-mode", newMode)
emitReaderModeChangeEvent(newMode)
}
for (const readerModeButton of document.getElementsByClassName("readermode")) {
readerModeButton.addEventListener("click", switchReaderMode)
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
}
// Set initial state
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
})

View file

@ -300,9 +300,11 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
itemTile.classList.add("result-card") itemTile.classList.add("result-card")
itemTile.id = slug itemTile.id = slug
itemTile.href = resolveUrl(slug).toString() itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${ itemTile.innerHTML = `
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>` <h3 class="card-title">${title}</h3>
}` ${htmlTags}
<p class="card-description">${content}</p>
`
itemTile.addEventListener("click", (event) => { itemTile.addEventListener("click", (event) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch() hideSearch()

View file

@ -6,7 +6,7 @@
border: none; border: none;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin: 0 10px; margin: 0;
text-align: inherit; text-align: inherit;
flex-shrink: 0; flex-shrink: 0;

View file

@ -263,22 +263,8 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
} }
} }
.no-scroll { .mobile-no-scroll {
opacity: 0; @media all and ($mobile) {
overflow: hidden; overflow: hidden;
}
html:has(.no-scroll) {
overflow: hidden;
}
@media all and not ($mobile) {
.no-scroll {
opacity: 1 !important;
overflow: auto !important;
}
html:has(.no-scroll) {
overflow: auto !important;
} }
} }

View file

@ -0,0 +1,34 @@
.readermode {
cursor: pointer;
padding: 0;
position: relative;
background: none;
border: none;
width: 20px;
height: 20px;
margin: 0;
text-align: inherit;
flex-shrink: 0;
& svg {
position: absolute;
width: 20px;
height: 20px;
top: calc(50% - 10px);
fill: var(--darkgray);
stroke: var(--darkgray);
transition: opacity 0.1s ease;
}
}
:root[reader-mode="on"] {
& .sidebar.left,
& .sidebar.right {
opacity: 0;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}

View file

@ -133,11 +133,13 @@
} }
@media all and ($mobile) { @media all and ($mobile) {
& > #preview-container { flex-direction: column;
& > .preview-container {
display: none !important; display: none !important;
} }
&[data-preview] > #results-container { &[data-preview] > .results-container {
width: 100%; width: 100%;
height: auto; height: auto;
flex: 0 0 100%; flex: 0 0 100%;
@ -204,6 +206,12 @@
margin: 0; margin: 0;
} }
@media all and not ($mobile) {
& > p.card-description {
display: none;
}
}
& > ul.tags { & > ul.tags {
margin-top: 0.45rem; margin-top: 0.45rem;
margin-bottom: 0; margin-bottom: 0;

View file

@ -32,6 +32,9 @@ export default {
explorer: { explorer: {
title: "المستعرض", title: "المستعرض",
}, },
readerMode: {
title: "وضع القارئ",
},
footer: { footer: {
createdWith: "أُنشئ باستخدام", createdWith: "أُنشئ باستخدام",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Mode clar", lightMode: "Mode clar",
darkMode: "Mode fosc", darkMode: "Mode fosc",
}, },
readerMode: {
title: "Mode lector",
},
explorer: { explorer: {
title: "Explorador", title: "Explorador",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Světlý režim", lightMode: "Světlý režim",
darkMode: "Tmavý režim", darkMode: "Tmavý režim",
}, },
readerMode: {
title: "Režim čtečky",
},
explorer: { explorer: {
title: "Procházet", title: "Procházet",
}, },

View file

@ -26,8 +26,11 @@ export default {
noBacklinksFound: "Keine Backlinks gefunden", noBacklinksFound: "Keine Backlinks gefunden",
}, },
themeToggle: { themeToggle: {
lightMode: "Light Mode", lightMode: "Heller Modus",
darkMode: "Dark Mode", darkMode: "Dunkler Modus",
},
readerMode: {
title: "Lesemodus",
}, },
explorer: { explorer: {
title: "Explorer", title: "Explorer",

View file

@ -31,6 +31,9 @@ export interface Translation {
lightMode: string lightMode: string
darkMode: string darkMode: string
} }
readerMode: {
title: string
}
explorer: { explorer: {
title: string title: string
} }

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Light mode", lightMode: "Light mode",
darkMode: "Dark mode", darkMode: "Dark mode",
}, },
readerMode: {
title: "Reader mode",
},
explorer: { explorer: {
title: "Explorer", title: "Explorer",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Light mode", lightMode: "Light mode",
darkMode: "Dark mode", darkMode: "Dark mode",
}, },
readerMode: {
title: "Reader mode",
},
explorer: { explorer: {
title: "Explorer", title: "Explorer",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Modo claro", lightMode: "Modo claro",
darkMode: "Modo oscuro", darkMode: "Modo oscuro",
}, },
readerMode: {
title: "Modo lector",
},
explorer: { explorer: {
title: "Explorador", title: "Explorador",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "حالت روشن", lightMode: "حالت روشن",
darkMode: "حالت تاریک", darkMode: "حالت تاریک",
}, },
readerMode: {
title: "حالت خواندن",
},
explorer: { explorer: {
title: "مطالب", title: "مطالب",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Vaalea tila", lightMode: "Vaalea tila",
darkMode: "Tumma tila", darkMode: "Tumma tila",
}, },
readerMode: {
title: "Lukijatila",
},
explorer: { explorer: {
title: "Selain", title: "Selain",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Mode clair", lightMode: "Mode clair",
darkMode: "Mode sombre", darkMode: "Mode sombre",
}, },
readerMode: {
title: "Mode lecture",
},
explorer: { explorer: {
title: "Explorateur", title: "Explorateur",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Világos mód", lightMode: "Világos mód",
darkMode: "Sötét mód", darkMode: "Sötét mód",
}, },
readerMode: {
title: "Olvasó mód",
},
explorer: { explorer: {
title: "Fájlböngésző", title: "Fájlböngésző",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Tema chiaro", lightMode: "Tema chiaro",
darkMode: "Tema scuro", darkMode: "Tema scuro",
}, },
readerMode: {
title: "Modalità lettura",
},
explorer: { explorer: {
title: "Esplora", title: "Esplora",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "ライトモード", lightMode: "ライトモード",
darkMode: "ダークモード", darkMode: "ダークモード",
}, },
readerMode: {
title: "リーダーモード",
},
explorer: { explorer: {
title: "エクスプローラー", title: "エクスプローラー",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "라이트 모드", lightMode: "라이트 모드",
darkMode: "다크 모드", darkMode: "다크 모드",
}, },
readerMode: {
title: "리더 모드",
},
explorer: { explorer: {
title: "탐색기", title: "탐색기",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Šviesus Režimas", lightMode: "Šviesus Režimas",
darkMode: "Tamsus Režimas", darkMode: "Tamsus Režimas",
}, },
readerMode: {
title: "Modalità lettore",
},
explorer: { explorer: {
title: "Naršyklė", title: "Naršyklė",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Lys modus", lightMode: "Lys modus",
darkMode: "Mørk modus", darkMode: "Mørk modus",
}, },
readerMode: {
title: "Læsemodus",
},
explorer: { explorer: {
title: "Utforsker", title: "Utforsker",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Lichte modus", lightMode: "Lichte modus",
darkMode: "Donkere modus", darkMode: "Donkere modus",
}, },
readerMode: {
title: "Leesmodus",
},
explorer: { explorer: {
title: "Verkenner", title: "Verkenner",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Trzyb jasny", lightMode: "Trzyb jasny",
darkMode: "Tryb ciemny", darkMode: "Tryb ciemny",
}, },
readerMode: {
title: "Tryb czytania",
},
explorer: { explorer: {
title: "Przeglądaj", title: "Przeglądaj",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Tema claro", lightMode: "Tema claro",
darkMode: "Tema escuro", darkMode: "Tema escuro",
}, },
readerMode: {
title: "Modo leitor",
},
explorer: { explorer: {
title: "Explorador", title: "Explorador",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Modul luminos", lightMode: "Modul luminos",
darkMode: "Modul întunecat", darkMode: "Modul întunecat",
}, },
readerMode: {
title: "Modul de citire",
},
explorer: { explorer: {
title: "Explorator", title: "Explorator",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Светлый режим", lightMode: "Светлый режим",
darkMode: "Тёмный режим", darkMode: "Тёмный режим",
}, },
readerMode: {
title: "Режим чтения",
},
explorer: { explorer: {
title: "Проводник", title: "Проводник",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "โหมดสว่าง", lightMode: "โหมดสว่าง",
darkMode: "โหมดมืด", darkMode: "โหมดมืด",
}, },
readerMode: {
title: "โหมดอ่าน",
},
explorer: { explorer: {
title: "รายการหน้า", title: "รายการหน้า",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Açık mod", lightMode: "Açık mod",
darkMode: "Koyu mod", darkMode: "Koyu mod",
}, },
readerMode: {
title: "Okuma modu",
},
explorer: { explorer: {
title: "Gezgin", title: "Gezgin",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Світлий режим", lightMode: "Світлий режим",
darkMode: "Темний режим", darkMode: "Темний режим",
}, },
readerMode: {
title: "Режим читання",
},
explorer: { explorer: {
title: "Провідник", title: "Провідник",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "Sáng", lightMode: "Sáng",
darkMode: "Tối", darkMode: "Tối",
}, },
readerMode: {
title: "Chế độ đọc",
},
explorer: { explorer: {
title: "Trong bài này", title: "Trong bài này",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "亮色模式", lightMode: "亮色模式",
darkMode: "暗色模式", darkMode: "暗色模式",
}, },
readerMode: {
title: "阅读模式",
},
explorer: { explorer: {
title: "探索", title: "探索",
}, },

View file

@ -29,6 +29,9 @@ export default {
lightMode: "亮色模式", lightMode: "亮色模式",
darkMode: "暗色模式", darkMode: "暗色模式",
}, },
readerMode: {
title: "閱讀模式",
},
explorer: { explorer: {
title: "探索", title: "探索",
}, },

View file

@ -128,14 +128,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
const umamiScript = document.createElement("script"); const umamiScript = document.createElement("script");
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"; umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js";
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}"); umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
umamiScript.setAttribute("data-auto-track", "false"); umamiScript.setAttribute("data-auto-track", "true");
umamiScript.defer = true; umamiScript.defer = true;
umamiScript.onload = () => {
umami.track();
document.addEventListener("nav", () => {
umami.track();
});
};
document.head.appendChild(umamiScript); document.head.appendChild(umamiScript);
`) `)

View file

@ -0,0 +1,22 @@
import sharp from "sharp"
import { joinSegments, QUARTZ, FullSlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
export const Favicon: QuartzEmitterPlugin = () => ({
name: "Favicon",
async *emit({ argv }) {
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
const faviconContent = sharp(iconPath).resize(48, 48).toFormat("png")
yield write({
ctx: { argv } as BuildCtx,
slug: "favicon" as FullSlug,
ext: ".ico",
content: faviconContent,
})
},
async *partialEmit() {},
})

View file

@ -5,6 +5,7 @@ export { ContentIndex as ContentIndex } from "./contentIndex"
export { AliasRedirects } from "./aliases" export { AliasRedirects } from "./aliases"
export { Assets } from "./assets" export { Assets } from "./assets"
export { Static } from "./static" export { Static } from "./static"
export { Favicon } from "./favicon"
export { ComponentResources } from "./componentResources" export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404" export { NotFoundPage } from "./404"
export { CNAME } from "./cname" export { CNAME } from "./cname"

View file

@ -191,7 +191,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
const displayAnchor = anchor ? `#${anchor.trim().replace(/^#+/, "")}` : "" const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : ""
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : "" const embedDisplay = value.startsWith("!") ? "!" : ""
@ -221,7 +222,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
let [rawFp, rawHeader, rawAlias] = capture let [rawFp, rawHeader, rawAlias] = capture
const fp = rawFp?.trim() ?? "" const fp = rawFp?.trim() ?? ""
const anchor = rawHeader?.trim() ?? "" const anchor = rawHeader?.trim() ?? ""
const alias = rawAlias?.slice(1).trim() const alias: string | undefined = rawAlias?.slice(1).trim()
// embed cases // embed cases
if (value.startsWith("!")) { if (value.startsWith("!")) {
@ -463,6 +464,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
}) })
} }
// For the rest of the MD callout elements other than the title, wrap them with
// two nested HTML <div>s (use some hacked mdhast component to achieve this) of
// class `callout-content` and `callout-content-inner` respectively for
// grid-based collapsible animation.
if (calloutContent.length > 0) {
node.children = [
node.children[0],
{
data: { hProperties: { className: ["callout-content"] }, hName: "div" },
type: "blockquote",
children: [
{
data: {
hProperties: { className: ["callout-content-inner"] },
hName: "div",
},
type: "blockquote",
children: [...calloutContent],
},
],
},
]
}
// replace first line of blockquote with title and rest of the paragraph text // replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent) node.children.splice(0, 1, ...blockquoteContent)
@ -484,21 +509,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
"data-callout-metadata": calloutMetaData, "data-callout-metadata": calloutMetaData,
}, },
} }
// Add callout-content class to callout body if it has one.
if (calloutContent.length > 0) {
const contentData: BlockContent | DefinitionContent = {
data: {
hProperties: {
className: "callout-content",
},
hName: "div",
},
type: "blockquote",
children: [...calloutContent],
}
node.children = [node.children[0], contentData]
}
} }
}) })
} }

View file

@ -7,11 +7,19 @@
border-radius: 5px; border-radius: 5px;
padding: 0 1rem; padding: 0 1rem;
overflow-y: hidden; overflow-y: hidden;
transition: max-height 0.3s ease;
box-sizing: border-box; box-sizing: border-box;
& > .callout-content > :first-child { & > .callout-content {
margin-top: 0; display: grid;
transition: grid-template-rows 0.3s ease;
& > .callout-content-inner {
overflow: hidden;
& > :first-child {
margin-top: 0;
}
}
} }
--callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>'); --callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');

View file

@ -107,6 +107,13 @@ export interface GoogleFontFile {
extension: string extension: string
} }
const fontMimeMap: Record<string, string> = {
truetype: "ttf",
woff: "woff",
woff2: "woff2",
opentype: "otf",
}
export async function processGoogleFonts( export async function processGoogleFonts(
stylesheet: string, stylesheet: string,
baseUrl: string, baseUrl: string,
@ -114,14 +121,16 @@ export async function processGoogleFonts(
processedStylesheet: string processedStylesheet: string
fontFiles: GoogleFontFile[] fontFiles: GoogleFontFile[]
}> { }> {
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g const fontSourceRegex =
/url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g
const fontFiles: GoogleFontFile[] = [] const fontFiles: GoogleFontFile[] = []
let processedStylesheet = stylesheet let processedStylesheet = stylesheet
let match let match
while ((match = fontSourceRegex.exec(stylesheet)) !== null) { while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
const url = match[1] const url = match[1]
const [filename, extension] = url.split("/").pop()!.split(".") const filename = match[2]
const extension = fontMimeMap[match[3].toLowerCase()]
const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}` const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
processedStylesheet = processedStylesheet.replace(url, staticUrl) processedStylesheet = processedStylesheet.replace(url, staticUrl)