Merge commit '76f2664277e07a7d1b011fac236840c6e8e69fdd' into v4
This commit is contained in:
59
quartz/plugins/emitters/404.tsx
Normal file
59
quartz/plugins/emitters/404.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FilePath, FullSlug } from "../../util/path"
|
||||
import { sharedPageComponents } from "../../../quartz.layout"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
|
||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
pageBody: NotFound(),
|
||||
beforeBody: [],
|
||||
left: [],
|
||||
right: [],
|
||||
}
|
||||
|
||||
const { head: Head, pageBody, footer: Footer } = opts
|
||||
const Body = BodyConstructor()
|
||||
|
||||
return {
|
||||
name: "404Page",
|
||||
getQuartzComponents() {
|
||||
return [Head, Body, pageBody, Footer]
|
||||
},
|
||||
async emit(ctx, _content, resources, emit): 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 [tree, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: "Not Found",
|
||||
description: "Not Found",
|
||||
frontmatter: { title: "Not Found", tags: [] },
|
||||
})
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: vfile.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles: [],
|
||||
}
|
||||
|
||||
return [
|
||||
await emit({
|
||||
content: renderPage(slug, componentData, opts, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
}),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import path from "path"
|
||||
|
||||
@ -12,15 +12,25 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
|
||||
for (const [_tree, file] of content) {
|
||||
const ogSlug = simplifySlug(file.data.slug!)
|
||||
const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory)
|
||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||
|
||||
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
|
||||
if (typeof aliases === "string") {
|
||||
aliases = [aliases]
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
const slug = path.posix.join(dir, alias) as FullSlug
|
||||
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||
const permalink = file.data.frontmatter?.permalink
|
||||
if (typeof permalink === "string") {
|
||||
slugs.push(permalink as FullSlug)
|
||||
}
|
||||
|
||||
for (let slug of slugs) {
|
||||
// fix any slugs that have trailing slash
|
||||
if (slug.endsWith("/")) {
|
||||
slug = joinSegments(slug, "index") as FullSlug
|
||||
}
|
||||
|
||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||
const fp = await emit({
|
||||
content: `
|
||||
|
@ -7,7 +7,7 @@ import spaRouterScript from "../../components/scripts/spa.inline"
|
||||
import plausibleScript from "../../components/scripts/plausible.inline"
|
||||
// @ts-ignore
|
||||
import popoverScript from "../../components/scripts/popover.inline"
|
||||
import styles from "../../styles/base.scss"
|
||||
import styles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
@ -96,6 +96,15 @@ function addGlobalPageResources(
|
||||
});`)
|
||||
} else if (cfg.analytics?.provider === "plausible") {
|
||||
componentResources.afterDOMLoaded.push(plausibleScript)
|
||||
} else if (cfg.analytics?.provider === "umami") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const umamiScript = document.createElement("script")
|
||||
umamiScript.src = "https://analytics.umami.is/script.js"
|
||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||
umamiScript.async = true
|
||||
|
||||
document.head.appendChild(umamiScript)
|
||||
`)
|
||||
}
|
||||
|
||||
if (cfg.enableSPA) {
|
||||
@ -107,12 +116,18 @@ function addGlobalPageResources(
|
||||
document.dispatchEvent(event)`)
|
||||
}
|
||||
|
||||
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
|
||||
|
||||
if (ctx.argv.remoteDevHost) {
|
||||
wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
|
||||
}
|
||||
|
||||
if (reloadScript) {
|
||||
staticResources.js.push({
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
script: `
|
||||
const socket = new WebSocket('ws://localhost:3001')
|
||||
const socket = new WebSocket('${wsUrl}')
|
||||
socket.addEventListener('message', () => document.location.reload())
|
||||
`,
|
||||
})
|
||||
@ -149,7 +164,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
|
||||
addGlobalPageResources(ctx, resources, componentResources)
|
||||
|
||||
const stylesheet = joinStyles(ctx.cfg.configuration.theme, styles, ...componentResources.css)
|
||||
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
|
||||
const prescript = joinScripts(componentResources.beforeDOMLoaded)
|
||||
const postscript = joinScripts(componentResources.afterDOMLoaded)
|
||||
const fps = await Promise.all([
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { Root } from "hast"
|
||||
import { GlobalConfiguration } from "../../cfg"
|
||||
import { getDate } from "../../components/Date"
|
||||
import { escapeHTML } from "../../util/escape"
|
||||
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import path from "path"
|
||||
|
||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||
@ -10,6 +13,7 @@ export type ContentDetails = {
|
||||
links: SimpleSlug[]
|
||||
tags: string[]
|
||||
content: string
|
||||
richContent?: string
|
||||
date?: Date
|
||||
description?: string
|
||||
}
|
||||
@ -17,19 +21,23 @@ export type ContentDetails = {
|
||||
interface Options {
|
||||
enableSiteMap: boolean
|
||||
enableRSS: boolean
|
||||
rssLimit?: number
|
||||
rssFullHtml: boolean
|
||||
includeEmptyFiles: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
enableSiteMap: true,
|
||||
enableRSS: true,
|
||||
rssLimit: 10,
|
||||
rssFullHtml: false,
|
||||
includeEmptyFiles: true,
|
||||
}
|
||||
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${base}/${slug}</loc>
|
||||
<loc>https://${base}/${encodeURI(slug)}</loc>
|
||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||
</url>`
|
||||
const urls = Array.from(idx)
|
||||
@ -38,27 +46,42 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||
}
|
||||
|
||||
function generateRSSFeed(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>${content.title}</title>
|
||||
<link>${root}/${slug}</link>
|
||||
<guid>${root}/${slug}</guid>
|
||||
<description>${content.description}</description>
|
||||
<title>${escapeHTML(content.title)}</title>
|
||||
<link>${root}/${encodeURI(slug)}</link>
|
||||
<guid>${root}/${encodeURI(slug)}</guid>
|
||||
<description>${content.richContent ?? content.description}</description>
|
||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||
</item>`
|
||||
|
||||
const items = Array.from(idx)
|
||||
.sort(([_, f1], [__, f2]) => {
|
||||
if (f1.date && f2.date) {
|
||||
return f2.date.getTime() - f1.date.getTime()
|
||||
} else if (f1.date && !f2.date) {
|
||||
return -1
|
||||
} else if (!f1.date && f2.date) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return f1.title.localeCompare(f2.title)
|
||||
})
|
||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||
.slice(0, limit ?? idx.size)
|
||||
.join("")
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${cfg.pageTitle}</title>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<link>${root}</link>
|
||||
<description>Recent content on ${cfg.pageTitle}</description>
|
||||
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
||||
cfg.pageTitle,
|
||||
)}</description>
|
||||
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||
${items}
|
||||
</channel>
|
||||
@ -73,7 +96,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const emitted: FilePath[] = []
|
||||
const linkIndex: ContentIndex = new Map()
|
||||
for (const [_tree, file] of content) {
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||
@ -82,6 +105,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
links: file.data.links ?? [],
|
||||
tags: file.data.frontmatter?.tags ?? [],
|
||||
content: file.data.text ?? "",
|
||||
richContent: opts?.rssFullHtml
|
||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||
: undefined,
|
||||
date: date,
|
||||
description: file.data.description ?? "",
|
||||
})
|
||||
@ -101,7 +127,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
if (opts?.enableRSS) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
content: generateRSSFeed(cfg, linkIndex),
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".xml",
|
||||
}),
|
||||
|
@ -4,9 +4,10 @@ import HeaderConstructor from "../../components/Header"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FilePath } from "../../util/path"
|
||||
import { FilePath, pathToRoot } from "../../util/path"
|
||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { Content } from "../../components"
|
||||
import chalk from "chalk"
|
||||
|
||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
@ -29,9 +30,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
const cfg = ctx.cfg.configuration
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
let containsIndex = false
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const externalResources = pageResources(slug, resources)
|
||||
if (slug === "index") {
|
||||
containsIndex = true
|
||||
}
|
||||
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: file.data,
|
||||
externalResources,
|
||||
@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
|
||||
fps.push(fp)
|
||||
}
|
||||
|
||||
if (!containsIndex) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fps
|
||||
},
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
SimpleSlug,
|
||||
_stripSlashes,
|
||||
joinSegments,
|
||||
pathToRoot,
|
||||
simplifySlug,
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
|
||||
for (const folder of folders) {
|
||||
const slug = joinSegments(folder, "index") as FullSlug
|
||||
const externalResources = pageResources(slug, resources)
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const [tree, file] = folderDescriptions[folder]
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: file.data,
|
||||
|
@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases"
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
export { NotFoundPage } from "./404"
|
||||
|
@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path"
|
||||
import {
|
||||
FilePath,
|
||||
FullSlug,
|
||||
getAllSegmentPrefixes,
|
||||
joinSegments,
|
||||
pathToRoot,
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { TagContent } from "../../components"
|
||||
|
||||
@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
|
||||
for (const tag of tags) {
|
||||
const slug = joinSegments("tags", tag) as FullSlug
|
||||
const externalResources = pageResources(slug, resources)
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const [tree, file] = tagDescriptions[tag]
|
||||
const componentData: QuartzComponentProps = {
|
||||
fileData: file.data,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Root as HTMLRoot } from "hast"
|
||||
import { toString } from "hast-util-to-string"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { escapeHTML } from "../../util/escape"
|
||||
|
||||
export interface Options {
|
||||
descriptionLength: number
|
||||
@ -10,15 +11,6 @@ const defaultOptions: Options = {
|
||||
descriptionLength: 150,
|
||||
}
|
||||
|
||||
const escapeHTML = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
|
@ -2,14 +2,17 @@ import matter from "gray-matter"
|
||||
import remarkFrontmatter from "remark-frontmatter"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import yaml from "js-yaml"
|
||||
import toml from "toml"
|
||||
import { slugTag } from "../../util/path"
|
||||
|
||||
export interface Options {
|
||||
delims: string | string[]
|
||||
language: "yaml" | "toml"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
delims: "---",
|
||||
language: "yaml",
|
||||
}
|
||||
|
||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
name: "FrontMatter",
|
||||
markdownPlugins() {
|
||||
return [
|
||||
remarkFrontmatter,
|
||||
[remarkFrontmatter, ["yaml", "toml"]],
|
||||
() => {
|
||||
return (_, file) => {
|
||||
const { data } = matter(file.value, {
|
||||
...opts,
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||
toml: (s) => toml.parse(s) as object,
|
||||
},
|
||||
})
|
||||
|
||||
@ -33,6 +37,11 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
data.tags = data.tag
|
||||
}
|
||||
|
||||
// coerce title to string
|
||||
if (data.title) {
|
||||
data.title = data.title.toString()
|
||||
}
|
||||
|
||||
if (data.tags && !Array.isArray(data.tags)) {
|
||||
data.tags = data.tags
|
||||
.toString()
|
||||
|
@ -5,5 +5,7 @@ export { Latex } from "./latex"
|
||||
export { Description } from "./description"
|
||||
export { CrawlLinks } from "./links"
|
||||
export { ObsidianFlavoredMarkdown } from "./ofm"
|
||||
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
||||
export { SyntaxHighlighting } from "./syntax"
|
||||
export { TableOfContents } from "./toc"
|
||||
export { HardLineBreaks } from "./linebreaks"
|
||||
|
@ -2,6 +2,7 @@ import fs from "fs"
|
||||
import path from "path"
|
||||
import { Repository } from "@napi-rs/simple-git"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import chalk from "chalk"
|
||||
|
||||
export interface Options {
|
||||
priority: ("frontmatter" | "git" | "filesystem")[]
|
||||
@ -11,6 +12,20 @@ const defaultOptions: Options = {
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}
|
||||
|
||||
function coerceDate(fp: string, d: any): Date {
|
||||
const dt = new Date(d)
|
||||
const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
|
||||
if (invalidDate && d !== undefined) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return invalidDate ? new Date() : dt
|
||||
}
|
||||
|
||||
type MaybeDate = undefined | string | number
|
||||
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@ -27,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
||||
let modified: MaybeDate = undefined
|
||||
let published: MaybeDate = undefined
|
||||
|
||||
const fp = path.posix.join(file.cwd, file.data.filePath as string)
|
||||
const fp = file.data.filePath!
|
||||
const fullFp = path.posix.join(file.cwd, fp)
|
||||
for (const source of opts.priority) {
|
||||
if (source === "filesystem") {
|
||||
const st = await fs.promises.stat(fp)
|
||||
const st = await fs.promises.stat(fullFp)
|
||||
created ||= st.birthtimeMs
|
||||
modified ||= st.mtimeMs
|
||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||
@ -49,9 +65,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
||||
}
|
||||
|
||||
file.data.dates = {
|
||||
created: created ? new Date(created) : new Date(),
|
||||
modified: modified ? new Date(modified) : new Date(),
|
||||
published: published ? new Date(published) : new Date(),
|
||||
created: coerceDate(fp, created),
|
||||
modified: coerceDate(fp, modified),
|
||||
published: coerceDate(fp, published),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
11
quartz/plugins/transformers/linebreaks.ts
Normal file
11
quartz/plugins/transformers/linebreaks.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
|
||||
export const HardLineBreaks: QuartzTransformerPlugin = () => {
|
||||
return {
|
||||
name: "HardLineBreaks",
|
||||
markdownPlugins() {
|
||||
return [remarkBreaks]
|
||||
},
|
||||
}
|
||||
}
|
@ -5,7 +5,6 @@ import {
|
||||
SimpleSlug,
|
||||
TransformOptions,
|
||||
_stripSlashes,
|
||||
joinSegments,
|
||||
simplifySlug,
|
||||
splitAnchor,
|
||||
transformLink,
|
||||
@ -19,11 +18,13 @@ interface Options {
|
||||
markdownLinkResolution: TransformOptions["strategy"]
|
||||
/** Strips folders from a link so that it looks nice */
|
||||
prettyLinks: boolean
|
||||
openLinksInNewTab: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
markdownLinkResolution: "absolute",
|
||||
prettyLinks: true,
|
||||
openLinksInNewTab: false,
|
||||
}
|
||||
|
||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
@ -53,8 +54,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
node.properties.className ??= []
|
||||
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
||||
|
||||
if (opts.openLinksInNewTab) {
|
||||
node.properties.target = "_blank"
|
||||
}
|
||||
|
||||
// don't process external links or intra-document anchors
|
||||
if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) {
|
||||
const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
|
||||
if (isInternal) {
|
||||
dest = node.properties.href = transformLink(
|
||||
file.data.slug!,
|
||||
dest,
|
||||
@ -72,11 +78,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
simplifySlug(destCanonical as FullSlug),
|
||||
) as SimpleSlug
|
||||
outgoing.add(simple)
|
||||
node.properties["data-slug"] = simple
|
||||
}
|
||||
|
||||
// rewrite link internals if prettylinks is on
|
||||
if (
|
||||
opts.prettyLinks &&
|
||||
isInternal &&
|
||||
node.children.length === 1 &&
|
||||
node.children[0].type === "text" &&
|
||||
!node.children[0].value.startsWith("#")
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PluggableList } from "unified"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
|
||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
||||
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
@ -13,6 +14,7 @@ import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
||||
import { toHast } from "mdast-util-to-hast"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
||||
import { capitalize } from "../../util/lang"
|
||||
|
||||
export interface Options {
|
||||
comments: boolean
|
||||
@ -21,6 +23,7 @@ export interface Options {
|
||||
callouts: boolean
|
||||
mermaid: boolean
|
||||
parseTags: boolean
|
||||
parseBlockReferences: boolean
|
||||
enableInHtmlEmbed: boolean
|
||||
}
|
||||
|
||||
@ -31,6 +34,7 @@ const defaultOptions: Options = {
|
||||
callouts: true,
|
||||
mermaid: true,
|
||||
parseTags: true,
|
||||
parseBlockReferences: true,
|
||||
enableInHtmlEmbed: false,
|
||||
}
|
||||
|
||||
@ -69,6 +73,8 @@ const callouts = {
|
||||
const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||
note: "note",
|
||||
abstract: "abstract",
|
||||
summary: "abstract",
|
||||
tldr: "abstract",
|
||||
info: "info",
|
||||
todo: "todo",
|
||||
tip: "tip",
|
||||
@ -96,11 +102,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||
|
||||
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
||||
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||
return calloutMapping[callout] ?? calloutName
|
||||
}
|
||||
|
||||
const capitalize = (s: string): string => {
|
||||
return s.substring(0, 1).toUpperCase() + s.substring(1)
|
||||
return calloutMapping[callout] ?? "note"
|
||||
}
|
||||
|
||||
// !? -> optional embedding
|
||||
@ -109,14 +111,17 @@ const capitalize = (s: string): string => {
|
||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
||||
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
|
||||
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
|
||||
const highlightRegex = new RegExp(/==(.+)==/, "g")
|
||||
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
|
||||
const commentRegex = new RegExp(/%%(.+)%%/, "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")
|
||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
||||
// #(\w+) -> tag itself is # followed by a string of alpha-numeric characters
|
||||
const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu")
|
||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
||||
// #(...) -> capturing group, tag itself must start with #
|
||||
// (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores
|
||||
// (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
|
||||
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
|
||||
|
||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@ -230,8 +235,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
value: `<iframe src="${url}"></iframe>`,
|
||||
}
|
||||
} else if (ext === "") {
|
||||
// TODO: note embed
|
||||
const block = anchor
|
||||
return {
|
||||
type: "html",
|
||||
data: { hProperties: { transclude: true } },
|
||||
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
|
||||
url + anchor
|
||||
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, fall through to regular link
|
||||
}
|
||||
|
||||
@ -320,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
|
||||
const titleHtml: HTML = {
|
||||
type: "html",
|
||||
value: `<div
|
||||
value: `<div
|
||||
class="callout-title"
|
||||
>
|
||||
<div class="callout-icon">${callouts[calloutType]}</div>
|
||||
@ -383,13 +396,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
return (tree: Root, file) => {
|
||||
const base = pathToRoot(file.data.slug!)
|
||||
findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
|
||||
// Check if the tag only includes numbers
|
||||
if (/^\d+$/.test(tag)) {
|
||||
return false
|
||||
}
|
||||
tag = slugTag(tag)
|
||||
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
|
||||
file.data.frontmatter.tags.push(tag)
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
url: base + `/tags/${slugTag(tag)}`,
|
||||
url: base + `/tags/${tag}`,
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["tag-link"],
|
||||
@ -406,11 +424,64 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
htmlPlugins() {
|
||||
return [rehypeRaw]
|
||||
const plugins = [rehypeRaw]
|
||||
|
||||
if (opts.parseBlockReferences) {
|
||||
plugins.push(() => {
|
||||
const inlineTagTypes = new Set(["p", "li"])
|
||||
const blockTagTypes = new Set(["blockquote"])
|
||||
return (tree, file) => {
|
||||
file.data.blocks = {}
|
||||
file.data.htmlAst = tree
|
||||
|
||||
visit(tree, "element", (node, index, parent) => {
|
||||
if (blockTagTypes.has(node.tagName)) {
|
||||
const nextChild = parent?.children.at(index! + 2) as Element
|
||||
if (nextChild && nextChild.tagName === "p") {
|
||||
const text = nextChild.children.at(0) as Literal
|
||||
if (text && text.value && text.type === "text") {
|
||||
const matches = text.value.match(blockReferenceRegex)
|
||||
if (matches && matches.length >= 1) {
|
||||
parent!.children.splice(index! + 2, 1)
|
||||
const block = matches[0].slice(1)
|
||||
|
||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
id: block,
|
||||
}
|
||||
file.data.blocks![block] = node
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (inlineTagTypes.has(node.tagName)) {
|
||||
const last = node.children.at(-1) as Literal
|
||||
if (last && last.value && typeof last.value === "string") {
|
||||
const matches = last.value.match(blockReferenceRegex)
|
||||
if (matches && matches.length >= 1) {
|
||||
last.value = last.value.slice(0, -matches[0].length)
|
||||
const block = matches[0].slice(1)
|
||||
|
||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
id: block,
|
||||
}
|
||||
file.data.blocks![block] = node
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
externalResources() {
|
||||
const js: JSResource[] = []
|
||||
@ -428,7 +499,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
script: `
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
|
||||
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
|
||||
mermaid.initialize({
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
theme: darkMode ? 'dark' : 'default'
|
||||
@ -449,3 +520,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
blocks: Record<string, Element>
|
||||
htmlAst: HtmlRoot
|
||||
}
|
||||
}
|
||||
|
108
quartz/plugins/transformers/oxhugofm.ts
Normal file
108
quartz/plugins/transformers/oxhugofm.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
|
||||
export interface Options {
|
||||
/** Replace {{ relref }} with quartz wikilinks []() */
|
||||
wikilinks: boolean
|
||||
/** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
|
||||
removePredefinedAnchor: boolean
|
||||
/** Remove hugo shortcode syntax */
|
||||
removeHugoShortcode: boolean
|
||||
/** Replace <figure/> with ![]() */
|
||||
replaceFigureWithMdImg: boolean
|
||||
|
||||
/** Replace org latex fragments with $ and $$ */
|
||||
replaceOrgLatex: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
wikilinks: true,
|
||||
removePredefinedAnchor: true,
|
||||
removeHugoShortcode: true,
|
||||
replaceFigureWithMdImg: true,
|
||||
replaceOrgLatex: true,
|
||||
}
|
||||
|
||||
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
|
||||
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
|
||||
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
|
||||
const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
|
||||
// \\\\\( -> matches \\(
|
||||
// (.+?) -> Lazy match for capturing the equation
|
||||
// \\\\\) -> matches \\)
|
||||
const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g")
|
||||
// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation
|
||||
// ([\s\S]*?) -> Matches the block equation
|
||||
// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation
|
||||
const blockLatexRegex = new RegExp(
|
||||
/(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/,
|
||||
"g",
|
||||
)
|
||||
// \$\$[\s\S]*?\$\$ -> Matches block equations
|
||||
// \$.*?\$ -> Matches inline equations
|
||||
const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g")
|
||||
|
||||
/**
|
||||
* ox-hugo is an org exporter backend that exports org files to hugo-compatible
|
||||
* markdown in an opinionated way. This plugin adds some tweaks to the generated
|
||||
* markdown to make it compatible with quartz but the list of changes applied it
|
||||
* is not exhaustive.
|
||||
* */
|
||||
export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "OxHugoFlavouredMarkdown",
|
||||
textTransform(_ctx, src) {
|
||||
if (opts.wikilinks) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(relrefRegex, (value, ...capture) => {
|
||||
const [text, link] = capture
|
||||
return `[${text}](${link})`
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.removePredefinedAnchor) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
|
||||
const [headingText] = capture
|
||||
return headingText
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.removeHugoShortcode) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
|
||||
const [scContent] = capture
|
||||
return scContent
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.replaceFigureWithMdImg) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(figureTagRegex, (value, ...capture) => {
|
||||
const [src] = capture
|
||||
return ``
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.replaceOrgLatex) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
|
||||
const [eqn] = capture
|
||||
return `$${eqn}$`
|
||||
})
|
||||
src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
|
||||
const [eqn] = capture
|
||||
return `$$${eqn}$$`
|
||||
})
|
||||
|
||||
// ox-hugo escapes _ as \_
|
||||
src = src.replaceAll(quartzLatexRegex, (value) => {
|
||||
return value.replaceAll("\\_", "_")
|
||||
})
|
||||
}
|
||||
return src
|
||||
},
|
||||
}
|
||||
}
|
@ -8,12 +8,14 @@ export interface Options {
|
||||
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
|
||||
minEntries: 1
|
||||
showByDefault: boolean
|
||||
collapseByDefault: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
maxDepth: 3,
|
||||
minEntries: 1,
|
||||
showByDefault: true,
|
||||
collapseByDefault: false,
|
||||
}
|
||||
|
||||
interface TocEntry {
|
||||
@ -54,6 +56,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
|
||||
...entry,
|
||||
depth: entry.depth - highestDepth,
|
||||
}))
|
||||
file.data.collapseToc = opts.collapseByDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,5 +69,6 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
toc: TocEntry[]
|
||||
collapseToc: boolean
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user