Compare commits

...

4 Commits

Author SHA1 Message Date
Anton Bulakh
1fedbfe4df
Merge 2e299c67ccbd48552273b9bae1d4e9afecbd0715 into 91189dfd2f4cb32e205117b327e0ae7a0c2dd716 2025-02-03 09:26:03 -05:00
Emile Bangma
91189dfd2f
feat(explorer): collapsible mobile explorer (#1471)
Some checks failed
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Build and Test / publish-tag (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2025-02-03 09:25:42 -05:00
Aaron Pham
fbc45548f7
feat(graph): enable radial mode (#1738)
Some checks failed
Build and Test / publish-tag (push) Has been cancelled
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
2025-02-01 16:22:29 -05:00
Anton Bulakh
2e299c67cc
feat: untangle quartz from local configs in least amount of changes
For the current setup where people have to fork or at least clone quartz
this changes nothing - but it allows you to install quartz as a
devDependency via npm and have it actually work.

One real change is switch from `.quartz-cache` to
`node_modules/.cache/quartz` for transpilation results, this is an
artifact from my previous attempts, I guess with this one I can change
it back - but `node_modules/.cache` feels more better imo idk.

edit: OTOH if you want to have quartz be a _completely_ separate binary
(which this also enables I think), having it create a node_modules
folder is weird, so I made a quick hack for that for now.

Example:
```bash
$ mkdir my-repo && cd my-repo
$ npm i quartz@necauqua/quartz#untangled # quartz@ prefix is important
$ cp node_modules/quartz/quartz.*.ts .   # copy the default configs
$ mkdir content && echo "# Hello World!" > content/index.md
$ npx quartz build --serve # this just works!
$ echo 'body { background: red !important; }' > styles.scss
```
Notice how I used my branch in the `npm i` line, ideally it'd be
`npm i quartz@jackyzho0/quartz`, or maybe we can somehow get the quartz
package on npm and it'll just be `npm i quartz`.
In the latter case `npx quartz build` will literally just work without
a local npm package at all?.

Having some support for components and plugins being in separate npm
packages instead of people copying code around is not out of the picture
with this too btw.

Closes #502

MOVE ME
2025-01-23 20:53:58 +02:00
29 changed files with 453 additions and 112 deletions

40
ambient.d.ts vendored Normal file
View File

@ -0,0 +1,40 @@
declare module "*.scss" {
const content: string
export = content
}
declare module "$config" {
import { QuartzConfig } from "./quartz"
const config: QuartzConfig
export = config
}
declare module "$layout" {
import { SharedLayout, PageLayout } from "./quartz/cfg"
export const sharedPageComponents: SharedLayout
export const defaultContentPageLayout: PageLayout
export const defaultListPageLayout: PageLayout
}
declare module "$styles" {
const content: string
export = content
}
declare module "quartz" {
// without this the export below does nothing for some reason
// sometimes TS is funn
import("./quartz")
export * from "./quartz"
}
// dom custom event
interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}
declare const fetchData: Promise<ContentIndex>

View File

@ -17,7 +17,7 @@ This question is best answered by tracing what happens when a user (you!) runs `
1. A WebSocket server on port 3001 to handle hot-reload signals. This tracks all inbound connections and sends a 'rebuild' message a server-side change is detected (either content or configuration).
2. An HTTP file-server on a user defined port (normally 8080) to serve the actual website files.
4. If the `--serve` flag is set, it also starts a file watcher to detect source-code changes (e.g. anything that is `.ts`, `.tsx`, `.scss`, or packager files). On a change, we rebuild the module (step 2 above) using esbuild's [rebuild API](https://esbuild.github.io/api/#rebuild) which drastically reduces the build times.
5. After transpiling the main Quartz build module (`quartz/build.ts`), we write it to a cache file `.quartz-cache/transpiled-build.mjs` and then dynamically import this using `await import(cacheFile)`. However, we need to be pretty smart about how to bust Node's [import cache](https://github.com/nodejs/modules/issues/307) so we add a random query string to fake Node into thinking it's a new module. This does, however, cause memory leaks so we just hope that the user doesn't hot-reload their configuration too many times in a single session :)) (it leaks about ~350kB memory on each reload). After importing the module, we then invoke it, passing in the command line arguments we parsed earlier along with a callback function to signal the client to refresh.
5. After transpiling the main Quartz build module (`quartz/build.ts`), we write it to a cache file `node_modules/.cache/quartz/transpiled-build.mjs` and then dynamically import this using `await import(cacheFile)`. However, we need to be pretty smart about how to bust Node's [import cache](https://github.com/nodejs/modules/issues/307) so we add a random query string to fake Node into thinking it's a new module. This does, however, cause memory leaks so we just hope that the user doesn't hot-reload their configuration too many times in a single session :)) (it leaks about ~350kB memory on each reload). After importing the module, we then invoke it, passing in the command line arguments we parsed earlier along with a callback function to signal the client to refresh.
4. In `build.ts`, we start by installing source map support manually to account for the query string cache busting hack we introduced earlier. Then, we start processing content:
1. Clean the output directory.
2. Recursively glob all files in the `content` folder, respecting the `.gitignore`.

View File

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

12
index.d.ts vendored
View File

@ -1,12 +0,0 @@
declare module "*.scss" {
const content: string
export = content
}
// dom custom event
interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}
declare const fetchData: Promise<ContentIndex>

1
index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./quartz"

View File

@ -34,6 +34,7 @@
"bin": {
"quartz": "./quartz/bootstrap-cli.mjs"
},
"types": "./ambient.d.ts",
"dependencies": {
"@clack/prompts": "^0.9.1",
"@floating-ui/dom": "^1.6.13",

View File

@ -1,5 +1,4 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
import { QuartzConfig, Plugin } from "quartz"
/**
* Quartz 4.0 Configuration

View File

@ -1,5 +1,4 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
import { Component, PageLayout, SharedLayout } from "quartz"
// components shared across all pages
export const sharedPageComponents: SharedLayout = {
@ -27,7 +26,7 @@ export const defaultContentPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
Component.Explorer(),
],
right: [
Component.Graph(),
@ -44,7 +43,7 @@ export const defaultListPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
Component.Explorer(),
],
right: [],
}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const cacheFile = process.argv[2]
const { parseMarkdown, processHtml } = await import(cacheFile)
workerpool.worker({
parseMarkdown,

View File

@ -8,7 +8,7 @@ import chalk from "chalk"
import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
import cfg from "../quartz.config"
import cfg from "$config"
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile"
@ -42,12 +42,13 @@ function newBuildId() {
return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
async function buildQuartz(quartzRoot: string, argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
buildId: newBuildId(),
argv,
cfg,
allSlugs: [],
quartzRoot,
}
const perf = new PerfTimer()
@ -413,9 +414,9 @@ async function rebuildFromEntrypoint(
release()
}
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
export default async (quartzRoot: string, argv: Argv, mut: Mutex, clientRefresh: () => void) => {
try {
return await buildQuartz(argv, mut, clientRefresh)
return await buildQuartz(quartzRoot, argv, mut, clientRefresh)
} catch (err) {
trace("\nExiting Quartz due to a fatal error", err as Error)
}

View File

@ -1,5 +1,5 @@
import path from "path"
import { readFileSync } from "fs"
import { accessSync, readFileSync } from "fs"
/**
* All constants relating to helpers or handlers
@ -7,9 +7,26 @@ import { readFileSync } from "fs"
export const ORIGIN_NAME = "origin"
export const UPSTREAM_NAME = "upstream"
export const QUARTZ_SOURCE_BRANCH = "v4"
export const cwd = process.cwd()
export const cacheDir = path.join(cwd, ".quartz-cache")
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
export const fp = "./quartz/build.ts"
export const { version } = JSON.parse(readFileSync("./package.json").toString())
function selectCacheDir() {
try {
const node_modules = path.join(cwd, "node_modules")
accessSync(node_modules) // check if node_modules exists
return path.join(node_modules, ".cache", "quartz")
} catch {
// standalone quartz bin?
return path.join(cwd, ".quartz-cache")
}
}
export const cacheDir = selectCacheDir()
export const cacheFile = path.join(cacheDir, "transpiled-build.mjs")
export const contentCacheFolder = path.join(cacheDir, "content-cache")
export const quartzRoot = path.resolve(import.meta.dirname, "..")
export const fp = path.join(quartzRoot, "build.ts")
export const { version } = JSON.parse(
readFileSync(path.resolve(quartzRoot, "..", "package.json")).toString(),
)

View File

@ -31,7 +31,9 @@ import {
fp,
cacheFile,
cwd,
quartzRoot,
} from "./constants.js"
import { pathToFileURL } from "url"
/**
* Handles `npx quartz create`
@ -232,6 +234,12 @@ export async function handleBuild(argv) {
metafile: true,
sourcemap: true,
sourcesContent: false,
alias: {
$config: path.join(cwd, "quartz.config.ts"),
$layout: path.join(cwd, "quartz.layout.ts"),
$styles: path.join(cwd, "styles.scss"),
quartz: path.resolve(quartzRoot, ".."),
},
plugins: [
sassPlugin({
type: "css-text",
@ -303,8 +311,9 @@ export async function handleBuild(argv) {
release()
if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName]
// metafile.outputs always uses /
const output = path.relative(cwd, cacheFile).replaceAll("\\", "/")
const meta = result.metafile.outputs[output]
console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
@ -313,12 +322,14 @@ export async function handleBuild(argv) {
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// absolute path on windows has to be a file:// url
const url = pathToFileURL(cacheFile)
// bypass module cache
// https://github.com/nodejs/modules/issues/307
const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`)
// ^ this import is relative, so base "cacheFile" path can't be used
url.searchParams.set("update", randomUUID())
const { default: buildQuartz } = await import(url)
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
cleanupBuild = await buildQuartz(quartzRoot, argv, buildMutex, clientRefresh)
clientRefresh()
}

