Merge branch 'jackyzha0:v4' into v4

This commit is contained in:
x4x 2025-02-17 12:38:11 +01:00 committed by GitHub
commit ca05e58313
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1481 additions and 1185 deletions

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.7.0 uses: sigstore/cosign-installer@v3.8.0
- 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

@ -274,7 +274,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!) const slug = canonicalizeServer(file.data.slug!)
const externalResources = pageResources(slug, resources) const externalResources = pageResources(slug, file.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,
externalResources, 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 ## Customization
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`. - 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` - Component: `quartz/components/Backlinks.tsx`
- Style: `quartz/components/styles/backlinks.scss` - Style: `quartz/components/styles/backlinks.scss`
- Script: `quartz/components/scripts/search.inline.ts` - Script: `quartz/components/scripts/search.inline.ts`

View File

@ -36,6 +36,7 @@ Component.Graph({
opacityScale: 1, // how quickly do we fade out the labels when zooming out? opacityScale: 1, // how quickly do we fade out the labels when zooming out?
removeTags: [], // what tags to remove from the graph removeTags: [], // what tags to remove from the graph
showTags: true, // whether to show tags in the graph showTags: true, // whether to show tags in the graph
enableRadial: false, // whether to constrain the graph, similar to Obsidian
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -49,6 +50,7 @@ Component.Graph({
opacityScale: 1, opacityScale: 1,
removeTags: [], // what tags to remove from the graph removeTags: [], // what tags to remove from the graph
showTags: true, // whether to show tags in the graph showTags: true, // whether to show tags in the graph
enableRadial: true, // whether to constrain the graph, similar to Obsidian
}, },
}) })
``` ```

View File

@ -31,4 +31,4 @@ This plugin accepts the following configuration options:
- Category: Transformer - Category: Transformer
- Function name: `Plugin.ObsidianFlavoredMarkdown()`. - 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

@ -30,5 +30,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja) - [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
- [Eledah's Crystalline](https://blog.eledah.ir/) - [Eledah's Crystalline](https://blog.eledah.ir/)
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com) - [🌓 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)! 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)!

1427
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,35 +35,34 @@
"quartz": "./quartz/bootstrap-cli.mjs" "quartz": "./quartz/bootstrap-cli.mjs"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.8.1", "@clack/prompts": "^0.10.0",
"@floating-ui/dom": "^1.6.12", "@floating-ui/dom": "^1.6.13",
"@myriaddreamin/rehype-typst": "^0.5.0-rc7", "@myriaddreamin/rehype-typst": "^0.5.4",
"@napi-rs/simple-git": "0.1.19", "@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0", "@tweenjs/tween.js": "^25.0.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"chalk": "^5.3.0", "chalk": "^5.4.1",
"chokidar": "^4.0.1", "chokidar": "^4.0.3",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"d3": "^7.9.0", "d3": "^7.9.0",
"esbuild-sass-plugin": "^3.3.1", "esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43", "flexsearch": "0.7.43",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^14.0.2", "globby": "^14.1.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.3", "hast-util-to-html": "^9.0.4",
"hast-util-to-jsx-runtime": "^2.3.2", "hast-util-to-jsx-runtime": "^2.3.2",
"hast-util-to-string": "^3.0.1", "hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lightningcss": "^1.28.1", "lightningcss": "^1.29.1",
"mdast-util-find-and-replace": "^3.0.1", "mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.0", "mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"mermaid": "^11.4.0",
"micromorph": "^0.4.5", "micromorph": "^0.4.5",
"pixi.js": "^8.5.2", "pixi.js": "^8.7.3",
"preact": "^10.24.3", "preact": "^10.25.4",
"preact-render-to-string": "^6.5.11", "preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
@ -77,17 +76,17 @@
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0", "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.1",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1", "rfdc": "^1.4.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"satori": "^0.11.3", "satori": "^0.12.1",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"shiki": "^1.23.1", "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",
"toml": "^3.0.0", "toml": "^3.0.0",
@ -103,14 +102,14 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.9.0", "@types/node": "^22.13.1",
"@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.5.13", "@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"esbuild": "^0.24.0", "esbuild": "^0.25.0",
"prettier": "^3.3.3", "prettier": "^3.5.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3" "typescript": "^5.7.3"
} }
} }

View File

