diff --git a/docs/configuration.md b/docs/configuration.md index b0c850623..366430cc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,8 +25,9 @@ This part of the configuration concerns anything that can affect the whole site. - `enablePopovers`: whether to enable [[popover previews]] on your site. - `analytics`: what to use for analytics on your site. Values can be - `null`: don't use analytics; - - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or - - `{ provider: 'google', tagId: }`: use Google Analytics + - `{ provider: 'google', tagId: '' }`: use Google Analytics; + - `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: '' }` (self-hosted): use [Plausible](https://plausible.io/); + - `{ provider: 'umami', host: '', websiteId: '' }`: use [Umami](https://umami.is/); - `locale`: used for [[i18n]] and date formatting - `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes. - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz` diff --git a/package-lock.json b/package-lock.json index 1b6a4e033..e48812495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "chokidar": "^3.5.3", "cli-spinner": "^0.2.10", "d3": "^7.8.5", - "esbuild-sass-plugin": "^2.16.0", + "esbuild-sass-plugin": "^2.16.1", "flexsearch": "0.7.43", "github-slugger": "^2.0.0", "globby": "^14.0.0", @@ -2052,9 +2052,9 @@ } }, "node_modules/esbuild-sass-plugin": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.0.tgz", - "integrity": "sha512-mGCe9MxNYvZ+j77Q/QFO+rwUGA36mojDXkOhtVmoyz1zwYbMaNrtVrmXwwYDleS/UMKTNU3kXuiTtPiAD3K+Pw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.1.tgz", + "integrity": "sha512-mBB2aEF0xk7yo+Q9pSUh8xYED/1O2wbAM6IauGkDrqy6pl9SbJNakLeLGXiNpNujWIudu8TJTZCv2L5AQYRXtA==", "dependencies": { "resolve": "^1.22.6", "sass": "^1.7.3" diff --git a/quartz/build.ts b/quartz/build.ts index ed166bb6c..452a2f1ae 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -203,8 +203,9 @@ async function partialRebuildFromEntrypoint( const emitterGraph = (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null - // emmiter may not define a dependency graph. nothing to update if so - if (emitterGraph) { + // only update the graph if the emitter plugin uses the changed file + // eg. Assets plugin ignores md files, so we skip updating the graph + if (emitterGraph?.hasNode(fp)) { // merge the new dependencies into the dep graph dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) } diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 3875f5e55..eab8a34e2 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -68,13 +68,9 @@ export default ((opts?: Partial) => { folderIndex = new Map() // construct the index for the first time for (const file of allFiles) { - if (file.slug?.endsWith("index")) { - const folderParts = file.slug?.split("/") - // 2nd last to exclude the /index - const folderName = folderParts?.at(-2) - if (folderName) { - folderIndex.set(folderName, file) - } + const folderParts = file.slug?.split("/") + if (folderParts?.at(-1) === "index") { + folderIndex.set(folderParts.slice(0, -1).join("/"), file) } } } @@ -88,7 +84,7 @@ export default ((opts?: Partial) => { let curPathSegment = slugParts[i] // Try to resolve frontmatter folder title - const currentFile = folderIndex?.get(curPathSegment) + const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/")) if (currentFile) { const title = currentFile.frontmatter!.title if (title !== "index") { diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx index 6cd083e69..bcbe4285d 100644 --- a/quartz/components/ContentMeta.tsx +++ b/quartz/components/ContentMeta.tsx @@ -2,6 +2,7 @@ import { formatDate, getDate } from "./Date" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import readingTime from "reading-time" import { classNames } from "../util/lang" +import { i18n } from "../i18n" interface ContentMetaOptions { /** @@ -30,8 +31,11 @@ export default ((opts?: Partial) => { // Display reading time if enabled if (options.showReadingTime) { - const { text: timeTaken, words: _words } = readingTime(text) - segments.push(timeTaken) + const { minutes, words: _words } = readingTime(text) + const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({ + minutes: Math.ceil(minutes), + }) + segments.push(displayedTime) } return

{segments.join(", ")}

diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index dae81c731..8292acc05 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -1,5 +1,5 @@ import { i18n } from "../i18n" -import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" +import { FullSlug, joinSegments, pathToRoot } from "../util/path" import { JSResourceToScriptElement } from "../util/resources" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index b954ea21c..d3f28ddf1 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -3,7 +3,7 @@ import path from "path" import style from "../styles/listPage.scss" import { PageList } from "../PageList" -import { _stripSlashes, simplifySlug } from "../../util/path" +import { stripSlashes, simplifySlug } from "../../util/path" import { Root } from "hast" import { htmlToJsx } from "../../util/jsx" import { i18n } from "../../i18n" @@ -24,9 +24,9 @@ export default ((opts?: Partial) => { function FolderContent(props: QuartzComponentProps) { const { tree, fileData, allFiles, cfg } = props - const folderSlug = _stripSlashes(simplifySlug(fileData.slug!)) + const folderSlug = stripSlashes(simplifySlug(fileData.slug!)) const allPagesInFolder = allFiles.filter((file) => { - const fileSlug = _stripSlashes(simplifySlug(file.slug!)) + 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) diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts index d8efeffef..f7048103f 100644 --- a/quartz/i18n/locales/ar-SA.ts +++ b/quartz/i18n/locales/ar-SA.ts @@ -53,6 +53,14 @@ export default { tableOfContents: { title: "فهرس المحتويات", }, + contentMeta: { + readingTime: ({ minutes }) => + minutes == 1 + ? `دقيقة أو أقل للقراءة` + : minutes == 2 + ? `دقيقتان للقراءة` + : `${minutes} دقائق للقراءة`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index f2125bf60..e3821944b 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Inhaltsverzeichnis", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index baf2a5875..1d5d3dda6 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -55,6 +55,9 @@ export interface Translation { tableOfContents: { title: string } + contentMeta: { + readingTime: (variables: { minutes: number }) => string + } } pages: { rss: { diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index 3ba28a0d5..4a308d79a 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Table of Contents", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts index 92a74a03c..f59d201a3 100644 --- a/quartz/i18n/locales/es-ES.ts +++ b/quartz/i18n/locales/es-ES.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Tabla de Contenidos", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts index fee1ad921..8b7229201 100644 --- a/quartz/i18n/locales/fr-FR.ts +++ b/quartz/i18n/locales/fr-FR.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Table des Matières", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts index f684c36b3..d429db411 100644 --- a/quartz/i18n/locales/ja-JP.ts +++ b/quartz/i18n/locales/ja-JP.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "目次", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts index b40362355..3ff3e8cd9 100644 --- a/quartz/i18n/locales/nl-NL.ts +++ b/quartz/i18n/locales/nl-NL.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Inhoudsopgave", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts index 39f1344be..556b18995 100644 --- a/quartz/i18n/locales/ro-RO.ts +++ b/quartz/i18n/locales/ro-RO.ts @@ -53,6 +53,10 @@ export default { tableOfContents: { title: "Cuprins", }, + contentMeta: { + readingTime: ({ minutes }) => + minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`, + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts index d60b1f8cf..c997a6972 100644 --- a/quartz/i18n/locales/uk-UA.ts +++ b/quartz/i18n/locales/uk-UA.ts @@ -53,6 +53,9 @@ export default { tableOfContents: { title: "Зміст", }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, }, pages: { rss: { diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index 379cd5b2d..036b27da4 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -4,6 +4,13 @@ import path from "path" import fs from "fs" import { glob } from "../../util/glob" import DepGraph from "../../depgraph" +import { Argv } from "../../util/ctx" +import { QuartzConfig } from "../../cfg" + +const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { + // glob all non MD files in content folder and copy it over + return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) +} export const Assets: QuartzEmitterPlugin = () => { return { @@ -15,7 +22,7 @@ export const Assets: QuartzEmitterPlugin = () => { const { argv, cfg } = ctx const graph = new DepGraph() - const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) + const fps = await filesToCopy(argv, cfg) for (const fp of fps) { const ext = path.extname(fp) @@ -30,9 +37,8 @@ export const Assets: QuartzEmitterPlugin = () => { return graph }, async emit({ argv, cfg }, _content, _resources): Promise { - // 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]) + const fps = await filesToCopy(argv, cfg) const res: FilePath[] = [] for (const fp of fps) { const ext = path.extname(fp) diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 7a62cda56..690fa56f7 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -10,7 +10,7 @@ import { FilePath, FullSlug, SimpleSlug, - _stripSlashes, + stripSlashes, joinSegments, pathToRoot, simplifySlug, @@ -38,7 +38,7 @@ export const FolderPage: QuartzEmitterPlugin> = (userOpt getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, - async getDependencyGraph(ctx, content, _resources) { + async getDependencyGraph(_ctx, _content, _resources) { // Example graph: // nested/file.md --> nested/file.html // \-------> nested/index.html @@ -75,7 +75,7 @@ export const FolderPage: QuartzEmitterPlugin> = (userOpt ) for (const [tree, file] of content) { - const slug = _stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug + const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug if (folders.has(slug)) { folderDescriptions[slug] = [tree, file] } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 1ba0c8e58..2d43be1b2 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -4,7 +4,7 @@ import { RelativeURL, SimpleSlug, TransformOptions, - _stripSlashes, + stripSlashes, simplifySlug, splitAnchor, transformLink, @@ -115,7 +115,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = } // need to decodeURIComponent here as WHATWG URL percent-encodes everything - const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug + const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug const simple = simplifySlug(full) outgoing.add(simple) node.properties["data-slug"] = full diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 89b297b2e..e110e403f 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -118,7 +118,10 @@ 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}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu") +const tagRegex = new RegExp( + /(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/, + "gu", +) const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g") const 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)$/) diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 5cd79ba5f..dceb89bfa 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -23,22 +23,22 @@ export type FullSlug = SlugLike<"full"> export function isFullSlug(s: string): s is FullSlug { const validStart = !(s.startsWith(".") || s.startsWith("/")) const validEnding = !s.endsWith("/") - return validStart && validEnding && !_containsForbiddenCharacters(s) + return validStart && validEnding && !containsForbiddenCharacters(s) } /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ export type SimpleSlug = SlugLike<"simple"> export function isSimpleSlug(s: string): s is SimpleSlug { const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/"))) - const validEnding = !(s.endsWith("/index") || s === "index") - return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) + const validEnding = !endsWith(s, "index") + return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) } /** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */ export type RelativeURL = SlugLike<"relative"> export function isRelativeURL(s: string): s is RelativeURL { const validStart = /^\.{1,2}/.test(s) - const validEnding = !(s.endsWith("/index") || s === "index") + const validEnding = !endsWith(s, "index") return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "") } @@ -63,7 +63,7 @@ function sluggify(s: string): string { } export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { - fp = _stripSlashes(fp) as FilePath + fp = stripSlashes(fp) as FilePath let ext = _getFileExtension(fp) const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") if (excludeExt || [".md", ".html", undefined].includes(ext)) { @@ -73,7 +73,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { let slug = sluggify(withoutFileExt) // treat _index as index - if (_endsWith(slug, "_index")) { + if (endsWith(slug, "_index")) { slug = slug.replace(/_index$/, "index") } @@ -81,21 +81,21 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { } export function simplifySlug(fp: FullSlug): SimpleSlug { - const res = _stripSlashes(_trimSuffix(fp, "index"), true) + const res = stripSlashes(trimSuffix(fp, "index"), true) return (res.length === 0 ? "/" : res) as SimpleSlug } export function transformInternalLink(link: string): RelativeURL { let [fplike, anchor] = splitAnchor(decodeURI(link)) - const folderPath = _isFolderPath(fplike) + const folderPath = isFolderPath(fplike) let segments = fplike.split("/").filter((x) => x.length > 0) - let prefix = segments.filter(_isRelativeSegment).join("/") - let fp = segments.filter((seg) => !_isRelativeSegment(seg) && seg !== "").join("/") + let prefix = segments.filter(isRelativeSegment).join("/") + let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/") // manually add ext here as we want to not strip 'index' if it has an extension const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath)) - const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(simpleSlug)) + const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug)) const trail = folderPath ? "/" : "" const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL return res @@ -206,8 +206,8 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti if (opts.strategy === "relative") { return targetSlug as RelativeURL } else { - const folderTail = _isFolderPath(targetSlug) ? "/" : "" - const canonicalSlug = _stripSlashes(targetSlug.slice(".".length)) + const folderTail = isFolderPath(targetSlug) ? "/" : "" + const canonicalSlug = stripSlashes(targetSlug.slice(".".length)) let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug) if (opts.strategy === "shortest") { @@ -230,28 +230,29 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti } } -function _isFolderPath(fplike: string): boolean { +// path helpers +function isFolderPath(fplike: string): boolean { return ( fplike.endsWith("/") || - _endsWith(fplike, "index") || - _endsWith(fplike, "index.md") || - _endsWith(fplike, "index.html") + endsWith(fplike, "index") || + endsWith(fplike, "index.md") || + endsWith(fplike, "index.html") ) } -function _endsWith(s: string, suffix: string): boolean { +export function endsWith(s: string, suffix: string): boolean { return s === suffix || s.endsWith("/" + suffix) } -function _trimSuffix(s: string, suffix: string): string { - if (_endsWith(s, suffix)) { +function trimSuffix(s: string, suffix: string): string { + if (endsWith(s, suffix)) { s = s.slice(0, -suffix.length) } return s } -function _containsForbiddenCharacters(s: string): boolean { - return s.includes(" ") || s.includes("#") || s.includes("?") +function containsForbiddenCharacters(s: string): boolean { + return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&") } function _hasFileExtension(s: string): boolean { @@ -262,11 +263,11 @@ function _getFileExtension(s: string): string | undefined { return s.match(/\.[A-Za-z0-9]+$/)?.[0] } -function _isRelativeSegment(s: string): boolean { +function isRelativeSegment(s: string): boolean { return /^\.{0,2}$/.test(s) } -export function _stripSlashes(s: string, onlyStripPrefix?: boolean): string { +export function stripSlashes(s: string, onlyStripPrefix?: boolean): string { if (s.startsWith("/")) { s = s.substring(1) }