View File

@ -1,5 +1,5 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss"
import style from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
@ -83,18 +83,46 @@ export default ((userOpts?: Partial<Options>) => {
lastBuildId = ctx.buildId
constructFileTree(allFiles)
}
return (
<div class={classNames(displayClass, "explorer")}>
<button
type="button"
id="explorer"
id="mobile-explorer"
class="collapsed hide-until-loaded"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
data-mobile={true}
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>
<svg
@ -122,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
)
}
Explorer.css = explorerStyle
Explorer.css = style
Explorer.afterDOMLoaded = script
return Explorer
}) satisfies QuartzComponentConstructor

View File

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

View File

@ -1,7 +1,9 @@
import { FolderState } from "../ExplorerNode"
// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul")
@ -16,23 +18,43 @@ const observer = new IntersectionObserver((entries) => {
})
function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
// Toggle collapsed aria state of entire explorer
this.setAttribute(
"aria-expanded",
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("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) {
evt.stopPropagation()
// Element that was clicked
const target = evt.target as MaybeHTMLElement
if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const childFolderContainer = (
isSvg
? target.parentElement?.nextSibling
@ -42,10 +64,14 @@ function toggleFolder(evt: MouseEvent) {
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState)
@ -53,20 +79,34 @@ function toggleFolder(evt: MouseEvent) {
}
function setupExplorer() {
const explorer = document.getElementById("explorer")
if (!explorer) return
// Set click handler for collapsing entire explorer
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(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
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))
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
@ -77,8 +117,6 @@ function setupExplorer() {
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
@ -86,13 +124,17 @@ function setupExplorer() {
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
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) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`,
`[data-folderpath='${folderState.path.replace("'", "-")}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
@ -100,10 +142,43 @@ function setupExplorer() {
}
})
}
}
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)
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()
observer.disconnect()
// select pseudo element at end of list
@ -111,6 +186,12 @@ document.addEventListener("nav", () => {
if (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,
forceLink,
forceCollide,
forceRadial,
zoomIdentity,
select,
drag,
@ -87,6 +88,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags,
showTags,
focusOnHover,
enableRadial,
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map(
@ -161,15 +163,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
})),
}
const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
// we virtualize the simulation and use pixi to actually render it
// Calculate the radius of the container circle
const radius = Math.min(width, height) / 2 - 40 // 40px padding
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", forceCenter().strength(centerForce))
.force("link", forceLink(graphData.links).distance(linkDistance))
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
if (enableRadial)
simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
// precompute style prop strings as pixi doesn't support css variables
const cssVars = [

View File

@ -1,14 +1,70 @@
@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 {
display: flex;
height: 100%;
flex-direction: column;
overflow-y: hidden;
@media all and ($mobile) {
order: -1;
height: initial;
overflow: hidden;
flex-shrink: 0;
align-self: flex-start;
}
button#mobile-explorer {
display: none;
}
button#desktop-explorer {
display: flex;
}
@media all and ($mobile) {
button#mobile-explorer {
display: flex;
}
button#desktop-explorer {
display: none;
}
}
&.desktop-only {
@media all and not ($mobile) {
display: flex;
}
}
/*&:after {
pointer-events: none;
content: "";
@ -23,7 +79,8 @@
}*/
}
button#explorer {
button#mobile-explorer,
button#desktop-explorer {
background-color: transparent;
border: none;
text-align: left;
@ -68,19 +125,19 @@ button#explorer {
list-style: none;
overflow: hidden;
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%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
margin-top: 0.5rem;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
& ul {
@ -91,12 +148,14 @@ button#explorer {
max-height 0.35s ease,
transform 0.35s ease,
opacity 0.2s ease;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
}
}
> #explorer-ul {
max-height: none;
}
@ -179,3 +238,80 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
// remove default margin from li
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;
}
}