@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Search(),
Component.Darkmode(), Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()), Component.Explorer(),
], ],
right: [ right: [
Component.Graph(), Component.Graph(),
@ -44,7 +44,7 @@ export const defaultListPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Search(),
Component.Darkmode(), Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()), Component.Explorer(),
], ],
right: [], right: [],
} }

View File

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

View File

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

View File

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

View File

@ -28,7 +28,8 @@ export default ((opts: Options) => {
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => { const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
// check if comments should be displayed according to frontmatter // check if comments should be displayed according to frontmatter
const disableComment: boolean = const disableComment: boolean =
!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false" typeof fileData.frontmatter?.comments !== "undefined" &&
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
if (disableComment) { if (disableComment) {
return <></> return <></>
} }

View File

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

View File

@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
} }
export function Date({ date, locale }: Props) { 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 { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss" import style from "./styles/explorer.scss"
// @ts-ignore // @ts-ignore
import script from "./scripts/explorer.inline" import script from "./scripts/explorer.inline"
@ -83,18 +83,46 @@ export default ((userOpts?: Partial<Options>) => {
lastBuildId = ctx.buildId lastBuildId = ctx.buildId
constructFileTree(allFiles) constructFileTree(allFiles)
} }
return ( return (
<div class={classNames(displayClass, "explorer")}> <div class={classNames(displayClass, "explorer")}>
<button <button
type="button" type="button"
id="explorer" id="mobile-explorer"
class="collapsed hide-until-loaded"
data-behavior={opts.folderClickBehavior} data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState} data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState} data-savestate={opts.useSavedState}
data-tree={jsonTree} data-tree={jsonTree}
data-mobile={true}
aria-controls="explorer-content" aria-controls="explorer-content"
aria-expanded={opts.folderDefaultState === "open"} aria-expanded={false}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<button
type="button"
id="desktop-explorer"
class="title-button"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
data-mobile={false}
aria-controls="explorer-content"
aria-expanded={true}
> >
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2> <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg <svg
@ -122,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
) )
} }
Explorer.css = explorerStyle Explorer.css = style
Explorer.afterDOMLoaded = script Explorer.afterDOMLoaded = script
return Explorer return Explorer
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -18,6 +18,7 @@ export interface D3Config {
removeTags: string[] removeTags: string[]
showTags: boolean showTags: boolean
focusOnHover?: boolean focusOnHover?: boolean
enableRadial?: boolean
} }
interface GraphOptions { interface GraphOptions {
@ -39,6 +40,7 @@ const defaultOptions: GraphOptions = {
showTags: true, showTags: true,
removeTags: [], removeTags: [],
focusOnHover: false, focusOnHover: false,
enableRadial: false,
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -53,10 +55,11 @@ const defaultOptions: GraphOptions = {
showTags: true, showTags: true,
removeTags: [], removeTags: [],
focusOnHover: true, focusOnHover: true,
enableRadial: true,
}, },
} }
export default ((opts?: GraphOptions) => { export default ((opts?: Partial<GraphOptions>) => {
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }

View File

@ -165,6 +165,7 @@ export default (() => {
<link rel="stylesheet" href={googleFontHref(cfg.theme)} /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
{/* OG/Twitter meta tags */} {/* OG/Twitter meta tags */}
<meta name="og:site_name" content={cfg.pageTitle}></meta> <meta name="og:site_name" content={cfg.pageTitle}></meta>
@ -181,8 +182,6 @@ export default (() => {
<> <>
<meta property="og:image:width" content={fullOptions.width.toString()} /> <meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} /> <meta property="og:image:height" content={fullOptions.height.toString()} />
<meta property="og:width" content={fullOptions.width.toString()} />
<meta property="og:height" content={fullOptions.height.toString()} />
</> </>
)} )}
<meta property="og:image:url" content={ogImagePath} /> <meta property="og:image:url" content={ogImagePath} />

View File

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

View File

@ -71,7 +71,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
}) })
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? [] const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ") const classes = cssClasses.join(" ")
const listProps = { const listProps = {
...props, ...props,
sort: options.sort, sort: options.sort,
@ -84,8 +84,8 @@ export default ((opts?: Partial<FolderContentOptions>) => {
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
return ( return (
<div class={classes}> <div class="popover-hint">
<article>{content}</article> <article class={classes}>{content}</article>
<div class="page-listing"> <div class="page-listing">
{options.showFolderCount && ( {options.showFolderCount && (
<p> <p>

View File

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

View File

@ -8,6 +8,10 @@ import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast" import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n" import { i18n } from "../i18n"
// @ts-ignore
import mermaidScript from "./scripts/mermaid.inline"
import mermaidStyle from "./styles/mermaid.inline.scss"
import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@ -23,12 +27,13 @@ interface RenderComponents {
const headerRegex = new RegExp(/h[1-6]/) const headerRegex = new RegExp(/h[1-6]/)
export function pageResources( export function pageResources(
baseDir: FullSlug | RelativeURL, baseDir: FullSlug | RelativeURL,
fileData: QuartzPluginData,
staticResources: StaticResources, staticResources: StaticResources,
): StaticResources { ): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return { const resources: StaticResources = {
css: [ css: [
{ {
content: joinSegments(baseDir, "index.css"), content: joinSegments(baseDir, "index.css"),
@ -48,14 +53,28 @@ export function pageResources(
script: contentIndexScript, script: contentIndexScript,
}, },
...staticResources.js, ...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"), src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
moduleType: "module", moduleType: "module",
contentType: "external", contentType: "external",
}, })
],
} return resources
} }
export function renderPage( export function renderPage(

View File

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

View File

@ -1,7 +1,9 @@
import { FolderState } from "../ExplorerNode" import { FolderState } from "../ExplorerNode"
// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[] let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible // If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul") const explorerUl = document.getElementById("explorer-ul")
@ -16,23 +18,43 @@ const observer = new IntersectionObserver((entries) => {
}) })
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
// Toggle collapsed aria state of entire explorer
this.setAttribute( this.setAttribute(
"aria-expanded", "aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true", this.getAttribute("aria-expanded") === "true" ? "false" : "true",
) )
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
const content = (
this.nextElementSibling?.nextElementSibling
? this.nextElementSibling.nextElementSibling
: this.nextElementSibling
) as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.classList.toggle("explorer-viewmode")
// Prevent scroll under
if (document.querySelector("#mobile-explorer")) {
// 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")
}
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
evt.stopPropagation() evt.stopPropagation()
// Element that was clicked
const target = evt.target as MaybeHTMLElement const target = evt.target as MaybeHTMLElement
if (!target) return if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg" const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const childFolderContainer = ( const childFolderContainer = (
isSvg isSvg
? target.parentElement?.nextSibling ? target.parentElement?.nextSibling
@ -42,10 +64,14 @@ function toggleFolder(evt: MouseEvent) {
isSvg ? target.nextElementSibling : target.parentElement isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement ) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
childFolderContainer.classList.toggle("open") childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open") const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed) setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
const fullFolderPath = currentFolderParent.dataset.folderpath as string const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath) toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState) const stringifiedFileTree = JSON.stringify(currentExplorerState)
@ -53,20 +79,34 @@ function toggleFolder(evt: MouseEvent) {
} }
function setupExplorer() { function setupExplorer() {
const explorer = document.getElementById("explorer") // Set click handler for collapsing entire explorer
if (!explorer) return const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
if (explorer.dataset.behavior === "collapse") { for (const explorer of allExplorers) {
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
// Convert to bool
const useSavedFolderState = explorer?.dataset.savestate === "true"
if (explorer) {
// Get config
const collapseBehavior = explorer.dataset.behavior
// Add click handlers for all folders (click handler on folder "label")
if (collapseBehavior === "collapse") {
for (const item of document.getElementsByClassName( for (const item of document.getElementsByClassName(
"folder-button", "folder-button",
) as HTMLCollectionOf<HTMLElement>) { ) as HTMLCollectionOf<HTMLElement>) {
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
item.addEventListener("click", toggleFolder) item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
} }
} }
explorer.addEventListener("click", toggleExplorer) // Add click handler to main explorer
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon") // Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName( for (const item of document.getElementsByClassName(
@ -77,8 +117,6 @@ function setupExplorer() {
} }
// Get folder state from local storage // Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] = const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : [] storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
@ -86,24 +124,61 @@ function setupExplorer() {
? JSON.parse(explorer.dataset.tree) ? JSON.parse(explorer.dataset.tree)
: [] : []
currentExplorerState = [] currentExplorerState = []
for (const { path, collapsed } of newExplorerState) { for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }) currentExplorerState.push({
path,
collapsed: oldIndex.get(path) ?? collapsed,
})
} }
currentExplorerState.map((folderState) => { currentExplorerState.map((folderState) => {
const folderLi = document.querySelector( const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`, `[data-folderpath='${folderState.path.replace("'", "-")}']`,
) as MaybeHTMLElement ) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) { if (folderUl) {
setFolderState(folderUl, folderState.collapsed) setFolderState(folderUl, folderState.collapsed)
} }
}) })
}
}
function toggleExplorerFolders() {
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
/\/index$/g,
"",
)
const allFolders = document.querySelectorAll(".folder-outer")
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")
}
}
}
})
} }
window.addEventListener("resize", setupExplorer) window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const explorer = document.querySelector("#mobile-explorer")
if (explorer) {
explorer.classList.add("collapsed")
const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
if (content) {
content.classList.add("collapsed")
content.classList.toggle("explorer-viewmode")
}
}
setupExplorer() setupExplorer()
observer.disconnect() observer.disconnect()
// select pseudo element at end of list // select pseudo element at end of list
@ -111,6 +186,12 @@ document.addEventListener("nav", () => {
if (lastItem) { if (lastItem) {
observer.observe(lastItem) observer.observe(lastItem)
} }
// Hide explorer on mobile until it is requested
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
toggleExplorerFolders()
}) })
/** /**

View File

@ -8,6 +8,7 @@ import {
forceCenter, forceCenter,
forceLink, forceLink,
forceCollide, forceCollide,
forceRadial,
zoomIdentity, zoomIdentity,
select, select,
drag, drag,
@ -87,6 +88,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags, removeTags,
showTags, showTags,
focusOnHover, focusOnHover,
enableRadial,
} = JSON.parse(graph.dataset["cfg"]!) as D3Config } = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map( const data: Map<SimpleSlug, ContentDetails> = new Map(
@ -161,15 +163,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
})), })),
} }
const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
// we virtualize the simulation and use pixi to actually render it // we virtualize the simulation and use pixi to actually render it
// Calculate the radius of the container circle
const radius = Math.min(width, height) / 2 - 40 // 40px padding
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes) const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.force("charge", forceManyBody().strength(-100 * repelForce)) .force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", forceCenter().strength(centerForce)) .force("center", forceCenter().strength(centerForce))
.force("link", forceLink(graphData.links).distance(linkDistance)) .force("link", forceLink(graphData.links).distance(linkDistance))
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3)) .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
const width = graph.offsetWidth if (enableRadial)
const height = Math.max(graph.offsetHeight, 250) simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
// precompute style prop strings as pixi doesn't support css variables // precompute style prop strings as pixi doesn't support css variables
const cssVars = [ const cssVars = [

View File

@ -1,5 +1,4 @@
import { removeAllChildren } from "./util" import { removeAllChildren } from "./util"
import mermaid from "mermaid"
interface Position { interface Position {
x: number x: number
@ -144,6 +143,7 @@ const cssVars = [
"--codeFont", "--codeFont",
] as const ] as const
let mermaidImport = undefined
document.addEventListener("nav", async () => { document.addEventListener("nav", async () => {
const center = document.querySelector(".center") as HTMLElement const center = document.querySelector(".center") as HTMLElement
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement> const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
@ -157,6 +157,12 @@ document.addEventListener("nav", async () => {
{} as Record<(typeof cssVars)[number], string>, {} 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" const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,

View File

@ -1,5 +1,6 @@
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"
const p = new DOMParser() const p = new DOMParser()
async function mouseEnterHandler( async function mouseEnterHandler(
@ -37,7 +38,7 @@ async function mouseEnterHandler(
targetUrl.hash = "" targetUrl.hash = ""
targetUrl.search = "" targetUrl.search = ""
const response = await fetch(`${targetUrl}`).catch((err) => { const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err) console.error(err)
}) })

View File

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

View File

@ -24,3 +24,22 @@ export function removeAllChildren(node: HTMLElement) {
node.removeChild(node.firstChild) 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

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

View File

@ -1,14 +1,70 @@
@use "../../styles/variables.scss" as *; @use "../../styles/variables.scss" as *;
@media all and ($mobile) {
.page > #quartz-body {
// Shift page position when toggling Explorer on mobile.
& > :not(.sidebar.left:has(.explorer)) {
transform: translateX(0);
transition: transform 300ms ease-in-out;
}
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
transform: translateX(100dvw);
transition: transform 300ms ease-in-out;
}
// Sticky top bar (stays in place when scrolling down on mobile).
.sidebar.left:has(.explorer) {
box-sizing: border-box;
position: sticky;
background-color: var(--light);
}
// Hide Explorer on mobile until done loading.
// Prevents ugly animation on page load.
.hide-until-loaded ~ #explorer-content {
display: none;
}
}
}
.explorer { .explorer {
display: flex; display: flex;
height: 100%;
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: hidden;
@media all and ($mobile) {
order: -1;
height: initial;
overflow: hidden;
flex-shrink: 0;
align-self: flex-start;
}
button#mobile-explorer {
display: none;
}
button#desktop-explorer {
display: flex;
}
@media all and ($mobile) {
button#mobile-explorer {
display: flex;
}
button#desktop-explorer {
display: none;
}
}
&.desktop-only { &.desktop-only {
@media all and not ($mobile) { @media all and not ($mobile) {
display: flex; display: flex;
} }
} }
/*&:after { /*&:after {
pointer-events: none; pointer-events: none;
content: ""; content: "";
@ -23,7 +79,8 @@
}*/ }*/
} }
button#explorer { button#mobile-explorer,
button#desktop-explorer {
background-color: transparent; background-color: transparent;
border: none; border: none;
text-align: left; text-align: left;
@ -68,19 +125,19 @@ button#explorer {
list-style: none; list-style: none;
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
max-height: 0px;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
margin-top: 0.5rem;
visibility: hidden;
&.collapsed {
max-height: 100%; max-height: 100%;
transition: transition:
max-height 0.35s ease, max-height 0.35s ease,
visibility 0s linear 0s; visibility 0s linear 0s;
margin-top: 0.5rem;
visibility: visible; visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
} }
& ul { & ul {
@ -91,12 +148,14 @@ button#explorer {
max-height 0.35s ease, max-height 0.35s ease,
transform 0.35s ease, transform 0.35s ease,
opacity 0.2s ease; opacity 0.2s ease;
& li > a { & li > a {
color: var(--dark); color: var(--dark);
opacity: 0.75; opacity: 0.75;
pointer-events: all; pointer-events: all;
} }
} }
> #explorer-ul { > #explorer-ul {
max-height: none; max-height: none;
} }
@ -179,3 +238,80 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
// remove default margin from li // remove default margin from li
margin: 0; margin: 0;
} }
.explorer {
@media all and ($mobile) {
#explorer-content {
box-sizing: border-box;
overscroll-behavior: none;
z-index: 100;
position: absolute;
top: 0;
background-color: var(--light);
max-width: 100dvw;
left: -100dvw;
width: 100%;
transition: transform 300ms ease-in-out;
overflow: hidden;
padding: $topSpacing 2rem 2rem;
height: 100dvh;
max-height: 100dvh;
margin-top: 0;
visibility: hidden;
&:not(.collapsed) {
transform: translateX(100dvw);
visibility: visible;
}
ul.overflow {
max-height: 100%;
width: 100%;
}
&.collapsed {
transform: translateX(0);
visibility: visible;
}
}
#mobile-explorer {
margin: 5px;
z-index: 101;
&:not(.collapsed) .lucide-menu {
transform: rotate(-90deg);
transition: transform 200ms ease-in-out;
}
.lucide-menu {
stroke: var(--darkgray);
transition: transform 200ms ease;
&:hover {
stroke: var(--dark);
}
}
}
}
}
.no-scroll {
opacity: 0;
overflow: hidden;
}
html:has(.no-scroll) {
overflow: hidden;
}
@media all and not ($mobile) {
.no-scroll {
opacity: 1 !important;
overflow: auto !important;
}
html:has(.no-scroll) {
overflow: auto !important;
}
}

