diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md index fcc88a7bc..65209a2ca 100644 --- a/docs/advanced/making plugins.md +++ b/docs/advanced/making plugins.md @@ -216,22 +216,19 @@ export type QuartzEmitterPlugin = ( export type QuartzEmitterPluginInstance = { name: string - emit( - ctx: BuildCtx, - content: ProcessedContent[], - resources: StaticResources, - emitCallback: EmitCallback, - ): Promise + emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise getQuartzComponents(ctx: BuildCtx): QuartzComponent[] } ``` -An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created. +An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created. -Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. Its interface looks something like this: +Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature: ```ts -export type EmitCallback = (data: { +export type WriteOptions = (data: { + // the build context + ctx: BuildCtx // the name of the file to emit (not including the file extension) slug: ServerSlug // the file extension diff --git a/docs/authoring content.md b/docs/authoring content.md new file mode 100644 index 000000000..248214037 --- /dev/null +++ b/docs/authoring content.md @@ -0,0 +1,48 @@ +--- +title: Authoring Content +--- + +All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initialized. Any Markdown in this folder will get processed by Quartz. + +It is recommended that you use [Obsidian](https://obsidian.md/) as a way to edit and maintain your Quartz. It comes with a nice editor and graphical interface to preview, edit, and link your local files and attachments. + +Got everything setup? Let's [[build]] and preview your Quartz locally! + +## Syntax + +As Quartz uses Markdown files as the main way of writing content, it fully supports Markdown syntax. By default, Quartz also ships with a few syntax extensions like [Github Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) (footnotes, strikethrough, tables, tasklists) and [Obsidian Flavored Markdown](https://help.obsidian.md/Editing+and+formatting/Obsidian+Flavored+Markdown) ([[callouts]], [[wikilinks]]). + +Additionally, Quartz also allows you to specify additional metadata in your notes called **frontmatter**. + +```md title="content/note.md" +--- +title: Example Title +draft: false +tags: + - example-tag +--- + +The rest of your content lives here. You can use **Markdown** here :) +``` + +Some common frontmatter fields that are natively supported by Quartz: + +- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title. +- `aliases`: Other names for this note. This is a list of strings. +- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz. +- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format. + +## Syncing your Content + +When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`. + +> [!hint] Flags and options +> For full help options, you can run `npx quartz sync --help`. +> +> Most of these have sensible defaults but you can override them if you have a custom setup: +> +> - `-d` or `--directory`: the content folder. This is normally just `content` +> - `-v` or `--verbose`: print out extra logging information +> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes +> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz +> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing diff --git a/docs/features/breadcrumbs.md b/docs/features/breadcrumbs.md index a241aac41..a70185836 100644 --- a/docs/features/breadcrumbs.md +++ b/docs/features/breadcrumbs.md @@ -20,7 +20,7 @@ Component.Breadcrumbs({ rootName: "Home", // name of first/root element resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page - showCurrentPage: true, // wether to display the current page in the breadcrumbs + showCurrentPage: true, // whether to display the current page in the breadcrumbs }) ``` diff --git a/docs/features/explorer.md b/docs/features/explorer.md index f4d54faaf..b5fd379a7 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -26,7 +26,7 @@ Component.Explorer({ title: "Explorer", // title of the explorer component folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open") - useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer + useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer // Sort order: folders first, then files. Sort folders and files alphabetically sortFn: (a, b) => { ... // default implementation shown later diff --git a/docs/hosting.md b/docs/hosting.md index 9761e5b45..e6340d293 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -225,6 +225,6 @@ pages: - public ``` -When `.gitlab-ci.yaml` is commited, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy > Pages` in the sidebar. +When `.gitlab-ci.yaml` is committed, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy > Pages` in the sidebar. By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`. diff --git a/docs/showcase.md b/docs/showcase.md index ca5737b78..16235e46a 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -23,5 +23,6 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Caicai's Novels](https://imoko.cc/blog/caicai/) - [🌊 Collapsed Wave](https://collapsedwave.com/) - [Aaron Pham's Garden](https://aarnphm.xyz/) +- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/) 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/quartz.layout.ts b/quartz.layout.ts index c692fff6c..60ef1f715 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -48,7 +48,7 @@ export const defaultContentPageLayout: PageLayout = { // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle()], + beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], left: [ Component.PageTitle(), Component.Search(), diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 37762a4fb..6f8aad1ab 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -450,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 } @@ -522,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 } diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 175f6f39d..0497b6458 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -18,15 +18,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 } diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index e4251af8d..d2e3c23de 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -35,12 +35,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 @@ -122,7 +122,10 @@ document.addEventListener("nav", async (e: unknown) => { // add "#" prefix for tag search if (searchBar) searchBar.value = "#" - } else if (e.key === "Enter") { + } + + if (!container?.classList.contains("active")) return + else 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 @@ -196,7 +199,7 @@ document.addEventListener("nav", async (e: unknown) => { const termLower = term.toLowerCase() let matching = tags.filter((str) => str.includes(termLower)) - // Substract matching from original tags, then push difference + // Subtract matching from original tags, then push difference if (matching.length > 0) { let difference = tags.filter((x) => !matching.includes(x)) diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index cd079a065..58ae59a4b 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -7,6 +7,7 @@ import { FilePath, FullSlug } from "../../util/path" import { sharedPageComponents } from "../../../quartz.layout" import { NotFound } from "../../components" import { defaultProcessedContent } from "../vfile" +import { write } from "./helpers" export const NotFoundPage: QuartzEmitterPlugin = () => { const opts: FullPageLayout = { @@ -25,7 +26,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { getQuartzComponents() { return [Head, Body, pageBody, Footer] }, - async emit(ctx, _content, resources, emit): Promise { + async emit(ctx, _content, resources): Promise { const cfg = ctx.cfg.configuration const slug = "404" as FullSlug @@ -48,7 +49,8 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { } return [ - await emit({ + await write({ + ctx, content: renderPage(slug, componentData, opts, externalResources), slug, ext: ".html", diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index 00c0c4541..36b6d8857 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -1,13 +1,15 @@ 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 { + async emit(ctx, content, _resources): Promise { + const { argv } = ctx const fps: FilePath[] = [] for (const [_tree, file] of content) { @@ -32,7 +34,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ } const redirUrl = resolveRelative(slug, file.data.slug!) - const fp = await emit({ + const fp = await write({ + ctx, content: ` diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index edc22d9e9..cc97b2e3e 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => { getQuartzComponents() { return [] }, - async emit({ argv, cfg }, _content, _resources, _emit): Promise { + 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]) diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index ffe2c6d12..3e17fea2e 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({ getQuartzComponents() { return [] }, - async emit({ argv, cfg }, _content, _resources, _emit): Promise { + async emit({ argv, cfg }, _content, _resources): Promise { if (!cfg.configuration.baseUrl) { console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) return [] diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index e8a81bc0b..f92c0a9b8 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -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,7 @@ 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, @@ -121,7 +122,7 @@ function addGlobalPageResources( umamiScript.src = "https://analytics.umami.is/script.js" umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.async = true - + document.head.appendChild(umamiScript) `) } @@ -168,7 +169,7 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< getQuartzComponents() { return [] }, - async emit(ctx, _content, resources, emit): Promise { + async emit(ctx, _content, resources): Promise { // component specific scripts and styles const componentResources = getComponentResources(ctx) // important that this goes *after* component scripts @@ -190,7 +191,8 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< ]) const fps = await Promise.all([ - emit({ + write({ + ctx, slug: "index" as FullSlug, ext: ".css", content: transform({ @@ -207,12 +209,14 @@ export const ComponentResources: QuartzEmitterPlugin = (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, diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index bc4c6c325..31e1d3e2a 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -6,6 +6,7 @@ import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../. import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import path from "path" +import { write } from "./helpers" export type ContentIndex = Map export type ContentDetails = { @@ -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 => ` ${escapeHTML(content.title)} - ${joinSegments(root, encodeURI(slug))} - ${joinSegments(root, encodeURI(slug))} + https://${joinSegments(base, encodeURI(slug))} + https://${joinSegments(base, encodeURI(slug))} ${content.richContent ?? content.description} ${content.date?.toUTCString()} ` @@ -78,7 +78,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${escapeHTML(cfg.pageTitle)} - ${root} + https://${base} ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML( cfg.pageTitle, )} @@ -92,7 +92,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (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> = (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> = (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> = (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> = (opts) => { ) emitted.push( - await emit({ + await write({ + ctx, content: JSON.stringify(simplifiedIndex), slug: fp, ext: ".json", diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 338bfae44..f8e640473 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -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> = (userOpts) => { const opts: FullPageLayout = { @@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, - async emit(ctx, content, resources, emit): Promise { + async emit(ctx, content, resources): Promise { const cfg = ctx.cfg.configuration const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) @@ -49,7 +50,8 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp } const content = renderPage(slug, componentData, opts, externalResources) - const fp = await emit({ + const fp = await write({ + ctx, content, slug, ext: ".html", diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 8632eceb4..a4bd1aed2 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -17,6 +17,7 @@ import { } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { FolderContent } from "../../components" +import { write } from "./helpers" export const FolderPage: QuartzEmitterPlugin = (userOpts) => { const opts: FullPageLayout = { @@ -35,7 +36,7 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => { getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, - async emit(ctx, content, resources, emit): Promise { + async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration @@ -82,7 +83,8 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => { } const content = renderPage(slug, componentData, opts, externalResources) - const fp = await emit({ + const fp = await write({ + ctx, content, slug, ext: ".html", diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts new file mode 100644 index 000000000..ef1d1c35c --- /dev/null +++ b/quartz/plugins/emitters/helpers.ts @@ -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 => { + 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 +} diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index f0118e2e8..9f93d9b0a 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({ getQuartzComponents() { return [] }, - async emit({ argv, cfg }, _content, _resources, _emit): Promise { + async emit({ argv, cfg }, _content, _resources): Promise { const staticPath = joinSegments(QUARTZ, "static") const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 566911983..56a552c69 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -14,6 +14,7 @@ import { } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" +import { write } from "./helpers" export const TagPage: QuartzEmitterPlugin = (userOpts) => { const opts: FullPageLayout = { @@ -32,7 +33,7 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => { getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, - async emit(ctx, content, resources, emit): Promise { + async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration @@ -81,7 +82,8 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => { } const content = renderPage(slug, componentData, opts, externalResources) - const fp = await emit({ + const fp = await write({ + ctx, content, slug: file.data.slug!, ext: ".html", diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 50d2d1a0a..1ba0c8e58 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -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 | undefined> = (userOpts) => { @@ -55,7 +57,29 @@ export const CrawlLinks: QuartzTransformerPlugin | 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 ( diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 47a9cb1b7..ca6b8e090 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,10 +1,10 @@ import { QuartzTransformerPlugin } from "../types" -import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast" +import { Root, Html, Image, BlockContent, DefinitionContent, Paragraph, Code } from "mdast" 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 @@ -26,6 +26,7 @@ export interface Options { parseBlockReferences: boolean enableInHtmlEmbed: boolean enableYouTubeEmbed: boolean + enableVideoEmbed: boolean } const defaultOptions: Options = { @@ -37,7 +38,8 @@ const defaultOptions: Options = { parseTags: true, parseBlockReferences: true, enableInHtmlEmbed: false, - enableYouTubeEmbed: false, + enableYouTubeEmbed: true, + enableVideoEmbed: true, } const icons = { @@ -130,6 +132,7 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") 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)$/) export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -150,7 +153,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin src = src.toString() } - src = src.replaceAll(calloutLineRegex, (value) => { + src = src.replace(calloutLineRegex, (value) => { // force newline after title of callout return value + "\n> " }) @@ -162,7 +165,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin 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 ?? "" @@ -236,7 +239,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin type: "html", value: ``, } - } else if (ext === "") { + } else { const block = anchor return { type: "html", @@ -330,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin 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 @@ -346,11 +349,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } }) } - 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: ``, + } + + parent.children.splice(index, 1, newNode) + return SKIP + } + }) + } + }) + } + if (opts.callouts) { plugins.push(() => { return (tree: Root, _file) => { @@ -366,7 +386,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } 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") @@ -382,7 +402,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) const titleNode: Paragraph = { type: "paragraph", - children: [{ type: "text", value: titleContent + " " }, ...restChildren], + children: + restOfTitle.length === 0 + ? [{ type: "text", value: titleContent + " " }] + : restOfTitle, } const title = mdastToHtml(titleNode) diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index eaeb12aec..bf1c0db0e 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -36,19 +36,6 @@ export type QuartzEmitterPlugin = ( ) => QuartzEmitterPluginInstance export type QuartzEmitterPluginInstance = { name: string - emit( - ctx: BuildCtx, - content: ProcessedContent[], - resources: StaticResources, - emitCallback: EmitCallback, - ): Promise + emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise getQuartzComponents(ctx: BuildCtx): QuartzComponent[] } - -export interface EmitOptions { - slug: FullSlug - ext: `.${string}` | "" - content: string -} - -export type EmitCallback = (data: EmitOptions) => Promise diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts index 3b357aa94..c68e0edeb 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -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) { diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index c5b526884..bc6d0bae7 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -77,6 +77,7 @@ a { background-color: var(--highlight); padding: 0 0.1rem; border-radius: 5px; + line-height: 1.4rem; &:has(> img) { background-color: none; @@ -84,6 +85,15 @@ a { padding: 0; } } + + &.external .external-icon { + height: 1ex; + margin: 0 0.15em; + + > path { + fill: var(--dark); + } + } } .desktop-only { diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index 18edc9407..6a8aa3d1d 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -105,6 +105,9 @@ 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-q"], + ["test/special chars #3.md", "test/special-chars-3"], ], path.slugifyFilePath, path.isFilePath, diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 6cedffdb6..95acf119d 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -50,7 +50,9 @@ 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, "-percent").replace(/\?/g, "-q").replace(/#/g, ""), + ) // slugify all segments .join("/") // always use / as sep .replace(/\/$/, "") }