Merge branch 'v4' of github-bfahrenfort:jackyzha0/quartz into v4

This commit is contained in:
bfahrenfort 2023-09-02 13:35:17 -05:00
commit 803125579e
32 changed files with 1100 additions and 596 deletions

View File

@ -1,6 +1,9 @@
name: Build and Test name: Build and Test
on: on:
pull_request:
branches:
- v4
push: push:
branches: branches:
- v4 - v4

2
.gitignore vendored
View File

@ -7,3 +7,5 @@ tsconfig.tsbuildinfo
.obsidian .obsidian
.quartz-cache .quartz-cache
private/ private/
.replit
replit.nix

View File

@ -34,7 +34,7 @@ Some common frontmatter fields that are natively supported by Quartz:
## Syncing your Content ## Syncing your Content
When you're Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`. When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`.
> [!hint] Flags and options > [!hint] Flags and options
> For full help options, you can run `npx quartz sync --help`. > For full help options, you can run `npx quartz sync --help`.

View File

@ -31,6 +31,7 @@ This part of the configuration concerns anything that can affect the whole site.
- This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz` - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`
- Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it. - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it.
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. - `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details.
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
- `theme`: configure how the site looks. - `theme`: configure how the site looks.
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here. - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
- `header`: Font to use for headers - `header`: Font to use for headers

View File

@ -0,0 +1,38 @@
---
tags:
- plugin/transformer
---
[org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown.
Because the Markdown generated by ox-hugo is not pure Markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific Markdown.
```typescript title="quartz.config.ts"
plugins: {
transformers: [
Plugin.FrontMatter({ delims: "+++", language: "toml" }), // if toml frontmatter
// ...
Plugin.OxHugoFlavouredMarkdown(),
Plugin.GitHubFlavoredMarkdown(),
// ...
],
},
```
## Usage
Quartz by default doesn't understand `org-roam` files as they aren't Markdown. You're responsible for using an external tool like `ox-hugo` to export the `org-roam` files as Markdown content to Quartz and managing the static assets so that they're available in the final output.
## Configuration
- Link resolution
- `wikilinks`: Whether to replace `{{ relref }}` with Quartz [[wikilinks]]
- `removePredefinedAnchor`: Whether to remove [pre-defined anchor set by ox-hugo](https://ox-hugo.scripter.co/doc/anchors/).
- Image handling
- `replaceFigureWithMdImg`: Whether to replace `<figure/>` with `![]()`
- Formatting
- `removeHugoShortcode`: Whether to remove hugo shortcode syntax (`{{}}`)
> [!warning]
>
> While you can use `Plugin.OxHugoFlavoredMarkdown` and `Plugin.ObsidianFlavoredMarkdown` together, it's not recommended because it might mutate the file in unexpected ways. Use with caution.

View File

@ -16,5 +16,6 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/) - [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Matt Dunn's Second Brain](https://mattdunn.info/) - [Matt Dunn's Second Brain](https://mattdunn.info/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
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/content/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/content/showcase.md)!

10
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.9", "version": "4.0.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.9", "version": "4.0.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.6.3", "@clack/prompts": "^0.6.3",
@ -55,6 +55,7 @@
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^7.2.4", "to-vfile": "^7.2.4",
"toml": "^3.0.0",
"unified": "^10.1.2", "unified": "^10.1.2",
"unist-util-visit": "^4.1.2", "unist-util-visit": "^4.1.2",
"vfile": "^5.3.7", "vfile": "^5.3.7",
@ -5548,6 +5549,11 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.0.9", "version": "4.0.10",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@ -79,6 +79,7 @@
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^7.2.4", "to-vfile": "^7.2.4",
"toml": "^3.0.0",
"unified": "^10.1.2", "unified": "^10.1.2",
"unist-util-visit": "^4.1.2", "unist-util-visit": "^4.1.2",
"vfile": "^5.3.7", "vfile": "^5.3.7",

View File

@ -1,548 +1,39 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises, readFileSync } from "fs"
import yargs from "yargs" import yargs from "yargs"
import path from "path"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import esbuild from "esbuild" import {
import chalk from "chalk" handleBuild,
import { sassPlugin } from "esbuild-sass-plugin" handleCreate,
import fs from "fs" handleUpdate,
import { intro, isCancel, outro, select, text } from "@clack/prompts" handleRestore,
import { rimraf } from "rimraf" handleSync,
import chokidar from "chokidar" } from "./cli/handlers.js"
import prettyBytes from "pretty-bytes" import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
import { execSync, spawnSync } from "child_process" import { version } from "./cli/constants.js"
import http from "http"
import serveHandler from "serve-handler"
import { WebSocketServer } from "ws"
import { randomUUID } from "crypto"
import { Mutex } from "async-mutex"
const ORIGIN_NAME = "origin"
const UPSTREAM_NAME = "upstream"
const QUARTZ_SOURCE_BRANCH = "v4"
const cwd = process.cwd()
const cacheDir = path.join(cwd, ".quartz-cache")
const cacheFile = "./.quartz-cache/transpiled-build.mjs"
const fp = "./quartz/build.ts"
const { version } = JSON.parse(readFileSync("./package.json").toString())
const contentCacheFolder = path.join(cacheDir, "content-cache")
const CommonArgv = {
directory: {
string: true,
alias: ["d"],
default: "content",
describe: "directory to look for content files",
},
verbose: {
boolean: true,
alias: ["v"],
default: false,
describe: "print out extra logging information",
},
}
const SyncArgv = {
...CommonArgv,
commit: {
boolean: true,
default: true,
describe: "create a git commit for your unsaved changes",
},
push: {
boolean: true,
default: true,
describe: "push updates to your Quartz fork",
},
pull: {
boolean: true,
default: true,
describe: "pull updates from your Quartz fork",
},
}
const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: "run a local server to live-preview your Quartz",
},
baseDir: {
string: true,
default: "",
describe: "base path to serve your local server on",
},
port: {
number: true,
default: 8080,
describe: "port to serve Quartz on",
},
bundleInfo: {
boolean: true,
default: false,
describe: "show detailed bundle information",
},
concurrency: {
number: true,
describe: "how many threads to use to parse notes",
},
}
function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
.replace(/^".*"$/, "$1")
.replace(/^'.*"$/, "$1")
.trim()
}
function exitIfCancel(val) {
if (isCancel(val)) {
outro(chalk.red("Exiting"))
process.exit(0)
} else {
return val
}
}
async function stashContentFolder(contentFolder) {
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
async function popContentFolder(contentFolder) {
await fs.promises.rm(contentFolder, { force: true, recursive: true })
await fs.promises.cp(contentCacheFolder, contentFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}
function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(`Error while pulling updates: ${out.stderr}`)
}
}
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("quartz") .scriptName("quartz")
.version(version) .version(version)
.usage("$0 <cmd> [args]") .usage("$0 <cmd> [args]")
.command("create", "Initialize Quartz", CommonArgv, async (argv) => { .command("create", "Initialize Quartz", CreateArgv, async (argv) => {
console.log() await handleCreate(argv)
intro(chalk.bgGreen.black(` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory)
const setupStrategy = exitIfCancel(
await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: "new", label: "Empty Quartz" },
{ value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
{
value: "symlink",
label: "Symlink an existing folder",
hint: "don't select this unless you know what you are doing!",
},
],
}),
)
async function rmContentFolder() {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
await fs.promises.unlink(contentFolder)
} else {
await rimraf(contentFolder)
}
}
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
if (setupStrategy === "copy" || setupStrategy === "symlink") {
const originalFolder = escapePath(
exitIfCancel(
await text({
message: "Enter the full path to existing content folder",
placeholder:
"On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
validate(fp) {
const fullPath = escapePath(fp)
if (!fs.existsSync(fullPath)) {
return "The given path doesn't exist"
} else if (!fs.lstatSync(fullPath).isDirectory()) {
return "The given path is not a folder"
}
},
}),
),
)
await rmContentFolder()
if (setupStrategy === "copy") {
await fs.promises.cp(originalFolder, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
}
} else if (setupStrategy === "new") {
await fs.promises.writeFile(
path.join(contentFolder, "index.md"),
`---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
`,
)
}
// get a preferred link resolution strategy
const linkResolutionStrategy = exitIfCancel(
await select({
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
options: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
},
{
value: "relative",
label: "Treat links as relative paths",
hint: "for just normal Markdown files",
},
],
}),
)
// now, do config changes
const configFilePath = path.join(cwd, "quartz.config.ts")
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
configContent = configContent.replace(
/markdownLinkResolution: '(.+)'/,
`markdownLinkResolution: '${linkResolutionStrategy}'`,
)
await fs.promises.writeFile(configFilePath, configContent)
outro(`You're all set! Not sure what to do next? Try:
Customizing Quartz a bit more by editing \`quartz.config.ts\`
Running \`npx quartz build --serve\` to preview your Quartz locally
Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
`)
}) })
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => { .command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleUpdate(argv)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
)
await stashContentFolder(contentFolder)
console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date")
spawnSync("npm", ["i"], { stdio: "inherit" })
console.log(chalk.green("Done!"))
}) })
.command( .command(
"restore", "restore",
"Try to restore your content folder from the cache", "Try to restore your content folder from the cache",
CommonArgv, CommonArgv,
async (argv) => { async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleRestore(argv)
await popContentFolder(contentFolder)
}, },
) )
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => { .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleSync(argv)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
if (argv.commit) {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
const linkTarg = await fs.promises.readlink(contentFolder)
console.log(chalk.yellow("Detected symlink, trying to dereference before committing"))
// stash symlink file
await stashContentFolder(contentFolder)
// follow symlink and copy content
await fs.promises.cp(linkTarg, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
}
const currentTimestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
spawnSync("git", ["add", "."], { stdio: "inherit" })
spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" })
if (contentStat.isSymbolicLink()) {
// put symlink back
await popContentFolder(contentFolder)
}
}
await stashContentFolder(contentFolder)
if (argv.pull) {
console.log(
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
}
await popContentFolder(contentFolder)
if (argv.push) {
console.log("Pushing your changes")
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
}
console.log(chalk.green("Done!"))
}) })
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) await handleBuild(argv)
const ctx = await esbuild.context({
entryPoints: [fp],
outfile: path.join("quartz", cacheFile),
bundle: true,
keepNames: true,
minifyWhitespace: true,
minifySyntax: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
sourcesContent: false,
plugins: [
sassPlugin({
type: "css-text",
cssImports: true,
}),
{
name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
},
],
})
const buildMutex = new Mutex()
let lastBuildMs = 0
let cleanupBuild = null
const build = async (clientRefresh) => {
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await buildMutex.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
if (cleanupBuild) {
await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
}
const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
process.exit(1)
})
release()
if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName]
console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
)})`,
)
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// bypass module cache
// https://github.com/nodejs/modules/issues/307
const { default: buildQuartz } = await import(cacheFile + `?update=${randomUUID()}`)
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
clientRefresh()
}
if (argv.serve) {
const connections = []
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
argv.baseDir = "/" + argv.baseDir
}
await build(clientRefresh)
const server = http.createServer(async (req, res) => {
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
console.log(
chalk.red(
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
),
)
res.writeHead(404)
res.end()
return
}
// strip baseDir prefix
req.url = req.url?.slice(argv.baseDir.length)
const serve = async () => {
await serveHandler(req, res, {
public: argv.output,
directoryListing: false,
headers: [
{
source: "**/*.html",
headers: [{ key: "Content-Disposition", value: "inline" }],
},
],
})
const status = res.statusCode
const statusString =
status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
}
const redirect = (newFp) => {
newFp = argv.baseDir + newFp
res.writeHead(302, {
Location: newFp,
})
console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
res.end()
}
let fp = req.url?.split("?")[0] ?? "/"
// handle redirects
if (fp.endsWith("/")) {
// /trailing/
// does /trailing/index.html exist? if so, serve it
const indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
req.url = fp
return serve()
}
// does /trailing.html exist? if so, redirect to /trailing
let base = fp.slice(0, -1)
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
return redirect(fp.slice(0, -1))
}
} else {
// /regular
// does /regular.html exist? if so, serve it
let base = fp
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
req.url = fp
return serve()
}
// does /regular/index.html exist? if so, redirect to /regular/
let indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
return redirect(fp + "/")
}
}
return serve()
})
server.listen(argv.port)
const wss = new WebSocketServer({ port: 3001 })
wss.on("connection", (ws) => connections.push(ws))
console.log(
chalk.cyan(
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
),
)
console.log("hint: exit with ctrl+c")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
build(clientRefresh)
})
} else {
await build(() => {})
ctx.dispose()
}
}) })
.showHelpOnFail(false) .showHelpOnFail(false)
.help() .help()

View File

@ -45,7 +45,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
perf.addEvent("glob") perf.addEvent("glob")
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
const fps = allFiles.filter((fp) => fp.endsWith(".md")) const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort()
console.log( console.log(
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
) )

View File

@ -1,3 +1,4 @@
import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types" import { QuartzComponent } from "./components/types"
import { PluginTypes } from "./plugins/types" import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme" import { Theme } from "./util/theme"
@ -22,6 +23,8 @@ export interface GlobalConfiguration {
analytics: Analytics analytics: Analytics
/** Glob patterns to not search */ /** Glob patterns to not search */
ignorePatterns: string[] ignorePatterns: string[]
/** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time * Quartz will avoid using this as much as possible and use relative URLs most of the time
*/ */

98
quartz/cli/args.js Normal file
View File

@ -0,0 +1,98 @@
export const CommonArgv = {
directory: {
string: true,
alias: ["d"],
default: "content",
describe: "directory to look for content files",
},
verbose: {
boolean: true,
alias: ["v"],
default: false,
describe: "print out extra logging information",
},
}
export const CreateArgv = {
...CommonArgv,
source: {
string: true,
alias: ["s"],
describe: "source directory to copy/create symlink from",
},
strategy: {
string: true,
alias: ["X"],
choices: ["new", "copy", "symlink"],
describe: "strategy for content folder setup",
},
links: {
string: true,
alias: ["l"],
choices: ["absolute", "shortest", "relative"],
describe: "strategy to resolve links",
},
}
export const SyncArgv = {
...CommonArgv,
commit: {
boolean: true,
default: true,
describe: "create a git commit for your unsaved changes",
},
push: {
boolean: true,
default: true,
describe: "push updates to your Quartz fork",
},
pull: {
boolean: true,
default: true,
describe: "pull updates from your Quartz fork",
},
}
export const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: "run a local server to live-preview your Quartz",
},
baseDir: {
string: true,
default: "",
describe: "base path to serve your local server on",
},
port: {
number: true,
default: 8080,
describe: "port to serve Quartz on",
},
wsPort: {
number: true,
default: 3001,
describe: "port to use for WebSocket-based hot-reload notifications",
},
remoteDevHost: {
string: true,
default: "",
describe: "A URL override for the websocket connection if you are not developing on localhost",
},
bundleInfo: {
boolean: true,
default: false,
describe: "show detailed bundle information",
},
concurrency: {
number: true,
describe: "how many threads to use to parse notes",
},
}

15
quartz/cli/constants.js Normal file
View File

@ -0,0 +1,15 @@
import path from "path"
import { readFileSync } from "fs"
/**
* All constants relating to helpers or handlers
*/
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())
export const contentCacheFolder = path.join(cacheDir, "content-cache")

511
quartz/cli/handlers.js Normal file
View File

@ -0,0 +1,511 @@
import { promises, readFileSync } from "fs"
import path from "path"
import esbuild from "esbuild"
import chalk from "chalk"
import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs"
import { intro, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf"
import chokidar from "chokidar"
import prettyBytes from "pretty-bytes"
import { execSync, spawnSync } from "child_process"
import http from "http"
import serveHandler from "serve-handler"
import { WebSocketServer } from "ws"
import { randomUUID } from "crypto"
import { Mutex } from "async-mutex"
import { CreateArgv } from "./args.js"
import {
exitIfCancel,
escapePath,
gitPull,
popContentFolder,
stashContentFolder,
} from "./helpers.js"
import {
UPSTREAM_NAME,
QUARTZ_SOURCE_BRANCH,
ORIGIN_NAME,
version,
fp,
cacheFile,
cwd,
} from "./constants.js"
/**
* Handles `npx quartz create`
* @param {*} argv arguments for `create`
*/
export async function handleCreate(argv) {
console.log()
intro(chalk.bgGreen.black(` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory)
let setupStrategy = argv.strategy?.toLowerCase()
let linkResolutionStrategy = argv.links?.toLowerCase()
const sourceDirectory = argv.source
// If all cmd arguments were provided, check if theyre valid
if (setupStrategy && linkResolutionStrategy) {
// If setup isn't, "new", source argument is required
if (setupStrategy !== "new") {
// Error handling
if (!sourceDirectory) {
outro(
chalk.red(
`Setup strategies (arg '${chalk.yellow(
`-${CreateArgv.strategy.alias[0]}`,
)}') other than '${chalk.yellow(
"new",
)}' require content folder argument ('${chalk.yellow(
`-${CreateArgv.source.alias[0]}`,
)}') to be set`,
),
)
process.exit(1)
} else {
if (!fs.existsSync(sourceDirectory)) {
outro(
chalk.red(
`Input directory to copy/symlink 'content' from not found ('${chalk.yellow(
sourceDirectory,
)}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`,
),
)
process.exit(1)
} else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
outro(
chalk.red(
`Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow(
sourceDirectory,
)}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`,
),
)
process.exit(1)
}
}
}
}
// Use cli process if cmd args werent provided
if (!setupStrategy) {
setupStrategy = exitIfCancel(
await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: "new", label: "Empty Quartz" },
{ value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
{
value: "symlink",
label: "Symlink an existing folder",
hint: "don't select this unless you know what you are doing!",
},
],
}),
)
}
async function rmContentFolder() {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
await fs.promises.unlink(contentFolder)
} else {
await rimraf(contentFolder)
}
}
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
if (setupStrategy === "copy" || setupStrategy === "symlink") {
let originalFolder = sourceDirectory
// If input directory was not passed, use cli
if (!sourceDirectory) {
originalFolder = escapePath(
exitIfCancel(
await text({
message: "Enter the full path to existing content folder",
placeholder:
"On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
validate(fp) {
const fullPath = escapePath(fp)
if (!fs.existsSync(fullPath)) {
return "The given path doesn't exist"
} else if (!fs.lstatSync(fullPath).isDirectory()) {
return "The given path is not a folder"
}
},
}),
),
)
}
await rmContentFolder()
if (setupStrategy === "copy") {
await fs.promises.cp(originalFolder, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
}
} else if (setupStrategy === "new") {
await fs.promises.writeFile(
path.join(contentFolder, "index.md"),
`---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
`,
)
}
// Use cli process if cmd args werent provided
if (!linkResolutionStrategy) {
// get a preferred link resolution strategy
linkResolutionStrategy = exitIfCancel(
await select({
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
options: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
},
{
value: "relative",
label: "Treat links as relative paths",
hint: "for just normal Markdown files",
},
],
}),
)
}
// now, do config changes
const configFilePath = path.join(cwd, "quartz.config.ts")
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
configContent = configContent.replace(
/markdownLinkResolution: '(.+)'/,
`markdownLinkResolution: '${linkResolutionStrategy}'`,
)
await fs.promises.writeFile(configFilePath, configContent)
outro(`You're all set! Not sure what to do next? Try:
Customizing Quartz a bit more by editing \`quartz.config.ts\`
Running \`npx quartz build --serve\` to preview your Quartz locally
Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
`)
}
/**
* Handles `npx quartz build`
* @param {*} argv arguments for `build`
*/
export async function handleBuild(argv) {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
const ctx = await esbuild.context({
entryPoints: [fp],
outfile: cacheFile,
bundle: true,
keepNames: true,
minifyWhitespace: true,
minifySyntax: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
sourcesContent: false,
plugins: [
sassPlugin({
type: "css-text",
cssImports: true,
}),
{
name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
},
],
})
const buildMutex = new Mutex()
let lastBuildMs = 0
let cleanupBuild = null
const build = async (clientRefresh) => {
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await buildMutex.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
if (cleanupBuild) {
await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
}
const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
process.exit(1)
})
release()
if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName]
console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
)})`,
)
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// 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
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
clientRefresh()
}
if (argv.serve) {
const connections = []
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
argv.baseDir = "/" + argv.baseDir
}
await build(clientRefresh)
const server = http.createServer(async (req, res) => {
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
console.log(
chalk.red(
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
),
)
res.writeHead(404)
res.end()
return
}
// strip baseDir prefix
req.url = req.url?.slice(argv.baseDir.length)
const serve = async () => {
const release = await buildMutex.acquire()
await serveHandler(req, res, {
public: argv.output,
directoryListing: false,
headers: [
{
source: "**/*.html",
headers: [{ key: "Content-Disposition", value: "inline" }],
},
],
})
const status = res.statusCode
const statusString =
status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
release()
}
const redirect = (newFp) => {
newFp = argv.baseDir + newFp
res.writeHead(302, {
Location: newFp,
})
console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
res.end()
}
let fp = req.url?.split("?")[0] ?? "/"
// handle redirects
if (fp.endsWith("/")) {
// /trailing/
// does /trailing/index.html exist? if so, serve it
const indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
req.url = fp
return serve()
}
// does /trailing.html exist? if so, redirect to /trailing
let base = fp.slice(0, -1)
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
return redirect(fp.slice(0, -1))
}
} else {
// /regular
// does /regular.html exist? if so, serve it
let base = fp
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
req.url = fp
return serve()
}
// does /regular/index.html exist? if so, redirect to /regular/
let indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
return redirect(fp + "/")
}
}
return serve()
})
server.listen(argv.port)
const wss = new WebSocketServer({ port: argv.wsPort })
wss.on("connection", (ws) => connections.push(ws))
console.log(
chalk.cyan(
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
),
)
console.log("hint: exit with ctrl+c")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
build(clientRefresh)
})
} else {
await build(() => {})
ctx.dispose()
}
}
/**
* Handles `npx quartz update`
* @param {*} argv arguments for `update`
*/
export async function handleUpdate(argv) {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
)
await stashContentFolder(contentFolder)
console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date")
spawnSync("npm", ["i"], { stdio: "inherit" })
console.log(chalk.green("Done!"))
}
/**
* Handles `npx quartz restore`
* @param {*} argv arguments for `restore`
*/
export async function handleRestore(argv) {
const contentFolder = path.join(cwd, argv.directory)
await popContentFolder(contentFolder)
}
/**
* Handles `npx quartz sync`
* @param {*} argv arguments for `sync`
*/
export async function handleSync(argv) {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
if (argv.commit) {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
const linkTarg = await fs.promises.readlink(contentFolder)
console.log(chalk.yellow("Detected symlink, trying to dereference before committing"))
// stash symlink file
await stashContentFolder(contentFolder)
// follow symlink and copy content
await fs.promises.cp(linkTarg, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
}
const currentTimestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
spawnSync("git", ["add", "."], { stdio: "inherit" })
spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" })
if (contentStat.isSymbolicLink()) {
// put symlink back
await popContentFolder(contentFolder)
}
}
await stashContentFolder(contentFolder)
if (argv.pull) {
console.log(
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
}
await popContentFolder(contentFolder)
if (argv.push) {
console.log("Pushing your changes")
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
}
console.log(chalk.green("Done!"))
}

52
quartz/cli/helpers.js Normal file
View File

@ -0,0 +1,52 @@
import { isCancel, outro } from "@clack/prompts"
import chalk from "chalk"
import { contentCacheFolder } from "./constants.js"
import { spawnSync } from "child_process"
import fs from "fs"
export function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
.replace(/^".*"$/, "$1")
.replace(/^'.*"$/, "$1")
.trim()
}
export function exitIfCancel(val) {
if (isCancel(val)) {
outro(chalk.red("Exiting"))
process.exit(0)
} else {
return val
}
}
export async function stashContentFolder(contentFolder) {
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(`Error while pulling updates: ${out.stderr}`)
}
}
export async function popContentFolder(contentFolder) {
await fs.promises.rm(contentFolder, { force: true, recursive: true })
await fs.promises.cp(contentCacheFolder, contentFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}

View File

@ -1,15 +1,16 @@
import { formatDate } from "./Date" import { formatDate, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time" import readingTime from "reading-time"
export default (() => { export default (() => {
function ContentMetadata({ fileData }: QuartzComponentProps) { function ContentMetadata({ cfg, fileData }: QuartzComponentProps) {
const text = fileData.text const text = fileData.text
if (text) { if (text) {
const segments: string[] = [] const segments: string[] = []
const { text: timeTaken, words: _words } = readingTime(text) const { text: timeTaken, words: _words } = readingTime(text)
if (fileData.dates?.modified) {
segments.push(formatDate(fileData.dates.modified)) if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!))
} }
segments.push(timeTaken) segments.push(timeTaken)

View File

@ -1,7 +1,21 @@
import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
interface Props { interface Props {
date: Date date: Date
} }
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
if (!cfg.defaultDateType) {
throw new Error(
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
)
}
return data.dates?.[cfg.defaultDateType]
}
export function formatDate(d: Date): string { export function formatDate(d: Date): string {
return d.toLocaleDateString("en-US", { return d.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",

View File

@ -1,31 +1,36 @@
import { FullSlug, resolveRelative } from "../util/path" import { FullSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { Date } from "./Date" import { Date, getDate } from "./Date"
import { QuartzComponentProps } from "./types" import { QuartzComponentProps } from "./types"
import { GlobalConfiguration } from "../cfg"
export function byDateAndAlphabetical(f1: QuartzPluginData, f2: QuartzPluginData): number { export function byDateAndAlphabetical(
if (f1.dates && f2.dates) { cfg: GlobalConfiguration,
// sort descending by last modified ): (f1: QuartzPluginData, f2: QuartzPluginData) => number {
return f2.dates.modified.getTime() - f1.dates.modified.getTime() return (f1, f2) => {
} else if (f1.dates && !f2.dates) { if (f1.dates && f2.dates) {
// prioritize files with dates // sort descending
return -1 return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
} else if (!f1.dates && f2.dates) { } else if (f1.dates && !f2.dates) {
return 1 // prioritize files with dates
return -1
} else if (!f1.dates && f2.dates) {
return 1
}
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
} }
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
} }
type Props = { type Props = {
limit?: number limit?: number
} & QuartzComponentProps } & QuartzComponentProps
export function PageList({ fileData, allFiles, limit }: Props) { export function PageList({ cfg, fileData, allFiles, limit }: Props) {
let list = allFiles.sort(byDateAndAlphabetical) let list = allFiles.sort(byDateAndAlphabetical(cfg))
if (limit) { if (limit) {
list = list.slice(0, limit) list = list.slice(0, limit)
} }
@ -41,7 +46,7 @@ export function PageList({ fileData, allFiles, limit }: Props) {
<div class="section"> <div class="section">
{page.dates && ( {page.dates && (
<p class="meta"> <p class="meta">
<Date date={page.dates.modified} /> <Date date={getDate(cfg, page)!} />
</p> </p>
)} )}
<div class="desc"> <div class="desc">

View File

@ -3,7 +3,8 @@ import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { byDateAndAlphabetical } from "./PageList" import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss" import style from "./styles/recentNotes.scss"
import { Date } from "./Date" import { Date, getDate } from "./Date"
import { GlobalConfiguration } from "../cfg"
interface Options { interface Options {
title: string title: string
@ -13,18 +14,18 @@ interface Options {
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
} }
const defaultOptions: Options = { const defaultOptions = (cfg: GlobalConfiguration): Options => ({
title: "Recent Notes", title: "Recent Notes",
limit: 3, limit: 3,
linkToMore: false, linkToMore: false,
filter: () => true, filter: () => true,
sort: byDateAndAlphabetical, sort: byDateAndAlphabetical(cfg),
} })
export default ((userOpts?: Partial<Options>) => { export default ((userOpts?: Partial<Options>) => {
const opts = { ...defaultOptions, ...userOpts }
function RecentNotes(props: QuartzComponentProps) { function RecentNotes(props: QuartzComponentProps) {
const { allFiles, fileData, displayClass } = props const { allFiles, fileData, displayClass, cfg } = props
const opts = { ...defaultOptions(cfg), ...userOpts }
const pages = allFiles.filter(opts.filter).sort(opts.sort) const pages = allFiles.filter(opts.filter).sort(opts.sort)
const remaining = Math.max(0, pages.length - opts.limit) const remaining = Math.max(0, pages.length - opts.limit)
return ( return (
@ -47,7 +48,7 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
{page.dates && ( {page.dates && (
<p class="meta"> <p class="meta">
<Date date={page.dates.modified} /> <Date date={getDate(cfg, page)!} />
</p> </p>
)} )}
<ul class="tags"> <ul class="tags">

View File

@ -231,7 +231,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
.attr("dy", (d) => -nodeRadius(d) + "px") .attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.text( .text(
(d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "), (d) =>
data[d.id]?.title ||
(d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "),
) )
.style("opacity", (opacityScale - 1) / 3.75) .style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none") .style("pointer-events", "none")

View File

@ -1,4 +1,4 @@
import { Document } from "flexsearch" import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path" import { FullSlug, resolveRelative } from "../../util/path"
@ -8,12 +8,20 @@ interface Item {
slug: FullSlug slug: FullSlug
title: string title: string
content: string content: string
tags: string[]
} }
let index: Document<Item> | undefined = undefined let index: Document<Item> | undefined = undefined
// Can be expanded with things like "term" in the future
type SearchType = "basic" | "tags"
// Current searchType
let searchType: SearchType = "basic"
const contextWindowWords = 30 const contextWindowWords = 30
const numSearchResults = 5 const numSearchResults = 5
const numTagResults = 3
function highlight(searchTerm: string, text: string, trim?: boolean) { function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first // try to highlight longest tokens first
const tokenizedTerms = searchTerm const tokenizedTerms = searchTerm
@ -87,9 +95,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) { if (results) {
removeAllChildren(results) removeAllChildren(results)
} }
searchType = "basic" // reset search type after closing
} }
function showSearch() { function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "1" sidebar.style.zIndex = "1"
} }
@ -98,10 +109,18 @@ document.addEventListener("nav", async (e: unknown) => {
} }
function shortcutHandler(e: HTMLElementEventMap["keydown"]) { function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch() searchBarOpen ? hideSearch() : showSearch("basic")
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("tags")
// add "#" prefix for tag search
if (searchBar) searchBar.value = "#"
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (anchor) { if (anchor) {
@ -110,24 +129,81 @@ document.addEventListener("nav", async (e: unknown) => {
} }
} }
function trimContent(content: string) {
// works without escaping html like in `description.ts`
const sentences = content.replace(/\s+/g, " ").split(".")
let finalDesc = ""
let sentenceIdx = 0
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
const len = contextWindowWords * 5
while (finalDesc.length < len) {
const sentence = sentences[sentenceIdx]
if (!sentence) break
finalDesc += sentence + "."
sentenceIdx++
}
// If more content would be available, indicate it by finishing with "..."
if (finalDesc.length < content.length) {
finalDesc += ".."
}
return finalDesc
}
const formatForDisplay = (term: string, id: number) => { const formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] const slug = idDataMap[id]
return { return {
id, id,
slug, slug,
title: highlight(term, data[slug].title ?? ""), title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
content: highlight(term, data[slug].content ?? "", true), // if searchType is tag, display context from start of file and trim, otherwise use regular highlight
content:
searchType === "tags"
? trimContent(data[slug].content)
: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term, data[slug].tags),
} }
} }
const resultToHTML = ({ slug, title, content }: Item) => { function highlightTags(term: string, tags: string[]) {
if (tags && searchType === "tags") {
// Find matching tags
const termLower = term.toLowerCase()
let matching = tags.filter((str) => str.includes(termLower))
// Substract matching from original tags, then push difference
if (matching.length > 0) {
let difference = tags.filter((x) => !matching.includes(x))
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
matching.push(...difference)
}
// Only allow max of `numTagResults` in preview
if (tags.length > numTagResults) {
matching.splice(numTagResults)
}
return matching
} else {
return []
}
}
const resultToHTML = ({ slug, title, content, tags }: Item) => {
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
const button = document.createElement("button") const button = document.createElement("button")
button.classList.add("result-card") button.classList.add("result-card")
button.id = slug button.id = slug
button.innerHTML = `<h3>${title}</h3><p>${content}</p>` button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
button.addEventListener("click", () => { button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug) const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
hideSearch()
}) })
return button return button
} }
@ -147,15 +223,45 @@ document.addEventListener("nav", async (e: unknown) => {
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
const term = (e.target as HTMLInputElement).value let term = (e.target as HTMLInputElement).value
const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] let searchResults: SimpleDocumentSearchResultSetUnit[]
if (term.toLowerCase().startsWith("#")) {
searchType = "tags"
} else {
searchType = "basic"
}
switch (searchType) {
case "tags": {
term = term.substring(1)
searchResults =
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
[]
break
}
case "basic":
default: {
searchResults =
(await index?.searchAsync({
query: term,
limit: numSearchResults,
index: ["title", "content"],
})) ?? []
}
}
const getByField = (field: string): number[] => { const getByField = (field: string): number[] => {
const results = searchResults.filter((x) => x.field === field) const results = searchResults.filter((x) => x.field === field)
return results.length === 0 ? [] : ([...results[0].result] as number[]) return results.length === 0 ? [] : ([...results[0].result] as number[])
} }
// order titles ahead of content // order titles ahead of content
const allIds: Set<number> = new Set([...getByField("title"), ...getByField("content")]) const allIds: Set<number> = new Set([
...getByField("title"),
...getByField("content"),
...getByField("tags"),
])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults) displayResults(finalResults)
} }
@ -166,8 +272,8 @@ document.addEventListener("nav", async (e: unknown) => {
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler prevShortcutHandler = shortcutHandler
searchIcon?.removeEventListener("click", showSearch) searchIcon?.removeEventListener("click", () => showSearch("basic"))
searchIcon?.addEventListener("click", showSearch) searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType) searchBar?.removeEventListener("input", onType)
searchBar?.addEventListener("input", onType) searchBar?.addEventListener("input", onType)
@ -189,22 +295,36 @@ document.addEventListener("nav", async (e: unknown) => {
field: "content", field: "content",
tokenize: "reverse", tokenize: "reverse",
}, },
{
field: "tags",
tokenize: "reverse",
},
], ],
}, },
}) })
let id = 0 fillDocument(index, data)
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, {
id,
slug: slug as FullSlug,
title: fileData.title,
content: fileData.content,
})
id++
}
} }
// register handlers // register handlers
registerEscapeHandler(container, hideSearch) registerEscapeHandler(container, hideSearch)
}) })
/**
* Fills flexsearch document with data
* @param index index to fill
* @param data data to fill index with
*/
async function fillDocument(index: Document<Item, false>, data: any) {
let id = 0
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, {
id,
slug: slug as FullSlug,
title: fileData.title,
content: fileData.content,
tags: fileData.tags,
})
id++
}
}

View File

@ -130,6 +130,44 @@
margin: 0; margin: 0;
} }
& > ul > li {
margin: 0;
display: inline-block;
white-space: nowrap;
margin: 0;
overflow-wrap: normal;
}
& > ul {
list-style: none;
display: flex;
padding-left: 0;
gap: 0.4rem;
margin: 0;
margin-top: 0.45rem;
// Offset border radius
margin-left: -2px;
overflow: hidden;
background-clip: border-box;
}
& > ul > li > p {
border-radius: 8px;
background-color: var(--highlight);
overflow: hidden;
background-clip: border-box;
padding: 0.03rem 0.4rem;
margin: 0;
color: var(--secondary);
opacity: 0.85;
}
& > ul > li > .match-tag {
color: var(--tertiary);
font-weight: bold;
opacity: 1;
}
& > p { & > p {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@ -12,15 +12,20 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
for (const [_tree, file] of content) { for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!) const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory) const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? [] let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") { if (typeof aliases === "string") {
aliases = [aliases] aliases = [aliases]
} }
for (const alias of aliases) { const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const slug = path.posix.join(dir, alias) as FullSlug const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") {
slugs.push(permalink as FullSlug)
}
for (const slug of slugs) {
const redirUrl = resolveRelative(slug, file.data.slug!) const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({ const fp = await emit({
content: ` content: `

View File

@ -107,12 +107,18 @@ function addGlobalPageResources(
document.dispatchEvent(event)`) document.dispatchEvent(event)`)
} }
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
if (ctx.argv.remoteDevHost) {
wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
}
if (reloadScript) { if (reloadScript) {
staticResources.js.push({ staticResources.js.push({
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "inline", contentType: "inline",
script: ` script: `
const socket = new WebSocket('ws://localhost:3001') const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload()) socket.addEventListener('message', () => document.location.reload())
`, `,
}) })

View File

@ -1,4 +1,5 @@
import { GlobalConfiguration } from "../../cfg" import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date"
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import path from "path" import path from "path"
@ -74,7 +75,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const linkIndex: ContentIndex = new Map() const linkIndex: ContentIndex = new Map()
for (const [_tree, file] of content) { for (const [_tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
const date = file.data.dates?.modified ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { linkIndex.set(slug, {
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,

View File

@ -2,14 +2,17 @@ import matter from "gray-matter"
import remarkFrontmatter from "remark-frontmatter" import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import yaml from "js-yaml" import yaml from "js-yaml"
import toml from "toml"
import { slugTag } from "../../util/path" import { slugTag } from "../../util/path"
export interface Options { export interface Options {
delims: string | string[] delims: string | string[]
language: "yaml" | "toml"
} }
const defaultOptions: Options = { const defaultOptions: Options = {
delims: "---", delims: "---",
language: "yaml",
} }
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
name: "FrontMatter", name: "FrontMatter",
markdownPlugins() { markdownPlugins() {
return [ return [
remarkFrontmatter, [remarkFrontmatter, ["yaml", "toml"]],
() => { () => {
return (_, file) => { return (_, file) => {
const { data } = matter(file.value, { const { data } = matter(file.value, {
...opts, ...opts,
engines: { engines: {
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
toml: (s) => toml.parse(s) as object,
}, },
}) })

View File

@ -5,6 +5,7 @@ export { Latex } from "./latex"
export { Description } from "./description" export { Description } from "./description"
export { CrawlLinks } from "./links" export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm" export { ObsidianFlavoredMarkdown } from "./ofm"
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax" export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc" export { TableOfContents } from "./toc"
export { Remark42 } from "./remark42" export { Remark42 } from "./remark42"

View File

@ -109,14 +109,16 @@ const capitalize = (s: string): string => {
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
const highlightRegex = new RegExp(/==(.+)==/, "g") const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g") const commentRegex = new RegExp(/%%(.+)%%/, "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 // (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
// #(\w+) -> tag itself is # followed by a string of alpha-numeric characters // #(...) -> capturing group, tag itself must start with #
const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu") // (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores
// (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
@ -383,13 +385,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return (tree: Root, file) => { return (tree: Root, file) => {
const base = pathToRoot(file.data.slug!) const base = pathToRoot(file.data.slug!)
findAndReplace(tree, tagRegex, (_value: string, tag: string) => { findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
tag = slugTag(tag)
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
file.data.frontmatter.tags.push(tag) file.data.frontmatter.tags.push(tag)
} }
return { return {
type: "link", type: "link",
url: base + `/tags/${slugTag(tag)}`, url: base + `/tags/${tag}`,
data: { data: {
hProperties: { hProperties: {
className: ["tag-link"], className: ["tag-link"],

View File

@ -0,0 +1,73 @@
import { QuartzTransformerPlugin } from "../types"
export interface Options {
/** Replace {{ relref }} with quartz wikilinks []() */
wikilinks: boolean
/** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
removePredefinedAnchor: boolean
/** Remove hugo shortcode syntax */
removeHugoShortcode: boolean
/** Replace <figure/> with ![]() */
replaceFigureWithMdImg: boolean
}
const defaultOptions: Options = {
wikilinks: true,
removePredefinedAnchor: true,
removeHugoShortcode: true,
replaceFigureWithMdImg: true,
}
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
/**
* ox-hugo is an org exporter backend that exports org files to hugo-compatible
* markdown in an opinionated way. This plugin adds some tweaks to the generated
* markdown to make it compatible with quartz but the list of changes applied it
* is not exhaustive.
* */
export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "OxHugoFlavouredMarkdown",
textTransform(_ctx, src) {
if (opts.wikilinks) {
src = src.toString()
src = src.replaceAll(relrefRegex, (value, ...capture) => {
const [text, link] = capture
return `[${text}](${link})`
})
}
if (opts.removePredefinedAnchor) {
src = src.toString()
src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
const [headingText] = capture
return headingText
})
}
if (opts.removeHugoShortcode) {
src = src.toString()
src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
const [scContent] = capture
return scContent
})
}
if (opts.replaceFigureWithMdImg) {
src = src.toString()
src = src.replaceAll(figureTagRegex, (value, ...capture) => {
const [src] = capture
return `![](${src})`
})
}
return src
},
}
}

View File

@ -27,6 +27,11 @@ section {
border-radius: 5px; border-radius: 5px;
} }
::selection {
background: color-mix(in srgb, var(--tertiary) 75%, transparent);
color: var(--darkgray);
}
p, p,
ul, ul,
text, text,

View File

@ -82,7 +82,6 @@
.callout-title { .callout-title {
display: flex; display: flex;
align-items: center;
gap: 5px; gap: 5px;
padding: 1rem 0; padding: 1rem 0;
color: var(--color); color: var(--color);
@ -103,6 +102,8 @@
.callout-icon { .callout-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
padding-top: 4px;
} }
.callout-title-inner { .callout-title-inner {

View File

@ -7,6 +7,8 @@ export interface Argv {
output: string output: string
serve: boolean serve: boolean
port: number port: number
wsPort: number
remoteDevHost?: string
concurrency?: number concurrency?: number
} }