View File

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

View File

@ -14,6 +14,7 @@ import uk from "./locales/uk-UA"
import ru from "./locales/ru-RU" import ru from "./locales/ru-RU"
import ko from "./locales/ko-KR" import ko from "./locales/ko-KR"
import zh from "./locales/zh-CN" import zh from "./locales/zh-CN"
import zhTw from "./locales/zh-TW"
import vi from "./locales/vi-VN" import vi from "./locales/vi-VN"
import pt from "./locales/pt-BR" import pt from "./locales/pt-BR"
import hu from "./locales/hu-HU" import hu from "./locales/hu-HU"
@ -21,6 +22,8 @@ import fa from "./locales/fa-IR"
import pl from "./locales/pl-PL" import pl from "./locales/pl-PL"
import cs from "./locales/cs-CZ" import cs from "./locales/cs-CZ"
import tr from "./locales/tr-TR" import tr from "./locales/tr-TR"
import th from "./locales/th-TH"
import lt from "./locales/lt-LT"
export const TRANSLATIONS = { export const TRANSLATIONS = {
"en-US": enUs, "en-US": enUs,
@ -59,6 +62,7 @@ export const TRANSLATIONS = {
"ru-RU": ru, "ru-RU": ru,
"ko-KR": ko, "ko-KR": ko,
"zh-CN": zh, "zh-CN": zh,
"zh-TW": zhTw,
"vi-VN": vi, "vi-VN": vi,
"pt-BR": pt, "pt-BR": pt,
"hu-HU": hu, "hu-HU": hu,
@ -66,6 +70,8 @@ export const TRANSLATIONS = {
"pl-PL": pl, "pl-PL": pl,
"cs-CZ": cs, "cs-CZ": cs,
"tr-TR": tr, "tr-TR": tr,
"th-TH": th,
"lt-LT": lt,
} as const } as const
export const defaultTranslation = "en-US" 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,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 url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug const path = url.pathname as FullSlug
const externalResources = pageResources(path, resources)
const notFound = i18n(cfg.locale).pages.error.title const notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({ const [tree, vfile] = defaultProcessedContent({
slug, slug,
@ -45,6 +44,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
description: notFound, description: notFound,
frontmatter: { title: notFound, tags: [] }, frontmatter: { title: notFound, tags: [] },
}) })
const externalResources = pageResources(path, vfile.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData: vfile.data, fileData: vfile.data,

View File

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

View File

@ -106,8 +106,8 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
for (const folder of folders) { for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug const slug = joinSegments(folder, "index") as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = folderDescriptions[folder] const [tree, file] = folderDescriptions[folder]
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData: file.data, fileData: file.data,

View File

@ -105,14 +105,17 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
const tag = slug.slice("tags/".length) const tag = slug.slice("tags/".length)
if (tags.has(tag)) { if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file] 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) { for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug const slug = joinSegments("tags", tag) as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = tagDescriptions[tag] const [tree, file] = tagDescriptions[tag]
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData: file.data, fileData: file.data,

View File

@ -73,6 +73,18 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"]) 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 if (socialImage) data.socialImage = socialImage
// fill in frontmatter // fill in frontmatter
@ -91,6 +103,9 @@ declare module "vfile" {
} & Partial<{ } & Partial<{
tags: string[] tags: string[]
aliases: string[] aliases: string[]
modified: string
created: string
published: string
description: string description: string
publish: boolean | string publish: boolean | string
draft: boolean | string draft: boolean | string

View File

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

View File

@ -1,5 +1,13 @@
import { QuartzTransformerPlugin } from "../types" 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 { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
@ -11,13 +19,9 @@ import { JSResource, CSSResource } from "../../util/resources"
import calloutScript from "../../components/scripts/callout.inline.ts" import calloutScript from "../../components/scripts/callout.inline.ts"
// @ts-ignore // @ts-ignore
import checkboxScript from "../../components/scripts/checkbox.inline.ts" import checkboxScript from "../../components/scripts/checkbox.inline.ts"
// @ts-ignore
import mermaidExtensionScript from "../../components/scripts/mermaid.inline.ts"
import mermaidStyle from "../../components/styles/mermaid.inline.scss"
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast" import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
import { capitalize } from "../../util/lang" import { capitalize } from "../../util/lang"
import { PluggableList } from "unified" import { PluggableList } from "unified"
@ -124,12 +128,12 @@ const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts // 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) 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 # // #(...) -> 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, 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 "/" // (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp( 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 blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
@ -156,7 +160,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
src = src.toString() src = src.toString()
} }
src = src.replace(commentRegex, "") src = (src as string).replace(commentRegex, "")
} }
// pre-transform blockquotes // pre-transform blockquotes
@ -165,7 +169,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
src = src.toString() src = src.toString()
} }
src = src.replace(calloutLineRegex, (value) => { src = (src as string).replace(calloutLineRegex, (value) => {
// force newline after title of callout // force newline after title of callout
return value + "\n> " return value + "\n> "
}) })
@ -178,7 +182,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
} }
// replace all wikilinks inside a table first // 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 // escape all aliases and headers in wikilinks inside a table
return value.replace(tableWikilinkRegex, (_value, raw) => { return value.replace(tableWikilinkRegex, (_value, raw) => {
// const [raw]: (string | undefined)[] = capture // const [raw]: (string | undefined)[] = capture
@ -192,7 +196,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
}) })
// replace all other wikilinks // 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 [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
@ -513,9 +517,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.mermaid) { if (opts.mermaid) {
plugins.push(() => { plugins.push(() => {
return (tree: Root, _file) => { return (tree: Root, file) => {
visit(tree, "code", (node: Code) => { visit(tree, "code", (node: Code) => {
if (node.lang === "mermaid") { if (node.lang === "mermaid") {
file.data.hasMermaidDiagram = true
node.data = { node.data = {
hProperties: { hProperties: {
className: ["mermaid"], className: ["mermaid"],
@ -813,19 +818,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
}) })
} }
if (opts.mermaid) {
js.push({
script: mermaidExtensionScript,
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
css.push({
content: mermaidStyle,
inline: true,
})
}
return { js, css } return { js, css }
}, },
} }
@ -835,5 +827,6 @@ declare module "vfile" {
interface DataMap { interface DataMap {
blocks: Record<string, Element> blocks: Record<string, Element>
htmlAst: HtmlRoot 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" import { Data, VFile } from "vfile"
export type QuartzPluginData = Data 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 { export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
const root: Parent = { type: "root", children: [] } const root: HtmlRoot = { type: "root", children: [] }
const vfile = new VFile("") const vfile = new VFile("")
vfile.data = vfileData vfile.data = vfileData
return [root, vfile] return [root, vfile]

View File

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

View File

@ -1,3 +1,5 @@
@use "sass:map";
@use "./variables.scss" as *; @use "./variables.scss" as *;
@use "./syntax.scss"; @use "./syntax.scss";
@use "./callouts.scss"; @use "./callouts.scss";
@ -85,7 +87,7 @@ a {
line-height: 1.4rem; line-height: 1.4rem;
&:has(> img) { &:has(> img) {
background-color: none; background-color: transparent;
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
} }
@ -121,7 +123,7 @@ a {
} }
.page { .page {
max-width: calc(#{map-get($breakpoints, desktop)} + 300px); max-width: calc(#{map.get($breakpoints, desktop)} + 300px);
margin: 0 auto; margin: 0 auto;
& article { & article {
& > h1 { & > h1 {
@ -151,24 +153,25 @@ a {
& > #quartz-body { & > #quartz-body {
display: grid; display: grid;
grid-template-columns: #{map-get($desktopGrid, templateColumns)}; grid-template-columns: #{map.get($desktopGrid, templateColumns)};
grid-template-rows: #{map-get($desktopGrid, templateRows)}; grid-template-rows: #{map.get($desktopGrid, templateRows)};
column-gap: #{map-get($desktopGrid, columnGap)}; column-gap: #{map.get($desktopGrid, columnGap)};
row-gap: #{map-get($desktopGrid, rowGap)}; row-gap: #{map.get($desktopGrid, rowGap)};
grid-template-areas: #{map-get($desktopGrid, templateAreas)}; grid-template-areas: #{map.get($desktopGrid, templateAreas)};
@media all and ($tablet) { @media all and ($tablet) {
grid-template-columns: #{map-get($tabletGrid, templateColumns)}; grid-template-columns: #{map.get($tabletGrid, templateColumns)};
grid-template-rows: #{map-get($tabletGrid, templateRows)}; grid-template-rows: #{map.get($tabletGrid, templateRows)};
column-gap: #{map-get($tabletGrid, columnGap)}; column-gap: #{map.get($tabletGrid, columnGap)};
row-gap: #{map-get($tabletGrid, rowGap)}; row-gap: #{map.get($tabletGrid, rowGap)};
grid-template-areas: #{map-get($tabletGrid, templateAreas)}; grid-template-areas: #{map.get($tabletGrid, templateAreas)};
} }
@media all and ($mobile) { @media all and ($mobile) {
grid-template-columns: #{map-get($mobileGrid, templateColumns)}; grid-template-columns: #{map.get($mobileGrid, templateColumns)};
grid-template-rows: #{map-get($mobileGrid, templateRows)}; grid-template-rows: #{map.get($mobileGrid, templateRows)};
column-gap: #{map-get($mobileGrid, columnGap)}; column-gap: #{map.get($mobileGrid, columnGap)};
row-gap: #{map-get($mobileGrid, rowGap)}; row-gap: #{map.get($mobileGrid, rowGap)};
grid-template-areas: #{map-get($mobileGrid, templateAreas)}; grid-template-areas: #{map.get($mobileGrid, templateAreas)};
} }
@media all and not ($desktop) { @media all and not ($desktop) {
@ -388,7 +391,7 @@ figure[data-rehype-pretty-code-figure] {
font-size: 0.9rem; font-size: 0.9rem;
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
border: 1px solid var(--lightgray); border: 1px solid var(--lightgray);
width: max-content; width: fit-content;
border-radius: 5px; border-radius: 5px;
margin-bottom: -0.5rem; margin-bottom: -0.5rem;
color: var(--darkgray); color: var(--darkgray);
@ -512,6 +515,7 @@ img {
max-width: 100%; max-width: 100%;
border-radius: 5px; border-radius: 5px;
margin: 1rem 0; margin: 1rem 0;
content-visibility: auto;
} }
p > img + em { p > img + em {
@ -587,3 +591,14 @@ iframe.pdf {
width: 100%; width: 100%;
border-radius: 5px; 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 * Layout breakpoints
* $mobile: screen width below this value will use mobile styles * $mobile: screen width below this value will use mobile styles
@ -10,11 +12,11 @@ $breakpoints: (
desktop: 1200px, desktop: 1200px,
); );
$mobile: "(max-width: #{map-get($breakpoints, mobile)})"; $mobile: "(max-width: #{map.get($breakpoints, mobile)})";
$tablet: "(min-width: #{map-get($breakpoints, mobile)}) and (max-width: #{map-get($breakpoints, desktop)})"; $tablet: "(min-width: #{map.get($breakpoints, mobile)}) and (max-width: #{map.get($breakpoints, desktop)})";
$desktop: "(min-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; $sidePanelWidth: 320px; //380px;
$topSpacing: 6rem; $topSpacing: 6rem;
$boldWeight: 700; $boldWeight: 700;

View File

@ -35,7 +35,9 @@ export async function getSatoriFont(headerFontName: string, bodyFontName: string
async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> { async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
try { try {
// Get css file from google fonts // Get css file from google fonts
const cssResponse = await fetch(`https://fonts.googleapis.com/css?family=${fontName}:${weight}`) const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
)
const css = await cssResponse.text() const css = await cssResponse.text()
// Extract .ttf url from css file // Extract .ttf url from css file

View File

@ -158,6 +158,29 @@ describe("transforms", () => {
path.isRelativeURL, 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", () => { 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) el.setAttribute(attr, rebased.pathname + rebased.hash)
} }
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) { 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), _rebaseHtmlElement(item, "href", destination),
) )
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => el.querySelectorAll('[src=""], [src^="./"], [src^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "src", destination), _rebaseHtmlElement(item, "src", destination),
) )
} }
@ -183,10 +183,26 @@ export function slugTag(tag: string) {
} }
export function joinSegments(...args: string[]): string { export function joinSegments(...args: string[]): string {
return args if (args.length === 0) {
.filter((segment) => segment !== "") return ""
}
let joined = args
.filter((segment) => segment !== "" && segment !== "/")
.map((segment) => stripSlashes(segment))
.join("/") .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[] { export function getAllSegmentPrefixes(tags: string): string[] {

View File

@ -3,23 +3,46 @@ sourceMapSupport.install(options)
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { Argv, BuildCtx } from "./util/ctx" import { Argv, BuildCtx } from "./util/ctx"
import { FilePath, FullSlug } from "./util/path" 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 { options } from "./util/sourcemap"
import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
// only called from worker thread // only called from worker thread
export async function parseFiles( export async function parseMarkdown(
buildId: string, buildId: string,
argv: Argv, argv: Argv,
fps: FilePath[], 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 = { const ctx: BuildCtx = {
buildId, buildId,
cfg, cfg,
argv, argv,
allSlugs, allSlugs,
} }
const processor = createProcessor(ctx) return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
const parse = createFileParser(ctx, fps) }
return parse(processor)
// 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))
} }