mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-30 16:24:06 -06:00
Merge branch 'v4' of github-bfahrenfort:jackyzha0/quartz into v4
This commit is contained in:
commit
803125579e
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@ -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
2
.gitignore
vendored
@ -7,3 +7,5 @@ tsconfig.tsbuildinfo
|
|||||||
.obsidian
|
.obsidian
|
||||||
.quartz-cache
|
.quartz-cache
|
||||||
private/
|
private/
|
||||||
|
.replit
|
||||||
|
replit.nix
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
38
docs/features/OxHugo compatibility.md
Normal file
38
docs/features/OxHugo compatibility.md
Normal 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.
|
||||||
@ -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
10
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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")}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
98
quartz/cli/args.js
Normal 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
15
quartz/cli/constants.js
Normal 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
511
quartz/cli/handlers.js
Normal 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
52
quartz/cli/helpers.js
Normal 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 })
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: `
|
||||||
|
|||||||
@ -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())
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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!,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
73
quartz/plugins/transformers/oxhugofm.ts
Normal file
73
quartz/plugins/transformers/oxhugofm.ts
Normal 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 ``
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user