4
quartz/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * as Component from "./components"
export * as Plugin from "./plugins"
export * from "./i18n"
export * from "./cfg"

View File

@ -4,7 +4,7 @@ import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { FilePath, FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout"
import { sharedPageComponents } from "$layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"

View File

@ -14,6 +14,7 @@ import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
import DepGraph from "../../depgraph"
import path from "path"
type ComponentResources = {
css: string[]
@ -183,8 +184,13 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return []
},
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<FilePath>()
async getDependencyGraph(ctx, _content, _resources) {
const graph = new DepGraph<FilePath>()
graph.addEdge(
path.join(ctx.argv.output, "index.css") as FilePath,
path.join(process.cwd(), "styles.scss") as FilePath,
)
return graph
},
async emit(ctx, _content, _resources): Promise<FilePath[]> {
const promises: Promise<FilePath>[] = []
@ -245,6 +251,7 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
googleFontsStyleSheet,
...componentResources.css,
styles,
await import("$styles").then((s) => s.default ?? s).catch(() => ""),
)
const [prescript, postscript] = await Promise.all([
joinScripts(componentResources.beforeDOMLoaded),

View File

@ -10,7 +10,7 @@ import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { Argv } from "../../util/ctx"
import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { defaultContentPageLayout, sharedPageComponents } from "$layout"
import { Content } from "../../components"
import chalk from "chalk"
import { write } from "./helpers"

View File

@ -15,7 +15,7 @@ import {
pathToRoot,
simplifySlug,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { defaultListPageLayout, sharedPageComponents } from "$layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"

View File

@ -1,4 +1,4 @@
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { FilePath, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
@ -9,10 +9,10 @@ export const Static: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async getDependencyGraph({ argv, cfg }, _content, _resources) {
async getDependencyGraph({ argv, cfg, quartzRoot }, _content, _resources) {
const graph = new DepGraph<FilePath>()
const staticPath = joinSegments(QUARTZ, "static")
const staticPath = joinSegments(quartzRoot, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
for (const fp of fps) {
graph.addEdge(
@ -23,8 +23,8 @@ export const Static: QuartzEmitterPlugin = () => ({
return graph
},
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
async emit({ argv, cfg, quartzRoot }, _content, _resources): Promise<FilePath[]> {
const staticPath = joinSegments(quartzRoot, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
recursive: true,

View File

@ -12,7 +12,7 @@ import {
joinSegments,
pathToRoot,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { defaultListPageLayout, sharedPageComponents } from "$layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"

View File

@ -7,7 +7,7 @@ import { Root as HTMLRoot } from "hast"
import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
import { PerfTimer } from "../util/perf"
import { read } from "to-vfile"
import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path"
import { FilePath, FullSlug, slugifyFilePath } from "../util/path"
import path from "path"
import workerpool, { Promise as WorkerPromise } from "workerpool"
import { QuartzLogger } from "../util/log"
@ -49,20 +49,28 @@ function* chunks<T>(arr: T[], n: number) {
}
}
async function transpileWorkerScript() {
// transpile worker script
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const fp = "./quartz/worker.ts"
return esbuild.build({
async function transpileWorkerScript(ctx: BuildCtx): Promise<string> {
// import.meta.dirname is the cache folder, because we're in transpiled-build.mjs atm technically
const cacheFile = path.join(import.meta.dirname, "transpiled-worker.mjs")
const fp = path.join(ctx.quartzRoot, "worker.ts")
await esbuild.build({
entryPoints: [fp],
outfile: path.join(QUARTZ, cacheFile),
outfile: cacheFile,
bundle: true,
keepNames: true,
minifyWhitespace: true,
minifySyntax: true,
platform: "node",
format: "esm",
packages: "external",
sourcemap: true,
sourcesContent: false,
alias: {
$config: path.join(process.cwd(), "quartz.config.ts"),
$layout: path.join(process.cwd(), "quartz.layout.ts"),
$styles: path.join(process.cwd(), "styles.scss"),
quartz: path.resolve(ctx.quartzRoot, ".."),
},
plugins: [
{
name: "css-and-scripts-as-text",
@ -79,6 +87,7 @@ async function transpileWorkerScript() {
},
],
})
return cacheFile
}
export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
@ -164,11 +173,12 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
throw error
}
} else {
await transpileWorkerScript()
const pool = workerpool.pool("./quartz/bootstrap-worker.mjs", {
const transpiledWorker = await transpileWorkerScript(ctx)
const pool = workerpool.pool(path.join(ctx.quartzRoot, "bootstrap-worker.mjs"), {
minWorkers: "max",
maxWorkers: concurrency,
workerType: "thread",
workerThreadOpts: { argv: [transpiledWorker] },
})
const errorHandler = (err: any) => {
console.error(`${err}`.replace(/^error:\s*/i, ""))
@ -177,7 +187,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
for (const chunk of chunks(fps, CHUNK_SIZE)) {
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, ctx.quartzRoot, argv, chunk]))
}
const mdResults: [MarkdownContent[], FullSlug[]][] =
await WorkerPromise.all(mdPromises).catch(errorHandler)
@ -187,7 +197,9 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
ctx.allSlugs.push(...extraSlugs)
}
for (const [mdChunk, _] of mdResults) {
childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
childPromises.push(
pool.exec("processHtml", [ctx.buildId, ctx.quartzRoot, argv, mdChunk, ctx.allSlugs]),
)
}
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)

View File

@ -18,4 +18,5 @@ export interface BuildCtx {
argv: Argv
cfg: QuartzConfig
allSlugs: FullSlug[]
quartzRoot: string
}

View File

@ -6,8 +6,6 @@ export const clone = rfdc()
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
/// Utility type to simulate nominal types in TypeScript
type SlugLike<T> = string & { __brand: T }

View File

@ -6,7 +6,7 @@ export const options: sourceMapSupport.Options = {
// source map hack to get around query param
// import cache busting
retrieveSourceMap(source) {
if (source.includes(".quartz-cache")) {
if (source.includes("?update")) {
let realSource = fileURLToPath(source.split("?", 2)[0] + ".map")
return {
map: fs.readFileSync(realSource, "utf8"),

View File

@ -1,6 +1,5 @@
import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options)
import cfg from "../quartz.config"
import { Argv, BuildCtx } from "./util/ctx"
import { FilePath, FullSlug } from "./util/path"
import {
@ -12,9 +11,12 @@ import {
import { options } from "./util/sourcemap"
import { MarkdownContent, ProcessedContent } from "./plugins/vfile"
import cfg from "$config"
// only called from worker thread
export async function parseMarkdown(
buildId: string,
quartzRoot: string,
argv: Argv,
fps: FilePath[],
): Promise<[MarkdownContent[], FullSlug[]]> {
@ -27,6 +29,7 @@ export async function parseMarkdown(
cfg,
argv,
allSlugs,
quartzRoot,
}
return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
}
@ -34,6 +37,7 @@ export async function parseMarkdown(
// only called from worker thread
export function processHtml(
buildId: string,
quartzRoot: string,
argv: Argv,
mds: MarkdownContent[],
allSlugs: FullSlug[],
@ -43,6 +47,7 @@ export function processHtml(
cfg,
argv,
allSlugs,
quartzRoot,
}
return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
}