diff --git a/docs/build.md b/docs/build.md index 144768757..6005770dd 100644 --- a/docs/build.md +++ b/docs/build.md @@ -21,3 +21,7 @@ This will start a local web server to run your Quartz on your computer. Open a w > - `--serve`: run a local hot-reloading server to preview your Quartz > - `--port`: what port to run the local preview server on > - `--concurrency`: how many threads to use to parse notes + +> [!warning] Not to be used for production +> Serve mode is intended for local previews only. +> For production workloads, see the page on [[hosting]]. diff --git a/docs/features/Roam Research compatibility.md b/docs/features/Roam Research compatibility.md new file mode 100644 index 000000000..41ea88569 --- /dev/null +++ b/docs/features/Roam Research compatibility.md @@ -0,0 +1,28 @@ +--- +title: "Roam Research Compatibility" +tags: + - feature/transformer +--- + +[Roam Research](https://roamresearch.com) is a note-taking tool that organizes your knowledge graph in a unique and interconnected way. + +Quartz supports transforming the special Markdown syntax from Roam Research (like `{{[[components]]}}` and other formatting) into +regular Markdown via the [[RoamFlavoredMarkdown]] plugin. + +```typescript title="quartz.config.ts" +plugins: { + transformers: [ + // ... + Plugin.RoamFlavoredMarkdown(), + Plugin.ObsidianFlavoredMarkdown(), + // ... + ], +}, +``` + +> [!warning] +> As seen above placement of `Plugin.RoamFlavoredMarkdown()` within `quartz.config.ts` is very important. It must come before `Plugin.ObsidianFlavoredMarkdown()`. + +## Customization + +This functionality is provided by the [[RoamFlavoredMarkdown]] plugin. See the plugin page for customization options. diff --git a/docs/plugins/RoamFlavoredMarkdown.md b/docs/plugins/RoamFlavoredMarkdown.md new file mode 100644 index 000000000..9d89477a2 --- /dev/null +++ b/docs/plugins/RoamFlavoredMarkdown.md @@ -0,0 +1,26 @@ +--- +title: RoamFlavoredMarkdown +tags: + - plugin/transformer +--- + +This plugin provides support for [Roam Research](https://roamresearch.com) compatibility. See [[Roam Research Compatibility]] for more information. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page. + +This plugin accepts the following configuration options: + +- `orComponent`: If `true` (default), converts Roam `{{ or:ONE|TWO|THREE }}` shortcodes into HTML Dropdown options. +- `TODOComponent`: If `true` (default), converts Roam `{{[[TODO]]}}` shortcodes into HTML check boxes. +- `DONEComponent`: If `true` (default), converts Roam `{{[[DONE]]}}` shortcodes into checked HTML check boxes. +- `videoComponent`: If `true` (default), converts Roam `{{[[video]]:URL}}` shortcodes into embeded HTML video. +- `audioComponent`: If `true` (default), converts Roam `{{[[audio]]:URL}}` shortcodes into embeded HTML audio. +- `pdfComponent`: If `true` (default), converts Roam `{{[[pdf]]:URL}}` shortcodes into embeded HTML PDF viewer. +- `blockquoteComponent`: If `true` (default), converts Roam `{{[[>]]}}` shortcodes into Quartz blockquotes. + +## API + +- Category: Transformer +- Function name: `Plugin.RoamFlavoredMarkdown()`. +- Source: [`quartz/plugins/transformers/roam.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/roam.ts). diff --git a/docs/showcase.md b/docs/showcase.md index 73bb5a15d..c33b2aa5f 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -27,6 +27,6 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Ellie's Notes](https://ellie.wtf) - [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja) - [Eledah's Crystalline](https://blog.eledah.ir/) -- [Alex' Garden - アレックスの庭](https://alexanderweichart.de/) +- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com) If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! diff --git a/package-lock.json b/package-lock.json index 1daed7424..98809c603 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "github-slugger": "^2.0.0", "globby": "^14.0.2", "gray-matter": "^4.0.3", - "hast-util-to-html": "^9.0.1", + "hast-util-to-html": "^9.0.2", "hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", @@ -33,9 +33,9 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.3.3", + "pixi.js": "^8.3.4", "preact": "^10.23.2", - "preact-render-to-string": "^6.5.9", + "preact-render-to-string": "^6.5.10", "pretty-bytes": "^6.1.1", "pretty-time": "^1.1.0", "reading-time": "^1.5.0", @@ -76,14 +76,14 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.5.0", + "@types/node": "^22.5.4", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.5.12", "@types/yargs": "^17.0.33", "esbuild": "^0.19.9", "prettier": "^3.3.3", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4" }, "engines": { @@ -1263,9 +1263,9 @@ } }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dev": true, "dependencies": { "undici-types": "~6.19.2" @@ -2831,15 +2831,14 @@ "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, "node_modules/hast-util-to-html": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.1.tgz", - "integrity": "sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.2.tgz", + "integrity": "sha512-RP5wNpj5nm1Z8cloDv4Sl4RS8jH5HYa0v93YB6Wb4poEzgMo/dAAL0KcT4974dCjcNG5pkLqTImeFHHCwwfY3g==", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", - "hast-util-raw": "^9.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", @@ -4840,10 +4839,9 @@ } }, "node_modules/pixi.js": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz", - "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==", - "license": "MIT", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.4.tgz", + "integrity": "sha512-b5qdoHMQy79JjTiOOAH/fDiK9dLKGAoxfBwkHIdsK5XKNxsFuII2MBbktvR9pVaAmTDobDkMPDoIBFKYYpDeOg==", "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", @@ -4866,9 +4864,9 @@ } }, "node_modules/preact-render-to-string": { - "version": "6.5.9", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.9.tgz", - "integrity": "sha512-Fn9R89h6qrQeSRmsH2O2fWzqpVwsJgEL9UTly5nGEV2ldhVuG+9JhXdNJ6zreIkOZcBT20+AOMwlG1x72znJ+g==", + "version": "6.5.10", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.10.tgz", + "integrity": "sha512-BJdypTQaBA5UbTF9NKZS3zP93Sw33tZOxNXIfuHofqOZFoMdsquNkVebs/HkEw0in/Qbi6Ep/Anngnj+VsHeBQ==", "peerDependencies": { "preact": ">=10" } @@ -5911,9 +5909,9 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tsx": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.18.0.tgz", - "integrity": "sha512-a1jaKBSVQkd6yEc1/NI7G6yHFfefIcuf3QJST7ZEyn4oQnxLYrZR5uZAM8UrwUa3Ge8suiZHcNS1gNrEvmobqg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.0.tgz", + "integrity": "sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg==", "dev": true, "dependencies": { "esbuild": "~0.23.0", diff --git a/package.json b/package.json index e164baf59..52c87dd93 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "github-slugger": "^2.0.0", "globby": "^14.0.2", "gray-matter": "^4.0.3", - "hast-util-to-html": "^9.0.1", + "hast-util-to-html": "^9.0.2", "hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", @@ -59,9 +59,9 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.3.3", + "pixi.js": "^8.3.4", "preact": "^10.23.2", - "preact-render-to-string": "^6.5.9", + "preact-render-to-string": "^6.5.10", "pretty-bytes": "^6.1.1", "pretty-time": "^1.1.0", "reading-time": "^1.5.0", @@ -99,14 +99,14 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.5.0", + "@types/node": "^22.5.4", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.5.12", "@types/yargs": "^17.0.33", "esbuild": "^0.19.9", "prettier": "^3.3.3", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4" } } diff --git a/quartz/build.ts b/quartz/build.ts index 342a27c92..67ec0da4d 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -39,7 +39,7 @@ type BuildData = { type FileEvent = "add" | "change" | "delete" function newBuildId() { - return new Date().toISOString() + return Math.random().toString(36).substring(2, 8) } async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { @@ -162,17 +162,19 @@ async function partialRebuildFromEntrypoint( return } - const buildStart = new Date().getTime() - buildData.lastBuildMs = buildStart + const buildId = newBuildId() + ctx.buildId = buildId + buildData.lastBuildMs = new Date().getTime() const release = await mut.acquire() - if (buildData.lastBuildMs > buildStart) { + + // if there's another build after us, release and let them do it + if (ctx.buildId !== buildId) { release() return } const perf = new PerfTimer() console.log(chalk.yellow("Detected change, rebuilding...")) - ctx.buildId = newBuildId() // UPDATE DEP GRAPH const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath @@ -357,19 +359,19 @@ async function rebuildFromEntrypoint( toRemove.add(filePath) } - const buildStart = new Date().getTime() - buildData.lastBuildMs = buildStart + const buildId = newBuildId() + ctx.buildId = buildId + buildData.lastBuildMs = new Date().getTime() const release = await mut.acquire() // there's another build after us, release and let them do it - if (buildData.lastBuildMs > buildStart) { + if (ctx.buildId !== buildId) { release() return } const perf = new PerfTimer() console.log(chalk.yellow("Detected change, rebuilding...")) - ctx.buildId = newBuildId() try { const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) @@ -405,10 +407,10 @@ async function rebuildFromEntrypoint( } } - release() clientRefresh() toRebuild.clear() toRemove.clear() + release() } export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 6bf43aa84..5e8d48c13 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -550,6 +550,19 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { addToVisited(simplifySlug(slug)) await renderGraph("graph-container", slug) + // Function to re-render the graph when the theme changes + const handleThemeChange = () => { + renderGraph("graph-container", slug) + } + + // event listener for theme change + document.addEventListener("themechange", handleThemeChange) + + // cleanup for the event listener + window.addCleanup(() => { + document.removeEventListener("themechange", handleThemeChange) + }) + const container = document.getElementById("global-graph-outer") const sidebar = container?.closest(".sidebar") as HTMLElement diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 7908c865e..8e2cd844f 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -10,3 +10,4 @@ export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" +export { RoamFlavoredMarkdown } from "./roam" diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 38bc0dc5d..3e8dbdede 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -67,6 +67,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) properties: { "aria-hidden": "true", class: "external-icon", + style: "max-width:0.8em;max-height:0.8em", viewBox: "0 0 512 512", }, children: [ diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 0dea89384..dd743b6d0 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -324,8 +324,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> replacements.push([ tagRegex, (_value: string, tag: string) => { - // Check if the tag only includes numbers - if (/^\d+$/.test(tag)) { + // Check if the tag only includes numbers and slashes + if (/^[\/\d]+$/.test(tag)) { return false } diff --git a/quartz/plugins/transformers/roam.ts b/quartz/plugins/transformers/roam.ts new file mode 100644 index 000000000..b3be8f542 --- /dev/null +++ b/quartz/plugins/transformers/roam.ts @@ -0,0 +1,224 @@ +import { QuartzTransformerPlugin } from "../types" +import { PluggableList } from "unified" +import { SKIP, visit } from "unist-util-visit" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root, Html, Paragraph, Text, Link, Parent } from "mdast" +import { Node } from "unist" +import { VFile } from "vfile" +import { BuildVisitor } from "unist-util-visit" + +export interface Options { + orComponent: boolean + TODOComponent: boolean + DONEComponent: boolean + videoComponent: boolean + audioComponent: boolean + pdfComponent: boolean + blockquoteComponent: boolean + tableComponent: boolean + attributeComponent: boolean +} + +const defaultOptions: Options = { + orComponent: true, + TODOComponent: true, + DONEComponent: true, + videoComponent: true, + audioComponent: true, + pdfComponent: true, + blockquoteComponent: true, + tableComponent: true, + attributeComponent: true, +} + +const orRegex = new RegExp(/{{or:(.*?)}}/, "g") +const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g") +const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") +const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g") +const youtubeRegex = new RegExp( + /{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/, + "g", +) + +// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g") + +const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g") +const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g") +const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") +const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") +const roamItalicRegex = new RegExp(/__(.+)__/, "g") +const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */ +const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */ + +function isSpecialEmbed(node: Paragraph): boolean { + if (node.children.length !== 2) return false + + const [textNode, linkNode] = node.children + return ( + textNode.type === "text" && + textNode.value.startsWith("{{[[") && + linkNode.type === "link" && + linkNode.children[0].type === "text" && + linkNode.children[0].value.endsWith("}}") + ) +} + +function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null { + const [textNode, linkNode] = node.children as [Text, Link] + const embedType = textNode.value.match(/\{\{\[\[(.*?)\]\]:/)?.[1]?.toLowerCase() + const url = linkNode.url.slice(0, -2) // Remove the trailing '}}' + + switch (embedType) { + case "audio": + return opts.audioComponent + ? { + type: "html", + value: ``, + } + : null + case "video": + if (!opts.videoComponent) return null + // Check if it's a YouTube video + const youtubeMatch = url.match( + /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/, + ) + if (youtubeMatch) { + const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters + const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/) + const playlistId = playlistMatch ? playlistMatch[1] : null + + return { + type: "html", + value: ``, + } + } else { + return { + type: "html", + value: ``, + } + } + case "pdf": + return opts.pdfComponent + ? { + type: "html", + value: ``, + } + : null + default: + return null + } +} + +export const RoamFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( + userOpts, +) => { + const opts = { ...defaultOptions, ...userOpts } + + return { + name: "RoamFlavoredMarkdown", + markdownPlugins() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + // Handle special embeds (audio, video, PDF) + if (opts.audioComponent || opts.videoComponent || opts.pdfComponent) { + visit(tree, "paragraph", ((node: Paragraph, index: number, parent: Parent | null) => { + if (isSpecialEmbed(node)) { + const transformedNode = transformSpecialEmbed(node, opts) + if (transformedNode && parent) { + parent.children[index] = transformedNode + } + } + }) as BuildVisitor) + } + + // Roam italic syntax + replacements.push([ + roamItalicRegex, + (_value: string, match: string) => ({ + type: "emphasis", + children: [{ type: "text", value: match }], + }), + ]) + + // Roam highlight syntax + replacements.push([ + roamHighlightRegex, + (_value: string, inner: string) => ({ + type: "html", + value: `${inner}`, + }), + ]) + + if (opts.orComponent) { + replacements.push([ + orRegex, + (match: string) => { + const matchResult = match.match(/{{or:(.*?)}}/) + if (matchResult === null) { + return { type: "html", value: "" } + } + const optionsString: string = matchResult[1] + const options: string[] = optionsString.split("|") + const selectHtml: string = `` + return { type: "html", value: selectHtml } + }, + ]) + } + + if (opts.TODOComponent) { + replacements.push([ + TODORegex, + () => ({ + type: "html", + value: ``, + }), + ]) + } + + if (opts.DONEComponent) { + replacements.push([ + DONERegex, + () => ({ + type: "html", + value: ``, + }), + ]) + } + + if (opts.blockquoteComponent) { + replacements.push([ + blockquoteRegex, + (_match: string, _marker: string, content: string) => ({ + type: "html", + value: `
${content.trim()}
`, + }), + ]) + } + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +}