This commit is contained in:
parent
3b46993254
commit
312a2330c3
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
@ -1,4 +1,4 @@
|
||||
|
||||
# https://garden.matsuuratomoya.com w/ Quartz v4
|
||||
|
||||
https://github.com/jackyzha0/quartz
|
||||
https://github.com/jackyzha0/quartz
|
||||
|
3
globals.d.ts
vendored
3
globals.d.ts
vendored
@ -4,9 +4,10 @@ export declare global {
|
||||
type: K,
|
||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||
): void
|
||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
|
||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
|
||||
}
|
||||
interface Window {
|
||||
spaNavigate(url: URL, isBack: boolean = false)
|
||||
addCleanup(fn: (...args: any[]) => void)
|
||||
}
|
||||
}
|
||||
|
1
index.d.ts
vendored
1
index.d.ts
vendored
@ -6,6 +6,7 @@ declare module "*.scss" {
|
||||
// dom custom event
|
||||
interface CustomEventMap {
|
||||
nav: CustomEvent<{ url: FullSlug }>
|
||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||
}
|
||||
|
||||
declare const fetchData: Promise<ContentIndex>
|
||||
|
1114
package-lock.json
generated
1114
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@ -2,7 +2,7 @@
|
||||
"name": "@jackyzha0/quartz",
|
||||
"description": "🌱 publish your digital garden and notes as a website",
|
||||
"private": true,
|
||||
"version": "4.1.4",
|
||||
"version": "4.2.2",
|
||||
"type": "module",
|
||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||
"license": "MIT",
|
||||
@ -35,15 +35,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@floating-ui/dom": "^1.5.3",
|
||||
"@napi-rs/simple-git": "0.1.9",
|
||||
"async-mutex": "^0.4.0",
|
||||
"@floating-ui/dom": "^1.6.1",
|
||||
"@napi-rs/simple-git": "0.1.14",
|
||||
"async-mutex": "^0.4.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.8.5",
|
||||
"esbuild-sass-plugin": "^2.16.0",
|
||||
"flexsearch": "0.7.21",
|
||||
"flexsearch": "0.7.43",
|
||||
"github-slugger": "^2.0.0",
|
||||
"globby": "^14.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@ -52,9 +52,9 @@
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"is-absolute-url": "^4.0.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lightningcss": "^1.22.1",
|
||||
"lightningcss": "^1.23.0",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"mdast-util-to-hast": "^13.0.2",
|
||||
"mdast-util-to-hast": "^13.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromorph": "^0.4.5",
|
||||
"preact": "^10.19.3",
|
||||
@ -64,8 +64,8 @@
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-mathjax": "^5.0.0",
|
||||
"rehype-pretty-code": "^0.12.3",
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-pretty-code": "^0.12.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
@ -74,37 +74,35 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.0.0",
|
||||
"remark-rehype": "^11.1.0",
|
||||
"remark-smartypants": "^2.0.0",
|
||||
"rfdc": "^1.3.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"serve-handler": "^6.1.5",
|
||||
"shikiji": "^0.9.9",
|
||||
"shikiji": "^0.10.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"to-vfile": "^8.0.0",
|
||||
"toml": "^3.0.0",
|
||||
"unified": "^11.0.4",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.1",
|
||||
"workerpool": "^8.0.0",
|
||||
"workerpool": "^9.1.0",
|
||||
"ws": "^8.15.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-spinner": "^0.2.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/hast": "^3.0.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.1.2",
|
||||
"@types/node": "^20.11.14",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/workerpool": "^6.4.7",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"esbuild": "^0.19.9",
|
||||
"prettier": "^3.1.1",
|
||||
"tsx": "^4.6.2",
|
||||
"prettier": "^3.2.4",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
@ -6,11 +6,12 @@ const config: QuartzConfig = {
|
||||
pageTitle: "Matsuura Tomoya Research Note",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
locale: "en-US",
|
||||
analytics: {
|
||||
provider: "plausible",
|
||||
},
|
||||
baseUrl: "garden.matsuuratomoya.com",
|
||||
ignorePatterns: ["private", "templates",".obsidian"],
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "modified",
|
||||
theme: {
|
||||
typography: {
|
||||
@ -45,14 +46,14 @@ const config: QuartzConfig = {
|
||||
plugins: {
|
||||
transformers: [
|
||||
Plugin.FrontMatter(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["git", "filesystem" , "frontmatter"], // you can add 'git' here for last modified from Git but this makes the build slower
|
||||
priority: ["git", "filesystem", "frontmatter"], // you can add 'git' here for last modified from Git but this makes the build slower
|
||||
}),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
Plugin.SyntaxHighlighting(),
|
||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Description(),
|
||||
],
|
||||
|
@ -55,12 +55,13 @@ export const defaultContentPageLayout: PageLayout = {
|
||||
|
||||
// components for pages that display lists of pages (e.g. tags or folders)
|
||||
export const defaultListPageLayout: PageLayout = {
|
||||
beforeBody: [Component.ArticleTitle()],
|
||||
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
],
|
||||
right: [],
|
||||
}
|
198
quartz/build.ts
198
quartz/build.ts
@ -3,13 +3,13 @@ sourceMapSupport.install(options)
|
||||
import path from "path"
|
||||
import { PerfTimer } from "./util/perf"
|
||||
import { rimraf } from "rimraf"
|
||||
import { isGitIgnored } from "globby"
|
||||
import { GlobbyFilterFunction, isGitIgnored } from "globby"
|
||||
import chalk from "chalk"
|
||||
import { parseMarkdown } from "./processors/parse"
|
||||
import { filterContent } from "./processors/filter"
|
||||
import { emitContent } from "./processors/emit"
|
||||
import cfg from "../quartz.config"
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
||||
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
|
||||
import chokidar from "chokidar"
|
||||
import { ProcessedContent } from "./plugins/vfile"
|
||||
import { Argv, BuildCtx } from "./util/ctx"
|
||||
@ -18,6 +18,19 @@ import { trace } from "./util/trace"
|
||||
import { options } from "./util/sourcemap"
|
||||
import { Mutex } from "async-mutex"
|
||||
|
||||
type BuildData = {
|
||||
ctx: BuildCtx
|
||||
ignored: GlobbyFilterFunction
|
||||
mut: Mutex
|
||||
initialSlugs: FullSlug[]
|
||||
// TODO merge contentMap and trackedAssets
|
||||
contentMap: Map<FilePath, ProcessedContent>
|
||||
trackedAssets: Set<FilePath>
|
||||
toRebuild: Set<FilePath>
|
||||
toRemove: Set<FilePath>
|
||||
lastBuildMs: number
|
||||
}
|
||||
|
||||
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||
const ctx: BuildCtx = {
|
||||
argv,
|
||||
@ -73,91 +86,22 @@ async function startServing(
|
||||
) {
|
||||
const { argv } = ctx
|
||||
|
||||
const ignored = await isGitIgnored()
|
||||
const contentMap = new Map<FilePath, ProcessedContent>()
|
||||
for (const content of initialContent) {
|
||||
const [_tree, vfile] = content
|
||||
contentMap.set(vfile.data.filePath!, content)
|
||||
}
|
||||
|
||||
const initialSlugs = ctx.allSlugs
|
||||
let lastBuildMs = 0
|
||||
const toRebuild: Set<FilePath> = new Set()
|
||||
const toRemove: Set<FilePath> = new Set()
|
||||
const trackedAssets: Set<FilePath> = new Set()
|
||||
async function rebuild(fp: string, action: "add" | "change" | "delete") {
|
||||
// don't do anything for gitignored files
|
||||
if (ignored(fp)) {
|
||||
return
|
||||
}
|
||||
|
||||
// dont bother rebuilding for non-content files, just track and refresh
|
||||
fp = toPosixPath(fp)
|
||||
const filePath = joinSegments(argv.directory, fp) as FilePath
|
||||
if (path.extname(fp) !== ".md") {
|
||||
if (action === "add" || action === "change") {
|
||||
trackedAssets.add(filePath)
|
||||
} else if (action === "delete") {
|
||||
trackedAssets.delete(filePath)
|
||||
}
|
||||
clientRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "add" || action === "change") {
|
||||
toRebuild.add(filePath)
|
||||
} else if (action === "delete") {
|
||||
toRemove.add(filePath)
|
||||
}
|
||||
|
||||
// debounce rebuilds every 250ms
|
||||
|
||||
const buildStart = new Date().getTime()
|
||||
lastBuildMs = buildStart
|
||||
const release = await mut.acquire()
|
||||
if (lastBuildMs > buildStart) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||
try {
|
||||
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
||||
|
||||
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
|
||||
.filter((fp) => !toRemove.has(fp))
|
||||
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
|
||||
|
||||
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
|
||||
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
|
||||
for (const content of parsedContent) {
|
||||
const [_tree, vfile] = content
|
||||
contentMap.set(vfile.data.filePath!, content)
|
||||
}
|
||||
|
||||
for (const fp of toRemove) {
|
||||
contentMap.delete(fp)
|
||||
}
|
||||
|
||||
const parsedFiles = [...contentMap.values()]
|
||||
const filteredContent = filterContent(ctx, parsedFiles)
|
||||
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||
// instead of just deleting everything
|
||||
await rimraf(argv.output)
|
||||
await emitContent(ctx, filteredContent)
|
||||
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||
if (argv.verbose) {
|
||||
console.log(chalk.red(err))
|
||||
}
|
||||
}
|
||||
|
||||
release()
|
||||
clientRefresh()
|
||||
toRebuild.clear()
|
||||
toRemove.clear()
|
||||
const buildData: BuildData = {
|
||||
ctx,
|
||||
mut,
|
||||
contentMap,
|
||||
ignored: await isGitIgnored(),
|
||||
initialSlugs: ctx.allSlugs,
|
||||
toRebuild: new Set<FilePath>(),
|
||||
toRemove: new Set<FilePath>(),
|
||||
trackedAssets: new Set<FilePath>(),
|
||||
lastBuildMs: 0,
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(".", {
|
||||
@ -167,15 +111,101 @@ async function startServing(
|
||||
})
|
||||
|
||||
watcher
|
||||
.on("add", (fp) => rebuild(fp, "add"))
|
||||
.on("change", (fp) => rebuild(fp, "change"))
|
||||
.on("unlink", (fp) => rebuild(fp, "delete"))
|
||||
.on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
|
||||
.on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
|
||||
.on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
|
||||
|
||||
return async () => {
|
||||
await watcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildFromEntrypoint(
|
||||
fp: string,
|
||||
action: "add" | "change" | "delete",
|
||||
clientRefresh: () => void,
|
||||
buildData: BuildData, // note: this function mutates buildData
|
||||
) {
|
||||
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
|
||||
buildData
|
||||
|
||||
const { argv } = ctx
|
||||
|
||||
// don't do anything for gitignored files
|
||||
if (ignored(fp)) {
|
||||
return
|
||||
}
|
||||
|
||||
// dont bother rebuilding for non-content files, just track and refresh
|
||||
fp = toPosixPath(fp)
|
||||
const filePath = joinSegments(argv.directory, fp) as FilePath
|
||||
if (path.extname(fp) !== ".md") {
|
||||
if (action === "add" || action === "change") {
|
||||
trackedAssets.add(filePath)
|
||||
} else if (action === "delete") {
|
||||
trackedAssets.delete(filePath)
|
||||
}
|
||||
clientRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "add" || action === "change") {
|
||||
toRebuild.add(filePath)
|
||||
} else if (action === "delete") {
|
||||
toRemove.add(filePath)
|
||||
}
|
||||
|
||||
const buildStart = new Date().getTime()
|
||||
buildData.lastBuildMs = buildStart
|
||||
const release = await mut.acquire()
|
||||
|
||||
// there's another build after us, release and let them do it
|
||||
if (buildData.lastBuildMs > buildStart) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||
try {
|
||||
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
||||
|
||||
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
|
||||
.filter((fp) => !toRemove.has(fp))
|
||||
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
|
||||
|
||||
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
|
||||
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
|
||||
for (const content of parsedContent) {
|
||||
const [_tree, vfile] = content
|
||||
contentMap.set(vfile.data.filePath!, content)
|
||||
}
|
||||
|
||||
for (const fp of toRemove) {
|
||||
contentMap.delete(fp)
|
||||
}
|
||||
|
||||
const parsedFiles = [...contentMap.values()]
|
||||
const filteredContent = filterContent(ctx, parsedFiles)
|
||||
|
||||
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||
// instead of just deleting everything
|
||||
await rimraf(argv.output)
|
||||
await emitContent(ctx, filteredContent)
|
||||
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||
if (argv.verbose) {
|
||||
console.log(chalk.red(err))
|
||||
}
|
||||
}
|
||||
|
||||
release()
|
||||
clientRefresh()
|
||||
toRebuild.clear()
|
||||
toRemove.clear()
|
||||
}
|
||||
|
||||
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
|
||||
try {
|
||||
return await buildQuartz(argv, mut, clientRefresh)
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ValidDateType } from "./components/Date"
|
||||
import { QuartzComponent } from "./components/types"
|
||||
import { ValidLocale } from "./i18n"
|
||||
import { PluginTypes } from "./plugins/types"
|
||||
import { Theme } from "./util/theme"
|
||||
|
||||
@ -16,6 +17,7 @@ export type Analytics =
|
||||
| {
|
||||
provider: "umami"
|
||||
websiteId: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export interface GlobalConfiguration {
|
||||
@ -35,6 +37,15 @@ export interface GlobalConfiguration {
|
||||
*/
|
||||
baseUrl?: string
|
||||
theme: Theme
|
||||
/**
|
||||
* Allow to translate the date in the language of your choice.
|
||||
* Also used for UI translation (default: en-US)
|
||||
* Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
|
||||
* The first part is the language (en) and the second part is the script/region (US)
|
||||
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||
*/
|
||||
locale: ValidLocale
|
||||
}
|
||||
|
||||
export interface QuartzConfig {
|
||||
|
@ -113,7 +113,10 @@ export async function handleCreate(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
|
||||
const gitkeepPath = path.join(contentFolder, ".gitkeep")
|
||||
if (fs.existsSync(gitkeepPath)) {
|
||||
await fs.promises.unlink(gitkeepPath)
|
||||
}
|
||||
if (setupStrategy === "copy" || setupStrategy === "symlink") {
|
||||
let originalFolder = sourceDirectory
|
||||
|
||||
@ -165,22 +168,20 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
// 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\`.`,
|
||||
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. 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",
|
||||
hint: "(default)",
|
||||
},
|
||||
{
|
||||
value: "absolute",
|
||||
label: "Treat links as absolute path",
|
||||
},
|
||||
{
|
||||
value: "relative",
|
||||
label: "Treat links as relative paths",
|
||||
hint: "for just normal Markdown files",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@ -199,6 +200,7 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
// setup remote
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
{ stdio: "ignore" },
|
||||
)
|
||||
|
||||
outro(`You're all set! Not sure what to do next? Try:
|
||||
@ -255,6 +257,7 @@ export async function handleBuild(argv) {
|
||||
},
|
||||
write: false,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
})
|
||||
@ -344,7 +347,7 @@ export async function handleBuild(argv) {
|
||||
directoryListing: false,
|
||||
headers: [
|
||||
{
|
||||
source: "**/*.html",
|
||||
source: "**/*.*",
|
||||
headers: [{ key: "Content-Disposition", value: "inline" }],
|
||||
},
|
||||
],
|
||||
@ -447,7 +450,7 @@ export async function handleUpdate(argv) {
|
||||
try {
|
||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(chalk.red("An error occured above while pulling updates."))
|
||||
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
@ -519,7 +522,7 @@ export async function handleSync(argv) {
|
||||
try {
|
||||
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(chalk.red("An error occured above while pulling updates."))
|
||||
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
|
||||
const title = fileData.frontmatter?.title
|
||||
if (title) {
|
||||
return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
|
||||
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/backlinks.scss"
|
||||
import { resolveRelative, simplifySlug } from "../util/path"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
|
||||
function Backlinks({ fileData, allFiles, displayClass, cfg }: QuartzComponentProps) {
|
||||
const slug = simplifySlug(fileData.slug!)
|
||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||
return (
|
||||
<div class={`backlinks ${displayClass ?? ""}`}>
|
||||
<h3>Backlinks</h3>
|
||||
<div class={classNames(displayClass, "backlinks")}>
|
||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||
<ul class="overflow">
|
||||
{backlinkFiles.length > 0 ? (
|
||||
backlinkFiles.map((f) => (
|
||||
@ -18,7 +20,7 @@ function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>No backlinks found</li>
|
||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
type CrumbData = {
|
||||
displayName: string
|
||||
@ -18,15 +19,15 @@ interface BreadcrumbOptions {
|
||||
*/
|
||||
rootName: string
|
||||
/**
|
||||
* wether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Wether to display breadcrumbs on root `index.md`
|
||||
* Whether to display breadcrumbs on root `index.md`
|
||||
*/
|
||||
hideOnRoot: boolean
|
||||
/**
|
||||
* Wether to display the current page in the breadcrumbs.
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
}
|
||||
@ -69,9 +70,9 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
for (const file of allFiles) {
|
||||
if (file.slug?.endsWith("index")) {
|
||||
const folderParts = file.slug?.split("/")
|
||||
if (folderParts) {
|
||||
// 2nd last to exclude the /index
|
||||
const folderName = folderParts[folderParts?.length - 2]
|
||||
// 2nd last to exclude the /index
|
||||
const folderName = folderParts?.at(-2)
|
||||
if (folderName) {
|
||||
folderIndex.set(folderName, file)
|
||||
}
|
||||
}
|
||||
@ -104,15 +105,16 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
}
|
||||
|
||||
// Add current file to crumb (can directly use frontmatter title)
|
||||
if (options.showCurrentPage) {
|
||||
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||
crumbs.push({
|
||||
displayName: fileData.frontmatter!.title,
|
||||
path: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
|
||||
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<div class="breadcrumb-element">
|
||||
<a href={crumb.path}>{crumb.displayName}</a>
|
||||
|
@ -1,22 +1,40 @@
|
||||
import { formatDate, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
export default (() => {
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: string[] = []
|
||||
const { text: timeTaken, words: _words } = readingTime(text)
|
||||
|
||||
if (fileData.dates) {
|
||||
// segments.push(formatDate(getDate(cfg, fileData)!))
|
||||
// segments.push('created: ' + formatDate(fileData.dates?.["created"]))
|
||||
segments.push('last updated: ' + formatDate(fileData.dates?.["modified"]))
|
||||
segments.push('last updated: ' + formatDate(fileData.dates?.["modified"], cfg.locale))
|
||||
}
|
||||
|
||||
segments.push(timeTaken)
|
||||
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { text: timeTaken, words: _words } = readingTime(text)
|
||||
segments.push(timeTaken)
|
||||
}
|
||||
|
||||
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
@ -4,10 +4,12 @@
|
||||
import darkmodeScript from "./scripts/darkmode.inline"
|
||||
import styles from "./styles/darkmode.scss"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||
function Darkmode({ displayClass, cfg }: QuartzComponentProps) {
|
||||
return (
|
||||
<div class={`darkmode ${displayClass ?? ""}`}>
|
||||
<div class={classNames(displayClass, "darkmode")}>
|
||||
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
|
||||
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
|
||||
<svg
|
||||
@ -21,7 +23,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<title>Light mode</title>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
|
||||
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
@ -37,7 +39,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<title>Dark mode</title>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
|
||||
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { ValidLocale } from "../i18n"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
|
||||
interface Props {
|
||||
date: Date
|
||||
locale?: ValidLocale
|
||||
}
|
||||
|
||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||
@ -16,14 +18,14 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date
|
||||
return data.dates?.[cfg.defaultDateType]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString("ja-JP", {
|
||||
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||
return d.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function Date({ date }: Props) {
|
||||
return <>{formatDate(date)}</>
|
||||
export function Date({ date, locale }: Props) {
|
||||
return <>{formatDate(date, locale)}</>
|
||||
}
|
||||
|
@ -5,10 +5,11 @@ import explorerStyle from "./styles/explorer.scss"
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
||||
const defaultOptions = {
|
||||
title: "Explorer",
|
||||
folderClickBehavior: "collapse",
|
||||
folderDefaultState: "collapsed",
|
||||
useSavedState: true,
|
||||
@ -69,16 +70,15 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
}
|
||||
|
||||
// Get all folders of tree. Initialize with collapsed state
|
||||
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||
|
||||
// Stringify to pass json tree as data attribute ([data-tree])
|
||||
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||
jsonTree = JSON.stringify(folders)
|
||||
}
|
||||
|
||||
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
|
||||
function Explorer({ cfg, allFiles, displayClass, fileData }: QuartzComponentProps) {
|
||||
constructFileTree(allFiles)
|
||||
return (
|
||||
<div class={`explorer ${displayClass ?? ""}`}>
|
||||
<div class={classNames(displayClass, "explorer")}>
|
||||
<button
|
||||
type="button"
|
||||
id="explorer"
|
||||
@ -87,7 +87,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
>
|
||||
<h1>{opts.title}</h1>
|
||||
<h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title: string
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
|
@ -1,20 +1,22 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
function Footer({ displayClass }: QuartzComponentProps) {
|
||||
function Footer({ displayClass, cfg }: QuartzComponentProps) {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<hr />
|
||||
<p>
|
||||
Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year}
|
||||
{i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(links).map(([text, link]) => (
|
||||
|
@ -2,6 +2,8 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
@ -52,12 +54,12 @@ const defaultOptions: GraphOptions = {
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
function Graph({ displayClass }: QuartzComponentProps) {
|
||||
function Graph({ displayClass, cfg }: QuartzComponentProps) {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={`graph ${displayClass ?? ""}`}>
|
||||
<h3>Graph View</h3>
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
<h3>{i18n(cfg.locale).components.graph.title}</h3>
|
||||
<div class="graph-outer">
|
||||
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<svg
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { i18n } from "../i18n"
|
||||
import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
|
||||
import { JSResourceToScriptElement } from "../util/resources"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default (() => {
|
||||
function Head({ cfg, fileData, externalResources }: QuartzComponentProps) {
|
||||
const title = fileData.frontmatter?.title ?? "Untitled"
|
||||
const description = fileData.description?.trim() ?? "No description provided"
|
||||
const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const description =
|
||||
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
|
||||
const { css, js } = externalResources
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
|
@ -46,7 +46,7 @@ export function PageList({ cfg, fileData, allFiles, limit }: Props) {
|
||||
<div class="section">
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} />
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
</p>
|
||||
)}
|
||||
<div class="desc">
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { pathToRoot } from "../util/path"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
|
||||
const title = cfg?.pageTitle ?? "Untitled Quartz"
|
||||
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
return (
|
||||
<h1 class={`page-title ${displayClass ?? ""}`}>
|
||||
<h1 class={classNames(displayClass, "page-title")}>
|
||||
<a href={baseDir}>{title}</a>
|
||||
</h1>
|
||||
)
|
||||
|
@ -5,9 +5,11 @@ import { byDateAndAlphabetical } from "./PageList"
|
||||
import style from "./styles/recentNotes.scss"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
interface Options {
|
||||
title: string
|
||||
title?: string
|
||||
limit: number
|
||||
linkToMore: SimpleSlug | false
|
||||
filter: (f: QuartzPluginData) => boolean
|
||||
@ -15,7 +17,6 @@ interface Options {
|
||||
}
|
||||
|
||||
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||
title: "Recent Notes",
|
||||
limit: 3,
|
||||
linkToMore: false,
|
||||
filter: () => true,
|
||||
@ -28,11 +29,11 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||
const remaining = Math.max(0, pages.length - opts.limit)
|
||||
return (
|
||||
<div class={`recent-notes ${displayClass ?? ""}`}>
|
||||
<h3>{opts.title}</h3>
|
||||
<div class={classNames(displayClass, "recent-notes")}>
|
||||
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
||||
<ul class="recent-ul">
|
||||
{pages.slice(0, opts.limit).map((page) => {
|
||||
const title = page.frontmatter?.title
|
||||
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
@ -47,7 +48,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</div>
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} />
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
</p>
|
||||
)}
|
||||
<ul class="tags">
|
||||
@ -69,7 +70,9 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</ul>
|
||||
{opts.linkToMore && remaining > 0 && (
|
||||
<p>
|
||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>See {remaining} more →</a>
|
||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
||||
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
@ -2,13 +2,25 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/search.scss"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/search.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
export default (() => {
|
||||
function Search({ displayClass }: QuartzComponentProps) {
|
||||
export interface SearchOptions {
|
||||
enablePreview: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = {
|
||||
enablePreview: true,
|
||||
}
|
||||
|
||||
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||
function Search({ displayClass, cfg }: QuartzComponentProps) {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
||||
return (
|
||||
<div class={`search ${displayClass ?? ""}`}>
|
||||
<div class={classNames(displayClass, "search")}>
|
||||
<div id="search-icon">
|
||||
<p>Search</p>
|
||||
<p>{i18n(cfg.locale).components.search.title}</p>
|
||||
<div></div>
|
||||
<svg
|
||||
tabIndex={0}
|
||||
@ -32,10 +44,10 @@ export default (() => {
|
||||
id="search-bar"
|
||||
name="search"
|
||||
type="text"
|
||||
aria-label="Search for something"
|
||||
placeholder="Search for something"
|
||||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div id="results-container"></div>
|
||||
<div id="search-layout" data-preview={opts.enablePreview}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||
return <div class={`spacer ${displayClass ?? ""}`}></div>
|
||||
return <div class={classNames(displayClass, "spacer")}></div>
|
||||
}
|
||||
|
||||
export default (() => Spacer) satisfies QuartzComponentConstructor
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import legacyStyle from "./styles/legacyToc.scss"
|
||||
import modernStyle from "./styles/toc.scss"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/toc.inline"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
@ -13,15 +15,15 @@ const defaultOptions: Options = {
|
||||
layout: "modern",
|
||||
}
|
||||
|
||||
function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
|
||||
function TableOfContents({ fileData, displayClass, cfg }: QuartzComponentProps) {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`toc ${displayClass ?? ""}`}>
|
||||
<div class={classNames(displayClass, "toc")}>
|
||||
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||
<h3>Table of Contents</h3>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
@ -54,15 +56,14 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = script
|
||||
|
||||
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
|
||||
function LegacyTableOfContents({ fileData, cfg }: QuartzComponentProps) {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<details id="toc" open={!fileData.collapseToc}>
|
||||
<summary>
|
||||
<h3>Table of Contents</h3>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
</summary>
|
||||
<ul>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { pathToRoot, slugTag } from "../util/path"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
function TagList({ fileData, displayClass }: QuartzComponentProps) {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={`tags ${displayClass ?? ""}`}>
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const display = `#${tag}`
|
||||
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { QuartzComponentConstructor } from "../types"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
function NotFound() {
|
||||
function NotFound({ cfg }: QuartzComponentProps) {
|
||||
return (
|
||||
<article class="popover-hint">
|
||||
<h1>404</h1>
|
||||
<p>Either this page is private or doesn't exist.</p>
|
||||
<p>{i18n(cfg.locale).pages.error.notFound}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
function Content({ fileData, tree }: QuartzComponentProps) {
|
||||
const content = htmlToJsx(fileData.filePath!, tree)
|
||||
return <article class="popover-hint">{content}</article>
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
}
|
||||
|
||||
export default (() => Content) satisfies QuartzComponentConstructor
|
||||
|
@ -5,43 +5,67 @@ import style from "../styles/listPage.scss"
|
||||
import { PageList } from "../PageList"
|
||||
import { _stripSlashes, simplifySlug } from "../../util/path"
|
||||
import { Root } from "hast"
|
||||
import { pluralize } from "../../util/lang"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
function FolderContent(props: QuartzComponentProps) {
|
||||
const { tree, fileData, allFiles } = props
|
||||
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
|
||||
const allPagesInFolder = allFiles.filter((file) => {
|
||||
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
|
||||
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||
const folderParts = folderSlug.split(path.posix.sep)
|
||||
const fileParts = fileSlug.split(path.posix.sep)
|
||||
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||
return prefixed && isDirectChild
|
||||
})
|
||||
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content =
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
*/
|
||||
showFolderCount: boolean
|
||||
}
|
||||
|
||||
FolderContent.css = style + PageList.css
|
||||
export default (() => FolderContent) satisfies QuartzComponentConstructor
|
||||
const defaultOptions: FolderContentOptions = {
|
||||
showFolderCount: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function FolderContent(props: QuartzComponentProps) {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
|
||||
const allPagesInFolder = allFiles.filter((file) => {
|
||||
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
|
||||
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||
const folderParts = folderSlug.split(path.posix.sep)
|
||||
const fileParts = fileSlug.split(path.posix.sep)
|
||||
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||
return prefixed && isDirectChild
|
||||
})
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content =
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
|
||||
return (
|
||||
<div class={classes}>
|
||||
<article>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
|
||||
count: allPagesInFolder.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FolderContent.css = style + PageList.css
|
||||
return FolderContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
@ -4,12 +4,12 @@ import { PageList } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { pluralize } from "../../util/lang"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
const numPages = 10
|
||||
function TagContent(props: QuartzComponentProps) {
|
||||
const { tree, fileData, allFiles } = props
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const slug = fileData.slug
|
||||
|
||||
if (!(slug?.startsWith("tags/") || slug === "tags")) {
|
||||
@ -26,7 +26,8 @@ function TagContent(props: QuartzComponentProps) {
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
@ -37,13 +38,12 @@ function TagContent(props: QuartzComponentProps) {
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<div class={classes}>
|
||||
<article>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>Found {tags.length} total tags.</p>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
const pages = tagItemMap.get(tag)!
|
||||
@ -62,11 +62,17 @@ function TagContent(props: QuartzComponentProps) {
|
||||
</a>
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<p>
|
||||
{pluralize(pages.length, "item")} with this tag.{" "}
|
||||
{pages.length > numPages && `Showing first ${numPages}.`}
|
||||
</p>
|
||||
<PageList limit={numPages} {...listProps} />
|
||||
<div class="page-listing">
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
|
||||
{pages.length > numPages && (
|
||||
<span>
|
||||
{i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={numPages} {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -81,11 +87,13 @@ function TagContent(props: QuartzComponentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<div class={classes}>
|
||||
<article>{content}</article>
|
||||
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -7,6 +7,8 @@ import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../ut
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
@ -63,6 +65,7 @@ function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map<FullSlug, Quar
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
@ -136,7 +139,9 @@ export function renderPage(
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal"] },
|
||||
children: [{ type: "text", value: `Link to original` }],
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
} else if (page.htmlAst) {
|
||||
@ -147,7 +152,14 @@ export function renderPage(
|
||||
tagName: "h1",
|
||||
properties: {},
|
||||
children: [
|
||||
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
|
||||
{
|
||||
type: "text",
|
||||
value:
|
||||
page.frontmatter?.title ??
|
||||
i18n(cfg.locale).components.transcludes.transcludeOf({
|
||||
targetSlug: page.slug!,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(page.htmlAst.children as ElementContent[]).map((child) =>
|
||||
@ -157,7 +169,9 @@ export function renderPage(
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal"] },
|
||||
children: [{ type: "text", value: `Link to original` }],
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
@ -1,21 +1,21 @@
|
||||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle(`is-collapsed`)
|
||||
const collapsed = outerBlock.classList.contains(`is-collapsed`)
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
|
||||
outerBlock.style.maxHeight = height + `px`
|
||||
outerBlock.style.maxHeight = height + "px"
|
||||
|
||||
// walk and adjust height of all parents
|
||||
let current = outerBlock
|
||||
let parent = outerBlock.parentElement
|
||||
while (parent) {
|
||||
if (!parent.classList.contains(`callout`)) {
|
||||
if (!parent.classList.contains("callout")) {
|
||||
return
|
||||
}
|
||||
|
||||
const collapsed = parent.classList.contains(`is-collapsed`)
|
||||
const collapsed = parent.classList.contains("is-collapsed")
|
||||
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
|
||||
parent.style.maxHeight = height + `px`
|
||||
parent.style.maxHeight = height + "px"
|
||||
|
||||
current = parent
|
||||
parent = parent.parentElement
|
||||
@ -30,15 +30,15 @@ function setupCallout() {
|
||||
const title = div.firstElementChild
|
||||
|
||||
if (title) {
|
||||
title.removeEventListener(`click`, toggleCallout)
|
||||
title.addEventListener(`click`, toggleCallout)
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains(`is-collapsed`)
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
const height = collapsed ? title.scrollHeight : div.scrollHeight
|
||||
div.style.maxHeight = height + `px`
|
||||
div.style.maxHeight = height + "px"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(`nav`, setupCallout)
|
||||
window.addEventListener(`resize`, setupCallout)
|
||||
document.addEventListener("nav", setupCallout)
|
||||
window.addEventListener("resize", setupCallout)
|
||||
|
@ -14,7 +14,7 @@ document.addEventListener("nav", () => {
|
||||
button.type = "button"
|
||||
button.innerHTML = svgCopy
|
||||
button.ariaLabel = "Copy source"
|
||||
button.addEventListener("click", () => {
|
||||
function onClick() {
|
||||
navigator.clipboard.writeText(source).then(
|
||||
() => {
|
||||
button.blur()
|
||||
@ -26,7 +26,9 @@ document.addEventListener("nav", () => {
|
||||
},
|
||||
(error) => console.error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
els[i].prepend(button)
|
||||
}
|
||||
}
|
||||
|
@ -2,31 +2,39 @@ const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "l
|
||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||
|
||||
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||
detail: { theme },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchTheme = (e: any) => {
|
||||
if (e.target.checked) {
|
||||
document.documentElement.setAttribute("saved-theme", "dark")
|
||||
localStorage.setItem("theme", "dark")
|
||||
} else {
|
||||
document.documentElement.setAttribute("saved-theme", "light")
|
||||
localStorage.setItem("theme", "light")
|
||||
}
|
||||
const switchTheme = (e: Event) => {
|
||||
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
const themeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
toggleSwitch.checked = e.matches
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
// Darkmode toggle
|
||||
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
|
||||
toggleSwitch.removeEventListener("change", switchTheme)
|
||||
toggleSwitch.addEventListener("change", switchTheme)
|
||||
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
||||
if (currentTheme === "dark") {
|
||||
toggleSwitch.checked = true
|
||||
}
|
||||
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", (e) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
toggleSwitch.checked = e.matches
|
||||
})
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||
})
|
||||
|
@ -1,132 +1,106 @@
|
||||
import { FolderState } from "../ExplorerNode"
|
||||
|
||||
// Current state of folders
|
||||
let explorerState: FolderState[]
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
let currentExplorerState: FolderState[]
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||
const explorer = document.getElementById("explorer-ul")
|
||||
const explorerUl = document.getElementById("explorer-ul")
|
||||
if (!explorerUl) return
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
explorer?.classList.add("no-background")
|
||||
explorerUl.classList.add("no-background")
|
||||
} else {
|
||||
explorer?.classList.remove("no-background")
|
||||
explorerUl.classList.remove("no-background")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
// Toggle collapsed state of entire explorer
|
||||
this.classList.toggle("collapsed")
|
||||
const content = this.nextElementSibling as HTMLElement
|
||||
const content = this.nextElementSibling as MaybeHTMLElement
|
||||
if (!content) return
|
||||
|
||||
content.classList.toggle("collapsed")
|
||||
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Element that was clicked
|
||||
const target = evt.target as HTMLElement
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
const childFolderContainer = (
|
||||
isSvg
|
||||
? target.parentElement?.nextSibling
|
||||
: target.parentElement?.parentElement?.nextElementSibling
|
||||
) as MaybeHTMLElement
|
||||
const currentFolderParent = (
|
||||
isSvg ? target.nextElementSibling : target.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!(childFolderContainer && currentFolderParent)) return
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
let childFolderContainer: HTMLElement
|
||||
|
||||
// <li> element of folder (stores folder-path dataset)
|
||||
let currentFolderParent: HTMLElement
|
||||
|
||||
// Get correct relative container and toggle collapsed class
|
||||
if (isSvg) {
|
||||
childFolderContainer = target.parentElement?.nextSibling as HTMLElement
|
||||
currentFolderParent = target.nextElementSibling as HTMLElement
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
} else {
|
||||
childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
|
||||
currentFolderParent = target.parentElement as HTMLElement
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
}
|
||||
if (!childFolderContainer) return
|
||||
|
||||
// Collapse folder container
|
||||
childFolderContainer.classList.toggle("open")
|
||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, !isCollapsed)
|
||||
|
||||
// Save folder state to localStorage
|
||||
const clickFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
|
||||
const fullFolderPath = clickFolderPath
|
||||
toggleCollapsedByPath(explorerState, fullFolderPath)
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(explorerState)
|
||||
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function setupExplorer() {
|
||||
// Set click handler for collapsing entire explorer
|
||||
const explorer = document.getElementById("explorer")
|
||||
if (!explorer) return
|
||||
|
||||
if (explorer.dataset.behavior === "collapse") {
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
for (const item of document.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>) {
|
||||
item.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
|
||||
// Convert to bool
|
||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||
const oldExplorerState: FolderState[] =
|
||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||
? JSON.parse(explorer.dataset.tree)
|
||||
: []
|
||||
currentExplorerState = []
|
||||
for (const { path, collapsed } of newExplorerState) {
|
||||
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
|
||||
}
|
||||
|
||||
if (explorer) {
|
||||
// Get config
|
||||
const collapseBehavior = explorer.dataset.behavior
|
||||
|
||||
// Add click handlers for all folders (click handler on folder "label")
|
||||
if (collapseBehavior === "collapse") {
|
||||
Array.prototype.forEach.call(
|
||||
document.getElementsByClassName("folder-button"),
|
||||
function (item) {
|
||||
item.removeEventListener("click", toggleFolder)
|
||||
item.addEventListener("click", toggleFolder)
|
||||
},
|
||||
)
|
||||
currentExplorerState.map((folderState) => {
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='${folderState.path}']`,
|
||||
) as MaybeHTMLElement
|
||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||
if (folderUl) {
|
||||
setFolderState(folderUl, folderState.collapsed)
|
||||
}
|
||||
|
||||
// Add click handler to main explorer
|
||||
explorer.removeEventListener("click", toggleExplorer)
|
||||
explorer.addEventListener("click", toggleExplorer)
|
||||
}
|
||||
|
||||
// Set up click handlers for each folder (click handler on folder "icon")
|
||||
Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
|
||||
item.removeEventListener("click", toggleFolder)
|
||||
item.addEventListener("click", toggleFolder)
|
||||
})
|
||||
|
||||
if (storageTree && useSavedFolderState) {
|
||||
// Get state from localStorage and set folder state
|
||||
explorerState = JSON.parse(storageTree)
|
||||
explorerState.map((folderUl) => {
|
||||
// grab <li> element for matching folder path
|
||||
const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
|
||||
|
||||
// Get corresponding content <ul> tag and set state
|
||||
if (folderLi) {
|
||||
const folderUL = folderLi.parentElement?.nextElementSibling
|
||||
if (folderUL) {
|
||||
setFolderState(folderUL as HTMLElement, folderUl.collapsed)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (explorer?.dataset.tree) {
|
||||
// If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
|
||||
explorerState = JSON.parse(explorer.dataset.tree)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", setupExplorer)
|
||||
document.addEventListener("nav", () => {
|
||||
setupExplorer()
|
||||
|
||||
observer.disconnect()
|
||||
|
||||
// select pseudo element at end of list
|
||||
@ -142,11 +116,7 @@ document.addEventListener("nav", () => {
|
||||
* @param collapsed if folder should be set to collapsed or not
|
||||
*/
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
if (collapsed) {
|
||||
folderElement?.classList.remove("open")
|
||||
} else {
|
||||
folderElement?.classList.add("open")
|
||||
}
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -319,12 +319,12 @@ function renderGlobalGraph() {
|
||||
registerEscapeHandler(container, hideGlobalGraph)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: unknown) => {
|
||||
const slug = (e as CustomEventMap["nav"]).detail.url
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
addToVisited(slug)
|
||||
await renderGraph("graph-container", slug)
|
||||
|
||||
const containerIcon = document.getElementById("global-graph-icon")
|
||||
containerIcon?.removeEventListener("click", renderGlobalGraph)
|
||||
containerIcon?.addEventListener("click", renderGlobalGraph)
|
||||
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
|
||||
})
|
||||
|
@ -76,7 +76,7 @@ async function mouseEnterHandler(
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
|
||||
for (const link of links) {
|
||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
|
||||
}
|
||||
})
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
|
||||
import FlexSearch from "flexsearch"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, resolveRelative } from "../../util/path"
|
||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
@ -11,23 +11,53 @@ interface Item {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
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"
|
||||
let currentSearchTerm: string = ""
|
||||
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||
let index = new FlexSearch.Document<Item>({
|
||||
charset: "latin:extra",
|
||||
encode: encoder,
|
||||
document: {
|
||||
id: "id",
|
||||
index: [
|
||||
{
|
||||
field: "title",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
tokenize: "forward",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const p = new DOMParser()
|
||||
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||
const contextWindowWords = 30
|
||||
const numSearchResults = 5
|
||||
const numTagResults = 3
|
||||
const numSearchResults = 8
|
||||
const numTagResults = 5
|
||||
|
||||
const tokenizeTerm = (term: string) => {
|
||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||
const tokenLen = tokens.length
|
||||
if (tokenLen > 1) {
|
||||
for (let i = 1; i < tokenLen; i++) {
|
||||
tokens.push(tokens.slice(0, i + 1).join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
|
||||
}
|
||||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
// try to highlight longest tokens first
|
||||
const tokenizedTerms = searchTerm
|
||||
.split(/\s+/)
|
||||
.filter((t) => t !== "")
|
||||
.sort((a, b) => b.length - a.length)
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
||||
|
||||
let startIndex = 0
|
||||
@ -35,12 +65,12 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
if (trim) {
|
||||
const includesCheck = (tok: string) =>
|
||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||
const occurencesIndices = tokenizedText.map(includesCheck)
|
||||
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||
|
||||
let bestSum = 0
|
||||
let bestIndex = 0
|
||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||
const window = occurencesIndices.slice(i, i + contextWindowWords)
|
||||
const window = occurrencesIndices.slice(i, i + contextWindowWords)
|
||||
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||
if (windowSum >= bestSum) {
|
||||
bestSum = windowSum
|
||||
@ -71,20 +101,76 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
}`
|
||||
}
|
||||
|
||||
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
|
||||
document.addEventListener("nav", async (e: unknown) => {
|
||||
const currentSlug = (e as CustomEventMap["nav"]).detail.url
|
||||
function highlightHTML(searchTerm: string, el: HTMLElement) {
|
||||
const p = new DOMParser()
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
const html = p.parseFromString(el.innerHTML, "text/html")
|
||||
|
||||
const createHighlightSpan = (text: string) => {
|
||||
const span = document.createElement("span")
|
||||
span.className = "highlight"
|
||||
span.textContent = text
|
||||
return span
|
||||
}
|
||||
|
||||
const highlightTextNodes = (node: Node, term: string) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const nodeText = node.nodeValue ?? ""
|
||||
const regex = new RegExp(term.toLowerCase(), "gi")
|
||||
const matches = nodeText.match(regex)
|
||||
if (!matches || matches.length === 0) return
|
||||
const spanContainer = document.createElement("span")
|
||||
let lastIndex = 0
|
||||
for (const match of matches) {
|
||||
const matchIndex = nodeText.indexOf(match, lastIndex)
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
|
||||
spanContainer.appendChild(createHighlightSpan(match))
|
||||
lastIndex = matchIndex + match.length
|
||||
}
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
|
||||
node.parentNode?.replaceChild(spanContainer, node)
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if ((node as HTMLElement).classList.contains("highlight")) return
|
||||
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
|
||||
}
|
||||
}
|
||||
|
||||
for (const term of tokenizedTerms) {
|
||||
highlightTextNodes(html.body, term)
|
||||
}
|
||||
|
||||
return html.body
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const data = await fetchData
|
||||
const container = document.getElementById("search-container")
|
||||
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||
const searchIcon = document.getElementById("search-icon")
|
||||
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
||||
const results = document.getElementById("results-container")
|
||||
const resultCards = document.getElementsByClassName("result-card")
|
||||
const searchLayout = document.getElementById("search-layout")
|
||||
const idDataMap = Object.keys(data) as FullSlug[]
|
||||
|
||||
const appendLayout = (el: HTMLElement) => {
|
||||
if (searchLayout?.querySelector(`#${el.id}`) === null) {
|
||||
searchLayout?.appendChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
const enablePreview = searchLayout?.dataset?.preview === "true"
|
||||
let preview: HTMLDivElement | undefined = undefined
|
||||
let previewInner: HTMLDivElement | undefined = undefined
|
||||
const results = document.createElement("div")
|
||||
results.id = "results-container"
|
||||
appendLayout(results)
|
||||
|
||||
if (enablePreview) {
|
||||
preview = document.createElement("div")
|
||||
preview.id = "preview-container"
|
||||
appendLayout(preview)
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
container?.classList.remove("active")
|
||||
if (searchBar) {
|
||||
@ -96,6 +182,12 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
if (results) {
|
||||
removeAllChildren(results)
|
||||
}
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
}
|
||||
if (searchLayout) {
|
||||
searchLayout.classList.remove("display-results")
|
||||
}
|
||||
|
||||
searchType = "basic" // reset search type after closing
|
||||
}
|
||||
@ -109,11 +201,14 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
searchBar?.focus()
|
||||
}
|
||||
|
||||
function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
let currentHover: HTMLInputElement | null = null
|
||||
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container?.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("basic")
|
||||
return
|
||||
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
// Hotkey to open tag search
|
||||
e.preventDefault()
|
||||
@ -122,156 +217,205 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
|
||||
// add "#" prefix for tag search
|
||||
if (searchBar) searchBar.value = "#"
|
||||
} else if (e.key === "Enter") {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHover) {
|
||||
currentHover.classList.remove("focus")
|
||||
}
|
||||
|
||||
// If search is active, then we will render the first result and display accordingly
|
||||
if (!container?.classList.contains("active")) return
|
||||
if (e.key === "Enter") {
|
||||
// If result has focus, navigate to that one, otherwise pick first result
|
||||
if (results?.contains(document.activeElement)) {
|
||||
const active = document.activeElement as HTMLInputElement
|
||||
if (active.classList.contains("no-match")) return
|
||||
await displayPreview(active)
|
||||
active.click()
|
||||
} else {
|
||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||
anchor?.click()
|
||||
if (!anchor || anchor?.classList.contains("no-match")) return
|
||||
await displayPreview(anchor)
|
||||
anchor.click()
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
// When first pressing ArrowDown, results wont contain the active element, so focus first element
|
||||
if (!results?.contains(document.activeElement)) {
|
||||
const firstResult = resultCards[0] as HTMLInputElement | null
|
||||
firstResult?.focus()
|
||||
} else {
|
||||
// If an element in results-container already has focus, focus next one
|
||||
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
|
||||
nextResult?.focus()
|
||||
}
|
||||
} else if (e.key === "ArrowUp") {
|
||||
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault()
|
||||
if (results?.contains(document.activeElement)) {
|
||||
// If an element in results-container already has focus, focus previous one
|
||||
const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
|
||||
const currentResult = currentHover
|
||||
? currentHover
|
||||
: (document.activeElement as HTMLInputElement | null)
|
||||
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
|
||||
currentResult?.classList.remove("focus")
|
||||
prevResult?.focus()
|
||||
if (prevResult) currentHover = prevResult
|
||||
await displayPreview(prevResult)
|
||||
}
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
// The results should already been focused, so we need to find the next one.
|
||||
// The activeElement is the search bar, so we need to find the first result and focus it.
|
||||
if (document.activeElement === searchBar || currentHover !== null) {
|
||||
const firstResult = currentHover
|
||||
? currentHover
|
||||
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
|
||||
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
|
||||
firstResult?.classList.remove("focus")
|
||||
secondResult?.focus()
|
||||
if (secondResult) currentHover = secondResult
|
||||
await displayPreview(secondResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 slug = idDataMap[id]
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||
// 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),
|
||||
content: highlight(term, data[slug].content ?? "", true),
|
||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!tags || searchType !== "tags") {
|
||||
return []
|
||||
}
|
||||
|
||||
return tags
|
||||
.map((tag) => {
|
||||
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||
return `<li><p class="match-tag">#${tag}</p></li>`
|
||||
} else {
|
||||
return `<li><p>#${tag}</p></li>`
|
||||
}
|
||||
})
|
||||
.slice(0, numTagResults)
|
||||
}
|
||||
|
||||
function resolveUrl(slug: FullSlug): URL {
|
||||
return new URL(resolveRelative(currentSlug, slug), location.toString())
|
||||
}
|
||||
|
||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
||||
const button = document.createElement("button")
|
||||
button.classList.add("result-card")
|
||||
button.id = slug
|
||||
button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
||||
button.addEventListener("click", () => {
|
||||
const targ = resolveRelative(currentSlug, slug)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
||||
const itemTile = document.createElement("a")
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = resolveUrl(slug).toString()
|
||||
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${
|
||||
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`
|
||||
}`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
})
|
||||
return button
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
}
|
||||
|
||||
async function onMouseEnter(ev: MouseEvent) {
|
||||
if (!ev.target) return
|
||||
const target = ev.target as HTMLInputElement
|
||||
await displayPreview(target)
|
||||
}
|
||||
|
||||
itemTile.addEventListener("mouseenter", onMouseEnter)
|
||||
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
|
||||
itemTile.addEventListener("click", handler)
|
||||
window.addCleanup(() => itemTile.removeEventListener("click", handler))
|
||||
|
||||
return itemTile
|
||||
}
|
||||
|
||||
function displayResults(finalResults: Item[]) {
|
||||
async function displayResults(finalResults: Item[]) {
|
||||
if (!results) return
|
||||
|
||||
removeAllChildren(results)
|
||||
if (finalResults.length === 0) {
|
||||
results.innerHTML = `<button class="result-card">
|
||||
<h3>No results.</h3>
|
||||
<p>Try another search term?</p>
|
||||
</button>`
|
||||
results.innerHTML = `<a class="result-card no-match">
|
||||
<h3>No results.</h3>
|
||||
<p>Try another search term?</p>
|
||||
</a>`
|
||||
} else {
|
||||
results.append(...finalResults.map(resultToHTML))
|
||||
}
|
||||
|
||||
if (finalResults.length === 0 && preview) {
|
||||
// no results, clear previous preview
|
||||
removeAllChildren(preview)
|
||||
} else {
|
||||
// focus on first result, then also dispatch preview immediately
|
||||
const firstChild = results.firstElementChild as HTMLElement
|
||||
firstChild.classList.add("focus")
|
||||
currentHover = firstChild as HTMLInputElement
|
||||
await displayPreview(firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||
if (fetchContentCache.has(slug)) {
|
||||
return fetchContentCache.get(slug) as Element[]
|
||||
}
|
||||
|
||||
const targetUrl = resolveUrl(slug).toString()
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
if (contents === undefined) {
|
||||
throw new Error(`Could not fetch ${targetUrl}`)
|
||||
}
|
||||
const html = p.parseFromString(contents ?? "", "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
return [...html.getElementsByClassName("popover-hint")]
|
||||
})
|
||||
|
||||
fetchContentCache.set(slug, contents)
|
||||
return contents
|
||||
}
|
||||
|
||||
async function displayPreview(el: HTMLElement | null) {
|
||||
if (!searchLayout || !enablePreview || !el || !preview) return
|
||||
const slug = el.id as FullSlug
|
||||
const innerDiv = await fetchContent(slug).then((contents) =>
|
||||
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
|
||||
)
|
||||
previewInner = document.createElement("div")
|
||||
previewInner.classList.add("preview-inner")
|
||||
previewInner.append(...innerDiv)
|
||||
preview.replaceChildren(previewInner)
|
||||
|
||||
// scroll to longest
|
||||
const highlights = [...preview.querySelectorAll(".highlight")].sort(
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
)
|
||||
highlights[0]?.scrollIntoView({ block: "start" })
|
||||
}
|
||||
|
||||
async function onType(e: HTMLElementEventMap["input"]) {
|
||||
let term = (e.target as HTMLInputElement).value
|
||||
let searchResults: SimpleDocumentSearchResultSetUnit[]
|
||||
if (!searchLayout || !index) return
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
|
||||
|
||||
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"],
|
||||
})) ?? []
|
||||
}
|
||||
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
|
||||
if (searchType === "tags") {
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm.substring(1),
|
||||
limit: numSearchResults,
|
||||
index: ["tags"],
|
||||
})
|
||||
} else if (searchType === "basic") {
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["title", "content"],
|
||||
})
|
||||
}
|
||||
|
||||
const getByField = (field: string): number[] => {
|
||||
@ -285,51 +429,19 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
...getByField("content"),
|
||||
...getByField("tags"),
|
||||
])
|
||||
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
|
||||
displayResults(finalResults)
|
||||
}
|
||||
|
||||
if (prevShortcutHandler) {
|
||||
document.removeEventListener("keydown", prevShortcutHandler)
|
||||
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
|
||||
await displayResults(finalResults)
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
prevShortcutHandler = shortcutHandler
|
||||
searchIcon?.removeEventListener("click", () => showSearch("basic"))
|
||||
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||
searchIcon?.addEventListener("click", () => showSearch("basic"))
|
||||
searchBar?.removeEventListener("input", onType)
|
||||
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
|
||||
searchBar?.addEventListener("input", onType)
|
||||
window.addCleanup(() => searchBar?.removeEventListener("input", onType))
|
||||
|
||||
// setup index if it hasn't been already
|
||||
if (!index) {
|
||||
index = new Document({
|
||||
charset: "latin:extra",
|
||||
optimize: true,
|
||||
encode: encoder,
|
||||
document: {
|
||||
id: "id",
|
||||
index: [
|
||||
{
|
||||
field: "title",
|
||||
tokenize: "reverse",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
tokenize: "reverse",
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
tokenize: "reverse",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
fillDocument(index, data)
|
||||
}
|
||||
|
||||
// register handlers
|
||||
registerEscapeHandler(container, hideSearch)
|
||||
await fillDocument(data)
|
||||
})
|
||||
|
||||
/**
|
||||
@ -337,16 +449,20 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
* @param index index to fill
|
||||
* @param data data to fill index with
|
||||
*/
|
||||
async function fillDocument(index: Document<Item, false>, data: any) {
|
||||
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
|
||||
let id = 0
|
||||
const promises: Array<Promise<unknown>> = []
|
||||
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++
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: fileData.content,
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return await Promise.all(promises)
|
||||
}
|
||||
|
@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) {
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||
|
||||
let p: DOMParser
|
||||
async function navigate(url: URL, isBack: boolean = false) {
|
||||
p = p || new DOMParser()
|
||||
@ -57,6 +60,10 @@ async function navigate(url: URL, isBack: boolean = false) {
|
||||
|
||||
if (!contents) return
|
||||
|
||||
// cleanup old
|
||||
cleanupFns.forEach((fn) => fn())
|
||||
cleanupFns.clear()
|
||||
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
normalizeRelativeURLs(html, url)
|
||||
|
||||
|
@ -16,7 +16,8 @@ const observer = new IntersectionObserver((entries) => {
|
||||
|
||||
function toggleToc(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
const content = this.nextElementSibling as HTMLElement
|
||||
const content = this.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
content.classList.toggle("collapsed")
|
||||
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||
}
|
||||
@ -25,10 +26,11 @@ function setupToc() {
|
||||
const toc = document.getElementById("toc")
|
||||
if (toc) {
|
||||
const collapsed = toc.classList.contains("collapsed")
|
||||
const content = toc.nextElementSibling as HTMLElement
|
||||
const content = toc.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
|
||||
toc.removeEventListener("click", toggleToc)
|
||||
toc.addEventListener("click", toggleToc)
|
||||
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,10 +12,10 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
|
||||
cb()
|
||||
}
|
||||
|
||||
outsideContainer?.removeEventListener("click", click)
|
||||
outsideContainer?.addEventListener("click", click)
|
||||
document.removeEventListener("keydown", esc)
|
||||
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||
document.addEventListener("keydown", esc)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||
}
|
||||
|
||||
export function removeAllChildren(node: HTMLElement) {
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
button#explorer {
|
||||
all: unset;
|
||||
background-color: transparent;
|
||||
@ -85,7 +87,7 @@ svg {
|
||||
color: var(--secondary);
|
||||
font-family: var(--headerFont);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: $boldWeight;
|
||||
line-height: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
@ -110,7 +112,7 @@ svg {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
font-weight: 600;
|
||||
font-weight: $boldWeight;
|
||||
margin: 0;
|
||||
line-height: 1.5rem;
|
||||
pointer-events: none;
|
||||
@ -126,7 +128,7 @@ svg {
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
div:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
|
@ -26,6 +26,7 @@
|
||||
max-height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
font-style: initial;
|
||||
line-height: normal;
|
||||
font-size: initial;
|
||||
font-family: var(--bodyFont);
|
||||
|
@ -54,18 +54,14 @@
|
||||
}
|
||||
|
||||
& > #search-space {
|
||||
width: 50%;
|
||||
margin-top: 15vh;
|
||||
width: 65%;
|
||||
margin-top: 12vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media all and (max-width: $fullPageWidth) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border-radius: 7px;
|
||||
background: var(--light);
|
||||
box-shadow:
|
||||
0 14px 50px rgba(27, 33, 48, 0.12),
|
||||
@ -86,90 +82,136 @@
|
||||
}
|
||||
}
|
||||
|
||||
& > #results-container {
|
||||
& .result-card {
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border: 1px solid var(--lightgray);
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
& > #search-layout {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--lightgray);
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
// normalize button props
|
||||
&.display-results {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&[data-preview] > #results-container {
|
||||
flex: 0 0 min(30%, 450px);
|
||||
}
|
||||
|
||||
@media all and (min-width: $tabletBreakpoint) {
|
||||
&[data-preview] {
|
||||
& .result-card > p.preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
&:first-child {
|
||||
border-right: 1px solid var(--lightgray);
|
||||
border-top-right-radius: unset;
|
||||
border-bottom-right-radius: unset;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: unset;
|
||||
border-bottom-left-radius: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
height: calc(75vh - 12vh);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media all and (max-width: $tabletBreakpoint) {
|
||||
& > #preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[data-preview] > #results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .highlight {
|
||||
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
|
||||
border-radius: 5px;
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
& > #preview-container {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
background: var(--light);
|
||||
outline: none;
|
||||
color: var(--dark);
|
||||
line-height: 1.5em;
|
||||
font-weight: $normalWeight;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
|
||||
& .highlight {
|
||||
color: var(--secondary);
|
||||
font-weight: 700;
|
||||
& .preview-inner {
|
||||
margin: 0 auto;
|
||||
width: min($pageWidth, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
& > #results-container {
|
||||
overflow-y: auto;
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
& .result-card {
|
||||
overflow: hidden;
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border-bottom: 1px solid var(--lightgray);
|
||||
}
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
& > h3 {
|
||||
// normalize card props
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-weight: inherit;
|
||||
|
||||
& > ul > li {
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
& > 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;
|
||||
}
|
||||
& > h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > 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.tags {
|
||||
margin-top: 0.45rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& > ul > li > .match-tag {
|
||||
color: var(--tertiary);
|
||||
font-weight: bold;
|
||||
opacity: 1;
|
||||
}
|
||||
& > ul > li > p {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
font-weight: $boldWeight;
|
||||
color: var(--secondary);
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0;
|
||||
&.match-tag {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
quartz/i18n/index.ts
Normal file
11
quartz/i18n/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Translation } from "./locales/definition"
|
||||
import en from "./locales/en-US"
|
||||
import fr from "./locales/fr-FR"
|
||||
|
||||
export const TRANSLATIONS = {
|
||||
"en-US": en,
|
||||
"fr-FR": fr,
|
||||
} as const
|
||||
|
||||
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale]
|
||||
export type ValidLocale = keyof typeof TRANSLATIONS
|
63
quartz/i18n/locales/definition.ts
Normal file
63
quartz/i18n/locales/definition.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export interface Translation {
|
||||
propertyDefaults: {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
components: {
|
||||
backlinks: {
|
||||
title: string
|
||||
noBacklinksFound: string
|
||||
}
|
||||
themeToggle: {
|
||||
lightMode: string
|
||||
darkMode: string
|
||||
}
|
||||
explorer: {
|
||||
title: string
|
||||
}
|
||||
footer: {
|
||||
createdWith: string
|
||||
}
|
||||
graph: {
|
||||
title: string
|
||||
}
|
||||
recentNotes: {
|
||||
title: string
|
||||
seeRemainingMore: (variables: { remaining: number }) => string
|
||||
}
|
||||
transcludes: {
|
||||
transcludeOf: (variables: { targetSlug: FullSlug }) => string
|
||||
linkToOriginal: string
|
||||
}
|
||||
search: {
|
||||
title: string
|
||||
searchBarPlaceholder: string
|
||||
}
|
||||
tableOfContents: {
|
||||
title: string
|
||||
}
|
||||
}
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: string
|
||||
lastFewNotes: (variables: { count: number }) => string
|
||||
}
|
||||
error: {
|
||||
title: string
|
||||
notFound: string
|
||||
}
|
||||
folderContent: {
|
||||
folder: string
|
||||
itemsUnderFolder: (variables: { count: number }) => string
|
||||
}
|
||||
tagContent: {
|
||||
tag: string
|
||||
tagIndex: string
|
||||
itemsUnderTag: (variables: { count: number }) => string
|
||||
showingFirst: (variables: { count: number }) => string
|
||||
totalTags: (variables: { count: number }) => string
|
||||
}
|
||||
}
|
||||
}
|
65
quartz/i18n/locales/en-US.ts
Normal file
65
quartz/i18n/locales/en-US.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Untitled",
|
||||
description: "No description provided",
|
||||
},
|
||||
components: {
|
||||
backlinks: {
|
||||
title: "Backlinks",
|
||||
noBacklinksFound: "No backlinks found",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Light mode",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorer",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Created with",
|
||||
},
|
||||
graph: {
|
||||
title: "Graph View",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Recent Notes",
|
||||
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
|
||||
linkToOriginal: "Link to original",
|
||||
},
|
||||
search: {
|
||||
title: "Search",
|
||||
searchBarPlaceholder: "Search for something",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Table of Contents",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Recent notes",
|
||||
lastFewNotes: ({ count }) => `Last ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "Not Found",
|
||||
notFound: "Either this page is private or doesn't exist.",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Folder",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 item under this folder" : `${count} items under this folder.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Tag",
|
||||
tagIndex: "Tag Index",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 item with this tag" : `${count} items with this tag.`,
|
||||
showingFirst: ({ count }) => `Showing first ${count} tags.`,
|
||||
totalTags: ({ count }) => `Found ${count} total tags.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
65
quartz/i18n/locales/fr-FR.ts
Normal file
65
quartz/i18n/locales/fr-FR.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Sans titre",
|
||||
description: "Aucune description fournie",
|
||||
},
|
||||
components: {
|
||||
backlinks: {
|
||||
title: "Liens retour",
|
||||
noBacklinksFound: "Aucun lien retour trouvé",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Mode clair",
|
||||
darkMode: "Mode sombre",
|
||||
},
|
||||
explorer: {
|
||||
title: "Explorateur",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Créé avec",
|
||||
},
|
||||
graph: {
|
||||
title: "Vue Graphique",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Notes Récentes",
|
||||
seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
|
||||
linkToOriginal: "Lien vers l'original",
|
||||
},
|
||||
search: {
|
||||
title: "Recherche",
|
||||
searchBarPlaceholder: "Rechercher quelque chose",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Table des Matières",
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Notes récentes",
|
||||
lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
|
||||
},
|
||||
error: {
|
||||
title: "Pas trouvé",
|
||||
notFound: "Cette page est soit privée, soit elle n'existe pas.",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Dossier",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 élément sous ce dossier" : `${count} éléments sous ce dossier.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Étiquette",
|
||||
tagIndex: "Index des étiquettes",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 élément avec cette étiquette" : `${count} éléments avec cette étiquette.`,
|
||||
showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
|
||||
totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
@ -7,6 +7,8 @@ import { FilePath, FullSlug } from "../../util/path"
|
||||
import { sharedPageComponents } from "../../../quartz.layout"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
const opts: FullPageLayout = {
|
||||
@ -25,18 +27,19 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Body, pageBody, Footer]
|
||||
},
|
||||
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, _content, resources): Promise<FilePath[]> {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const slug = "404" as FullSlug
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const externalResources = pageResources(path, resources)
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: "Not Found",
|
||||
description: "Not Found",
|
||||
frontmatter: { title: "Not Found", tags: [] },
|
||||
text: notFound,
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: vfile.data,
|
||||
@ -48,8 +51,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
}
|
||||
|
||||
return [
|
||||
await emit({
|
||||
content: renderPage(slug, componentData, opts, externalResources),
|
||||
await write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, opts, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
}),
|
||||
|
@ -1,24 +1,21 @@
|
||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import path from "path"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
name: "AliasRedirects",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, _resources): Promise<FilePath[]> {
|
||||
const { argv } = ctx
|
||||
const fps: FilePath[] = []
|
||||
|
||||
for (const [_tree, file] of content) {
|
||||
const ogSlug = simplifySlug(file.data.slug!)
|
||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||
|
||||
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
|
||||
if (typeof aliases === "string") {
|
||||
aliases = [aliases]
|
||||
}
|
||||
|
||||
const aliases = file.data.frontmatter?.aliases ?? []
|
||||
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||
const permalink = file.data.frontmatter?.permalink
|
||||
if (typeof permalink === "string") {
|
||||
@ -32,7 +29,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
}
|
||||
|
||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||
const fp = await emit({
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-us">
|
||||
|
@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
// glob all non MD/MDX/HTML files in content folder and copy it over
|
||||
const assetsPath = argv.output
|
||||
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||
|
@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
if (!cfg.configuration.baseUrl) {
|
||||
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
||||
return []
|
||||
|
@ -13,6 +13,7 @@ import { QuartzComponent } from "../../components/types"
|
||||
import { googleFontHref, joinStyles } from "../../util/theme"
|
||||
import { Features, transform } from "lightningcss"
|
||||
import { transform as transpile } from "esbuild"
|
||||
import { write } from "./helpers"
|
||||
|
||||
type ComponentResources = {
|
||||
css: string[]
|
||||
@ -93,7 +94,6 @@ function addGlobalPageResources(
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag("js", new Date());
|
||||
gtag("config", "${tagId}", { send_page_view: false });
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
gtag("event", "page_view", {
|
||||
page_title: document.title,
|
||||
@ -118,10 +118,10 @@ function addGlobalPageResources(
|
||||
} else if (cfg.analytics?.provider === "umami") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const umamiScript = document.createElement("script")
|
||||
umamiScript.src = "https://analytics.umami.is/script.js"
|
||||
umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js"
|
||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||
umamiScript.async = true
|
||||
|
||||
|
||||
document.head.appendChild(umamiScript)
|
||||
`)
|
||||
}
|
||||
@ -130,9 +130,11 @@ function addGlobalPageResources(
|
||||
componentResources.afterDOMLoaded.push(spaRouterScript)
|
||||
} else {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
window.spaNavigate = (url, _) => window.location.assign(url)
|
||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||
document.dispatchEvent(event)`)
|
||||
window.spaNavigate = (url, _) => window.location.assign(url)
|
||||
window.addCleanup = () => {}
|
||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||
document.dispatchEvent(event)
|
||||
`)
|
||||
}
|
||||
|
||||
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
|
||||
@ -146,9 +148,9 @@ function addGlobalPageResources(
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
script: `
|
||||
const socket = new WebSocket('${wsUrl}')
|
||||
socket.addEventListener('message', () => document.location.reload())
|
||||
`,
|
||||
const socket = new WebSocket('${wsUrl}')
|
||||
socket.addEventListener('message', () => document.location.reload())
|
||||
`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -168,7 +170,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, _content, resources): Promise<FilePath[]> {
|
||||
// component specific scripts and styles
|
||||
const componentResources = getComponentResources(ctx)
|
||||
// important that this goes *after* component scripts
|
||||
@ -190,7 +192,8 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
])
|
||||
|
||||
const fps = await Promise.all([
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".css",
|
||||
content: transform({
|
||||
@ -207,12 +210,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
include: Features.MediaQueries,
|
||||
}).code.toString(),
|
||||
}),
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "prescript" as FullSlug,
|
||||
ext: ".js",
|
||||
content: prescript,
|
||||
}),
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "postscript" as FullSlug,
|
||||
ext: ".js",
|
||||
content: postscript,
|
||||
|
@ -5,7 +5,8 @@ import { escapeHTML } from "../../util/escape"
|
||||
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import path from "path"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||
export type ContentDetails = {
|
||||
@ -38,7 +39,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
|
||||
</url>`
|
||||
const urls = Array.from(idx)
|
||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||
@ -48,12 +49,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
|
||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const root = `https://${base}`
|
||||
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||
<title>${escapeHTML(content.title)}</title>
|
||||
<link>${joinSegments(root, encodeURI(slug))}</link>
|
||||
<guid>${joinSegments(root, encodeURI(slug))}</guid>
|
||||
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||
<description>${content.richContent ?? content.description}</description>
|
||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||
</item>`
|
||||
@ -78,8 +78,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<link>${root}</link>
|
||||
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
||||
<link>https://${base}</link>
|
||||
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
|
||||
cfg.pageTitle,
|
||||
)}</description>
|
||||
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||
@ -92,7 +92,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
opts = { ...defaultOptions, ...opts }
|
||||
return {
|
||||
name: "ContentIndex",
|
||||
async emit(ctx, content, _resources, emit) {
|
||||
async emit(ctx, content, _resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const emitted: FilePath[] = []
|
||||
const linkIndex: ContentIndex = new Map()
|
||||
@ -116,7 +116,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
|
||||
if (opts?.enableSiteMap) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: generateSiteMap(cfg, linkIndex),
|
||||
slug: "sitemap" as FullSlug,
|
||||
ext: ".xml",
|
||||
@ -126,7 +127,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
|
||||
if (opts?.enableRSS) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".xml",
|
||||
@ -134,7 +136,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
)
|
||||
}
|
||||
|
||||
const fp = path.join("static", "contentIndex") as FullSlug
|
||||
const fp = joinSegments("static", "contentIndex") as FullSlug
|
||||
const simplifiedIndex = Object.fromEntries(
|
||||
Array.from(linkIndex).map(([slug, content]) => {
|
||||
// remove description and from content index as nothing downstream
|
||||
@ -147,7 +149,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
)
|
||||
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: JSON.stringify(simplifiedIndex),
|
||||
slug: fp,
|
||||
ext: ".json",
|
||||
|
@ -8,6 +8,7 @@ import { FilePath, pathToRoot } from "../../util/path"
|
||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { Content } from "../../components"
|
||||
import chalk from "chalk"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
@ -48,8 +49,9 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
|
@ -17,8 +17,10 @@ import {
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { FolderContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
...defaultListPageLayout,
|
||||
@ -35,7 +37,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
@ -56,7 +58,10 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
folder,
|
||||
defaultProcessedContent({
|
||||
slug: joinSegments(folder, "index") as FullSlug,
|
||||
frontmatter: { title: `Folder: ${folder}`, tags: [] },
|
||||
frontmatter: {
|
||||
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
|
||||
tags: [],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
@ -81,8 +86,9 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
|
19
quartz/plugins/emitters/helpers.ts
Normal file
19
quartz/plugins/emitters/helpers.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { FilePath, FullSlug, joinSegments } from "../../util/path"
|
||||
|
||||
type WriteOptions = {
|
||||
ctx: BuildCtx
|
||||
slug: FullSlug
|
||||
ext: `.${string}` | ""
|
||||
content: string
|
||||
}
|
||||
|
||||
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
|
||||
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
|
||||
const dir = path.dirname(pathToPage)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(pathToPage, content)
|
||||
return pathToPage
|
||||
}
|
@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
const staticPath = joinSegments(QUARTZ, "static")
|
||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
|
||||
|
@ -14,8 +14,10 @@ import {
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { TagContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
...defaultListPageLayout,
|
||||
@ -32,7 +34,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
@ -46,7 +48,10 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
|
||||
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||
[...tags].map((tag) => {
|
||||
const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
|
||||
const title =
|
||||
tag === "index"
|
||||
? i18n(cfg.locale).pages.tagContent.tagIndex
|
||||
: `${i18n(cfg.locale).pages.tagContent.tag}: #${tag}`
|
||||
return [
|
||||
tag,
|
||||
defaultProcessedContent({
|
||||
@ -80,8 +85,9 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug: file.data.slug!,
|
||||
ext: ".html",
|
||||
|
@ -3,7 +3,6 @@ import { QuartzFilterPlugin } from "../types"
|
||||
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
||||
name: "ExplicitPublish",
|
||||
shouldPublish(_ctx, [_tree, vfile]) {
|
||||
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
|
||||
return publishFlag
|
||||
return vfile.data?.frontmatter?.publish ?? false
|
||||
},
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ import yaml from "js-yaml"
|
||||
import toml from "toml"
|
||||
import { slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export interface Options {
|
||||
delims: string | string[]
|
||||
@ -18,13 +19,34 @@ const defaultOptions: Options = {
|
||||
oneLineTagDelim: ",",
|
||||
}
|
||||
|
||||
function coalesceAliases(data: { [key: string]: any }, aliases: string[]) {
|
||||
for (const alias of aliases) {
|
||||
if (data[alias] !== undefined && data[alias] !== null) return data[alias]
|
||||
}
|
||||
}
|
||||
|
||||
function coerceToArray(input: string | string[]): string[] | undefined {
|
||||
if (input === undefined || input === null) return undefined
|
||||
|
||||
// coerce to array
|
||||
if (!Array.isArray(input)) {
|
||||
input = input
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((tag: string) => tag.trim())
|
||||
}
|
||||
|
||||
// remove all non-strings
|
||||
return input
|
||||
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
|
||||
.map((tag: string | number) => tag.toString())
|
||||
}
|
||||
|
||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "FrontMatter",
|
||||
markdownPlugins() {
|
||||
const { oneLineTagDelim } = opts
|
||||
|
||||
markdownPlugins({ cfg }) {
|
||||
return [
|
||||
[remarkFrontmatter, ["yaml", "toml"]],
|
||||
() => {
|
||||
@ -37,35 +59,19 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
},
|
||||
})
|
||||
|
||||
// tag is an alias for tags
|
||||
if (data.tag) {
|
||||
data.tags = data.tag
|
||||
}
|
||||
|
||||
// coerce title to string
|
||||
if (data.title) {
|
||||
data.title = data.title.toString()
|
||||
} else if (data.title === null || data.title === undefined) {
|
||||
data.title = file.stem ?? "Untitled"
|
||||
data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
|
||||
}
|
||||
|
||||
if (data.tags) {
|
||||
// coerce to array
|
||||
if (!Array.isArray(data.tags)) {
|
||||
data.tags = data.tags
|
||||
.toString()
|
||||
.split(oneLineTagDelim)
|
||||
.map((tag: string) => tag.trim())
|
||||
}
|
||||
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
|
||||
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
|
||||
|
||||
// remove all non-string tags
|
||||
data.tags = data.tags
|
||||
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
|
||||
.map((tag: string | number) => tag.toString())
|
||||
}
|
||||
|
||||
// slug them all!!
|
||||
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
|
||||
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
||||
if (aliases) data.aliases = aliases
|
||||
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
||||
if (cssclasses) data.cssclasses = cssclasses
|
||||
|
||||
// fill in frontmatter
|
||||
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||
@ -78,9 +84,16 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
frontmatter: { [key: string]: any } & {
|
||||
frontmatter: { [key: string]: unknown } & {
|
||||
title: string
|
||||
tags: string[]
|
||||
}
|
||||
} & Partial<{
|
||||
tags: string[]
|
||||
aliases: string[]
|
||||
description: string
|
||||
publish: boolean
|
||||
draft: boolean
|
||||
enableToc: string
|
||||
cssclasses: string[]
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
@ -37,8 +37,36 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
|
||||
"data-no-popover": true,
|
||||
},
|
||||
content: {
|
||||
type: "text",
|
||||
value: " §",
|
||||
type: "element",
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -43,24 +43,36 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
||||
let published: MaybeDate = undefined
|
||||
|
||||
const fp = file.data.filePath!
|
||||
const fullFp = path.posix.join(file.cwd, fp)
|
||||
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
|
||||
for (const source of opts.priority) {
|
||||
if (source === "filesystem") {
|
||||
const st = await fs.promises.stat(fullFp)
|
||||
created ||= st.birthtimeMs
|
||||
modified ||= st.mtimeMs
|
||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||
created ||= file.data.frontmatter.date
|
||||
modified ||= file.data.frontmatter.lastmod
|
||||
modified ||= file.data.frontmatter.updated
|
||||
modified ||= file.data.frontmatter["last-modified"]
|
||||
published ||= file.data.frontmatter.publishDate
|
||||
created ||= file.data.frontmatter.date as MaybeDate
|
||||
modified ||= file.data.frontmatter.lastmod as MaybeDate
|
||||
modified ||= file.data.frontmatter.updated as MaybeDate
|
||||
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
|
||||
published ||= file.data.frontmatter.publishDate as MaybeDate
|
||||
} else if (source === "git") {
|
||||
if (!repo) {
|
||||
repo = new Repository(file.cwd)
|
||||
// Get a reference to the main git repo.
|
||||
// It's either the same as the workdir,
|
||||
// or 1+ level higher in case of a submodule/subtree setup
|
||||
repo = Repository.discover(file.cwd)
|
||||
}
|
||||
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||
try {
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: ${file.data
|
||||
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
|
||||
return {
|
||||
css: [
|
||||
// base css
|
||||
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
|
||||
"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css",
|
||||
],
|
||||
js: [
|
||||
{
|
||||
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
|
||||
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
|
||||
src: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/copy-tex.min.js",
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
|
@ -21,6 +21,7 @@ interface Options {
|
||||
prettyLinks: boolean
|
||||
openLinksInNewTab: boolean
|
||||
lazyLoad: boolean
|
||||
externalLinkIcon: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
@ -28,6 +29,7 @@ const defaultOptions: Options = {
|
||||
prettyLinks: true,
|
||||
openLinksInNewTab: false,
|
||||
lazyLoad: false,
|
||||
externalLinkIcon: true,
|
||||
}
|
||||
|
||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
@ -55,7 +57,29 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
) {
|
||||
let dest = node.properties.href as RelativeURL
|
||||
const classes = (node.properties.className ?? []) as string[]
|
||||
classes.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
||||
const isExternal = isAbsoluteUrl(dest)
|
||||
classes.push(isExternal ? "external" : "internal")
|
||||
|
||||
if (isExternal && opts.externalLinkIcon) {
|
||||
node.children.push({
|
||||
type: "element",
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
class: "external-icon",
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the link has alias text
|
||||
if (
|
||||
|
@ -4,7 +4,7 @@ import { Element, Literal, Root as HtmlRoot } from "hast"
|
||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { SKIP, visit } from "unist-util-visit"
|
||||
import path from "path"
|
||||
import { JSResource } from "../../util/resources"
|
||||
// @ts-ignore
|
||||
@ -23,8 +23,11 @@ export interface Options {
|
||||
callouts: boolean
|
||||
mermaid: boolean
|
||||
parseTags: boolean
|
||||
parseArrows: boolean
|
||||
parseBlockReferences: boolean
|
||||
enableInHtmlEmbed: boolean
|
||||
enableYouTubeEmbed: boolean
|
||||
enableVideoEmbed: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
@ -34,43 +37,14 @@ const defaultOptions: Options = {
|
||||
callouts: true,
|
||||
mermaid: true,
|
||||
parseTags: true,
|
||||
parseArrows: true,
|
||||
parseBlockReferences: true,
|
||||
enableInHtmlEmbed: false,
|
||||
enableYouTubeEmbed: true,
|
||||
enableVideoEmbed: true,
|
||||
}
|
||||
|
||||
const icons = {
|
||||
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
|
||||
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
|
||||
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
|
||||
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
|
||||
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
|
||||
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
||||
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
||||
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
||||
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
||||
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
|
||||
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
|
||||
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
||||
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
|
||||
}
|
||||
|
||||
const callouts = {
|
||||
note: icons.pencilIcon,
|
||||
abstract: icons.clipboardListIcon,
|
||||
info: icons.infoIcon,
|
||||
todo: icons.checkCircleIcon,
|
||||
tip: icons.flameIcon,
|
||||
success: icons.checkIcon,
|
||||
question: icons.helpCircleIcon,
|
||||
warning: icons.alertTriangleIcon,
|
||||
failure: icons.xIcon,
|
||||
danger: icons.zapIcon,
|
||||
bug: icons.bugIcon,
|
||||
example: icons.listIcon,
|
||||
quote: icons.quoteIcon,
|
||||
}
|
||||
|
||||
const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||
const calloutMapping = {
|
||||
note: "note",
|
||||
abstract: "abstract",
|
||||
summary: "abstract",
|
||||
@ -98,26 +72,40 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||
example: "example",
|
||||
quote: "quote",
|
||||
cite: "quote",
|
||||
} as const
|
||||
|
||||
const arrowMapping: Record<string, string> = {
|
||||
"->": "→",
|
||||
"-->": "⇒",
|
||||
"=>": "⇒",
|
||||
"==>": "⇒",
|
||||
"<-": "←",
|
||||
"<--": "⇐",
|
||||
"<=": "⇐",
|
||||
"<==": "⇐",
|
||||
}
|
||||
|
||||
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
||||
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||
return calloutMapping[callout] ?? "note"
|
||||
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
|
||||
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||
// if callout is not recognized, make it a custom one
|
||||
return calloutMapping[normalizedCallout] ?? calloutName
|
||||
}
|
||||
|
||||
export const externalLinkRegex = /^https?:\/\//i
|
||||
|
||||
// !? -> optional embedding
|
||||
// \[\[ -> open brace
|
||||
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
||||
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
|
||||
export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g")
|
||||
|
||||
// !? -> optional embedding
|
||||
// \[\[ -> open brace
|
||||
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
||||
// (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias)
|
||||
export const wikilinkRegex = new RegExp(
|
||||
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
|
||||
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
|
||||
"g",
|
||||
)
|
||||
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
|
||||
const commentRegex = new RegExp(/%%(.+)%%/, "g")
|
||||
const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g")
|
||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
|
||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
||||
@ -125,8 +113,13 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
||||
// #(...) -> capturing group, tag itself must start with #
|
||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
|
||||
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
|
||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
|
||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
|
||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
|
||||
const wikilinkImageEmbedRegex = new RegExp(
|
||||
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
|
||||
)
|
||||
|
||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@ -141,13 +134,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
return {
|
||||
name: "ObsidianFlavoredMarkdown",
|
||||
textTransform(_ctx, src) {
|
||||
// do comments at text level
|
||||
if (opts.comments) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(commentRegex, "")
|
||||
}
|
||||
|
||||
// pre-transform blockquotes
|
||||
if (opts.callouts) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replaceAll(calloutLineRegex, (value) => {
|
||||
src = src.replace(calloutLineRegex, (value) => {
|
||||
// force newline after title of callout
|
||||
return value + "\n> "
|
||||
})
|
||||
@ -159,7 +161,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
|
||||
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||
|
||||
const fp = rawFp ?? ""
|
||||
@ -201,11 +203,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
if (value.startsWith("!")) {
|
||||
const ext: string = path.extname(fp).toLowerCase()
|
||||
const url = slugifyFilePath(fp as FilePath)
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
|
||||
const dims = alias ?? ""
|
||||
let [width, height] = dims.split("x", 2)
|
||||
width ||= "auto"
|
||||
height ||= "auto"
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
|
||||
const match = wikilinkImageEmbedRegex.exec(alias ?? "")
|
||||
const alt = match?.groups?.alt ?? ""
|
||||
const width = match?.groups?.width ?? "auto"
|
||||
const height = match?.groups?.height ?? "auto"
|
||||
return {
|
||||
type: "image",
|
||||
url,
|
||||
@ -213,6 +215,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
hProperties: {
|
||||
width,
|
||||
height,
|
||||
alt,
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -233,7 +236,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
type: "html",
|
||||
value: `<iframe src="${url}"></iframe>`,
|
||||
}
|
||||
} else if (ext === "") {
|
||||
} else {
|
||||
const block = anchor
|
||||
return {
|
||||
type: "html",
|
||||
@ -276,13 +279,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.comments) {
|
||||
if (opts.parseArrows) {
|
||||
replacements.push([
|
||||
commentRegex,
|
||||
(_value: string, ..._capture: string[]) => {
|
||||
arrowRegex,
|
||||
(value: string, ..._capture: string[]) => {
|
||||
const maybeArrow = arrowMapping[value]
|
||||
if (maybeArrow === undefined) return SKIP
|
||||
return {
|
||||
type: "text",
|
||||
value: "",
|
||||
type: "html",
|
||||
value: `<span>${maybeArrow}</span>`,
|
||||
}
|
||||
},
|
||||
])
|
||||
@ -298,8 +303,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
|
||||
tag = slugTag(tag)
|
||||
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
|
||||
file.data.frontmatter.tags.push(tag)
|
||||
if (file.data.frontmatter) {
|
||||
const noteTags = file.data.frontmatter.tags ?? []
|
||||
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
|
||||
}
|
||||
|
||||
return {
|
||||
@ -327,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
if (typeof replace === "string") {
|
||||
node.value = node.value.replace(regex, replace)
|
||||
} else {
|
||||
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
|
||||
node.value = node.value.replace(regex, (substring: string, ...args) => {
|
||||
const replaceValue = replace(substring, ...args)
|
||||
if (typeof replaceValue === "string") {
|
||||
return replaceValue
|
||||
@ -343,11 +349,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mdastFindReplace(tree, replacements)
|
||||
}
|
||||
})
|
||||
|
||||
if (opts.enableVideoEmbed) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
visit(tree, "image", (node, index, parent) => {
|
||||
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
|
||||
const newNode: Html = {
|
||||
type: "html",
|
||||
value: `<video controls src="${node.url}"></video>`,
|
||||
}
|
||||
|
||||
parent.children.splice(index, 1, newNode)
|
||||
return SKIP
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.callouts) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
@ -363,36 +386,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
|
||||
const text = firstChild.children[0].value
|
||||
const restChildren = firstChild.children.slice(1)
|
||||
const restOfTitle = firstChild.children.slice(1)
|
||||
const [firstLine, ...remainingLines] = text.split("\n")
|
||||
const remainingText = remainingLines.join("\n")
|
||||
|
||||
const match = firstLine.match(calloutRegex)
|
||||
if (match && match.input) {
|
||||
const [calloutDirective, typeString, collapseChar] = match
|
||||
const calloutType = canonicalizeCallout(
|
||||
typeString.toLowerCase() as keyof typeof calloutMapping,
|
||||
)
|
||||
const calloutType = canonicalizeCallout(typeString.toLowerCase())
|
||||
const collapse = collapseChar === "+" || collapseChar === "-"
|
||||
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
||||
const titleContent =
|
||||
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
|
||||
const titleContent = match.input.slice(calloutDirective.length).trim()
|
||||
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
|
||||
const titleNode: Paragraph = {
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
|
||||
},
|
||||
...restOfTitle,
|
||||
],
|
||||
}
|
||||
const title = mdastToHtml(titleNode)
|
||||
|
||||
const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>`
|
||||
const toggleIcon = `<div class="fold-callout-icon"></div>`
|
||||
|
||||
const titleHtml: Html = {
|
||||
type: "html",
|
||||
value: `<div
|
||||
class="callout-title"
|
||||
>
|
||||
<div class="callout-icon">${callouts[calloutType]}</div>
|
||||
<div class="callout-icon"></div>
|
||||
<div class="callout-title-inner">${title}</div>
|
||||
${collapse ? toggleIcon : ""}
|
||||
</div>`,
|
||||
@ -418,7 +443,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
node.data = {
|
||||
hProperties: {
|
||||
...(node.data?.hProperties ?? {}),
|
||||
className: `callout ${collapse ? "is-collapsible" : ""} ${
|
||||
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
|
||||
defaultState === "collapsed" ? "is-collapsed" : ""
|
||||
}`,
|
||||
"data-callout": calloutType,
|
||||
@ -505,6 +530,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.enableYouTubeEmbed) {
|
||||
plugins.push(() => {
|
||||
return (tree: HtmlRoot) => {
|
||||
visit(tree, "element", (node) => {
|
||||
if (node.tagName === "img" && typeof node.properties.src === "string") {
|
||||
const match = node.properties.src.match(ytLinkRegex)
|
||||
const videoId = match && match[2].length == 11 ? match[2] : null
|
||||
if (videoId) {
|
||||
node.tagName = "iframe"
|
||||
node.properties = {
|
||||
class: "external-embed",
|
||||
allow: "fullscreen",
|
||||
frameborder: 0,
|
||||
width: "600px",
|
||||
height: "350px",
|
||||
src: `https://www.youtube.com/embed/${videoId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
externalResources() {
|
||||
@ -550,4 +599,4 @@ declare module "vfile" {
|
||||
blocks: Record<string, Element>
|
||||
htmlAst: HtmlRoot
|
||||
}
|
||||
}
|
||||
}
|
@ -26,6 +26,7 @@ interface TocEntry {
|
||||
}
|
||||
|
||||
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
|
||||
const slugAnchor = new Slugger()
|
||||
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
) => {
|
||||
@ -38,7 +39,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
|
||||
return async (tree: Root, file) => {
|
||||
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
|
||||
if (display) {
|
||||
const slugAnchor = new Slugger()
|
||||
slugAnchor.reset()
|
||||
const toc: TocEntry[] = []
|
||||
let highestDepth: number = opts.maxDepth
|
||||
visit(tree, "heading", (node) => {
|
||||
|
@ -2,7 +2,7 @@ import { PluggableList } from "unified"
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { ProcessedContent } from "./vfile"
|
||||
import { QuartzComponent } from "../components/types"
|
||||
import { FilePath, FullSlug } from "../util/path"
|
||||
import { FilePath } from "../util/path"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export interface PluginTypes {
|
||||
@ -36,19 +36,6 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
) => QuartzEmitterPluginInstance
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit(
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
emitCallback: EmitCallback,
|
||||
): Promise<FilePath[]>
|
||||
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||
}
|
||||
|
||||
export interface EmitOptions {
|
||||
slug: FullSlug
|
||||
ext: `.${string}` | ""
|
||||
content: string
|
||||
}
|
||||
|
||||
export type EmitCallback = (data: EmitOptions) => Promise<FilePath>
|
||||
|
@ -1,10 +1,6 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { PerfTimer } from "../util/perf"
|
||||
import { getStaticResourcesFromPlugins } from "../plugins"
|
||||
import { EmitCallback } from "../plugins/types"
|
||||
import { ProcessedContent } from "../plugins/vfile"
|
||||
import { FilePath, joinSegments } from "../util/path"
|
||||
import { QuartzLogger } from "../util/log"
|
||||
import { trace } from "../util/trace"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
@ -15,19 +11,12 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
||||
const log = new QuartzLogger(ctx.argv.verbose)
|
||||
|
||||
log.start(`Emitting output files`)
|
||||
const emit: EmitCallback = async ({ slug, ext, content }) => {
|
||||
const pathToPage = joinSegments(argv.output, slug + ext) as FilePath
|
||||
const dir = path.dirname(pathToPage)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(pathToPage, content)
|
||||
return pathToPage
|
||||
}
|
||||
|
||||
let emittedFiles = 0
|
||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
try {
|
||||
const emitted = await emitter.emit(ctx, content, staticResources, emit)
|
||||
const emitted = await emitter.emit(ctx, content, staticResources)
|
||||
emittedFiles += emitted.length
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
|
@ -3,7 +3,6 @@
|
||||
@use "./variables.scss" as *;
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
overflow-x: hidden;
|
||||
width: 100vw;
|
||||
@ -26,7 +25,7 @@ section {
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--tertiary) 75%, transparent);
|
||||
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
|
||||
color: var(--darkgray);
|
||||
}
|
||||
|
||||
@ -54,7 +53,7 @@ ul,
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 600;
|
||||
font-weight: $boldWeight;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--secondary);
|
||||
@ -68,6 +67,7 @@ a {
|
||||
background-color: var(--highlight);
|
||||
padding: 0 0.1rem;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4rem;
|
||||
|
||||
&:has(> img) {
|
||||
background-color: none;
|
||||
@ -75,6 +75,15 @@ a {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.external .external-icon {
|
||||
height: 1ex;
|
||||
margin: 0 0.15em;
|
||||
|
||||
> path {
|
||||
fill: var(--dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@ -161,9 +170,11 @@ a {
|
||||
|
||||
& .sidebar.right {
|
||||
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
|
||||
flex-wrap: wrap;
|
||||
& > * {
|
||||
@media all and (max-width: $fullPageWidth) {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -266,7 +277,6 @@ h6 {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
transform: translateY(-0.1rem);
|
||||
display: inline-block;
|
||||
font-family: var(--codeFont);
|
||||
user-select: none;
|
||||
}
|
||||
@ -331,6 +341,7 @@ pre {
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--lightgray);
|
||||
position: relative;
|
||||
|
||||
&:has(> code.mermaid) {
|
||||
border: none;
|
||||
@ -344,6 +355,7 @@ pre {
|
||||
counter-increment: line 0;
|
||||
display: grid;
|
||||
padding: 0.5rem 0;
|
||||
overflow-x: scroll;
|
||||
|
||||
& [data-highlighted-chars] {
|
||||
background-color: var(--highlight);
|
||||
|
@ -1,3 +1,4 @@
|
||||
@use "./variables.scss" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.callout {
|
||||
@ -13,16 +14,33 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&[data-callout="note"] {
|
||||
--callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');
|
||||
--callout-icon-abstract: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>');
|
||||
--callout-icon-info: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>');
|
||||
--callout-icon-todo: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>');
|
||||
--callout-icon-tip: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg> ');
|
||||
--callout-icon-success: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> ');
|
||||
--callout-icon-question: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> ');
|
||||
--callout-icon-warning: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>');
|
||||
--callout-icon-failure: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> ');
|
||||
--callout-icon-danger: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg> ');
|
||||
--callout-icon-bug: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>');
|
||||
--callout-icon-example: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg> ');
|
||||
--callout-icon-quote: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>');
|
||||
--callout-icon-fold: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E');
|
||||
|
||||
&[data-callout] {
|
||||
--color: #448aff;
|
||||
--border: #448aff44;
|
||||
--bg: #448aff10;
|
||||
--callout-icon: var(--callout-icon-note);
|
||||
}
|
||||
|
||||
&[data-callout="abstract"] {
|
||||
--color: #00b0ff;
|
||||
--border: #00b0ff44;
|
||||
--bg: #00b0ff10;
|
||||
--callout-icon: var(--callout-icon-abstract);
|
||||
}
|
||||
|
||||
&[data-callout="info"],
|
||||
@ -30,30 +48,39 @@
|
||||
--color: #00b8d4;
|
||||
--border: #00b8d444;
|
||||
--bg: #00b8d410;
|
||||
--callout-icon: var(--callout-icon-info);
|
||||
}
|
||||
|
||||
&[data-callout="todo"] {
|
||||
--callout-icon: var(--callout-icon-todo);
|
||||
}
|
||||
|
||||
&[data-callout="tip"] {
|
||||
--color: #00bfa5;
|
||||
--border: #00bfa544;
|
||||
--bg: #00bfa510;
|
||||
--callout-icon: var(--callout-icon-tip);
|
||||
}
|
||||
|
||||
&[data-callout="success"] {
|
||||
--color: #09ad7a;
|
||||
--border: #09ad7144;
|
||||
--bg: #09ad7110;
|
||||
--callout-icon: var(--callout-icon-success);
|
||||
}
|
||||
|
||||
&[data-callout="question"] {
|
||||
--color: #dba642;
|
||||
--border: #dba64244;
|
||||
--bg: #dba64210;
|
||||
--callout-icon: var(--callout-icon-question);
|
||||
}
|
||||
|
||||
&[data-callout="warning"] {
|
||||
--color: #db8942;
|
||||
--border: #db894244;
|
||||
--bg: #db894210;
|
||||
--callout-icon: var(--callout-icon-warning);
|
||||
}
|
||||
|
||||
&[data-callout="failure"],
|
||||
@ -62,50 +89,74 @@
|
||||
--color: #db4242;
|
||||
--border: #db424244;
|
||||
--bg: #db424210;
|
||||
--callout-icon: var(--callout-icon-failure);
|
||||
}
|
||||
|
||||
&[data-callout="bug"] {
|
||||
--callout-icon: var(--callout-icon-bug);
|
||||
}
|
||||
|
||||
&[data-callout="danger"] {
|
||||
--callout-icon: var(--callout-icon-danger);
|
||||
}
|
||||
|
||||
&[data-callout="example"] {
|
||||
--color: #7a43b5;
|
||||
--border: #7a43b544;
|
||||
--bg: #7a43b510;
|
||||
--callout-icon: var(--callout-icon-example);
|
||||
}
|
||||
|
||||
&[data-callout="quote"] {
|
||||
--color: var(--secondary);
|
||||
--border: var(--lightgray);
|
||||
--callout-icon: var(--callout-icon-quote);
|
||||
}
|
||||
|
||||
&.is-collapsed > .callout-title > .fold {
|
||||
&.is-collapsed > .callout-title > .fold-callout-icon {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.callout-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
padding: 1rem 0;
|
||||
color: var(--color);
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
--icon-size: 18px;
|
||||
|
||||
& .fold-callout-icon {
|
||||
transition: transform 0.15s ease;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
--callout-icon: var(--callout-icon-fold);
|
||||
}
|
||||
|
||||
& > .callout-title-inner > p {
|
||||
color: var(--color);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.callout-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.callout-icon,
|
||||
& .fold-callout-icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
flex: 0 0 var(--icon-size);
|
||||
|
||||
.callout-title-inner {
|
||||
font-weight: 700;
|
||||
// icon support
|
||||
background-size: var(--icon-size) var(--icon-size);
|
||||
background-position: center;
|
||||
background-color: var(--color);
|
||||
mask-image: var(--callout-icon);
|
||||
mask-size: var(--icon-size) var(--icon-size);
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.callout-title-inner {
|
||||
font-weight: $boldWeight;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
$pageWidth: 750px;
|
||||
$mobileBreakpoint: 600px;
|
||||
$tabletBreakpoint: 1200px;
|
||||
$tabletBreakpoint: 1000px;
|
||||
$sidePanelWidth: 380px;
|
||||
$topSpacing: 6rem;
|
||||
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
|
||||
$boldWeight: 700;
|
||||
$normalWeight: 400;
|
||||
|
@ -1,11 +1,13 @@
|
||||
export function pluralize(count: number, s: string): string {
|
||||
if (count === 1) {
|
||||
return `1 ${s}`
|
||||
} else {
|
||||
return `${count} ${s}s`
|
||||
}
|
||||
}
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
return s.substring(0, 1).toUpperCase() + s.substring(1)
|
||||
}
|
||||
|
||||
export function classNames(
|
||||
displayClass?: "mobile-only" | "desktop-only",
|
||||
...classes: string[]
|
||||
): string {
|
||||
if (displayClass) {
|
||||
classes.push(displayClass)
|
||||
}
|
||||
return classes.join(" ")
|
||||
}
|
||||
|
@ -105,6 +105,10 @@ describe("transforms", () => {
|
||||
["index.md", "index"],
|
||||
["test.mp4", "test.mp4"],
|
||||
["note with spaces.md", "note-with-spaces"],
|
||||
["notes.with.dots.md", "notes.with.dots"],
|
||||
["test/special chars?.md", "test/special-chars"],
|
||||
["test/special chars #3.md", "test/special-chars-3"],
|
||||
["cool/what about r&d?.md", "cool/what-about-r-and-d"],
|
||||
],
|
||||
path.slugifyFilePath,
|
||||
path.isFilePath,
|
||||
|
@ -50,7 +50,14 @@ export function getFullSlug(window: Window): FullSlug {
|
||||
function sluggify(s: string): string {
|
||||
return s
|
||||
.split("/")
|
||||
.map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
|
||||
.map((segment) =>
|
||||
segment
|
||||
.replace(/\s/g, "-")
|
||||
.replace(/&/g, "-and-")
|
||||
.replace(/%/g, "-percent")
|
||||
.replace(/\?/g, "")
|
||||
.replace(/#/g, ""),
|
||||
)
|
||||
.join("/") // always use / as sep
|
||||
.replace(/\/$/, "")
|
||||
}
|
||||
|
@ -13,8 +13,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
"jsxImportSource": "preact",
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "./package.json"],
|
||||
"exclude": ["build/**/*.d.ts"]
|
||||
"exclude": ["build/**/*.d.ts"],
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user