merge
All checks were successful
Build / build (push) Successful in 2m11s

This commit is contained in:
2024-02-05 19:45:36 +09:00
parent 3b46993254
commit 312a2330c3
79 changed files with 2232 additions and 1347 deletions

View File

@ -7,6 +7,8 @@ import { FilePath, FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
@ -25,18 +27,19 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return [Head, Body, pageBody, Footer]
},
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
async emit(ctx, _content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const externalResources = pageResources(path, resources)
const notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({
slug,
text: "Not Found",
description: "Not Found",
frontmatter: { title: "Not Found", tags: [] },
text: notFound,
description: notFound,
frontmatter: { title: notFound, tags: [] },
})
const componentData: QuartzComponentProps = {
fileData: vfile.data,
@ -48,8 +51,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
}
return [
await emit({
content: renderPage(slug, componentData, opts, externalResources),
await write({
ctx,
content: renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
}),

View File

@ -1,24 +1,21 @@
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
import { write } from "./helpers"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
getQuartzComponents() {
return []
},
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
async emit(ctx, content, _resources): Promise<FilePath[]> {
const { argv } = ctx
const fps: FilePath[] = []
for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") {
aliases = [aliases]
}
const aliases = file.data.frontmatter?.aliases ?? []
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") {
@ -32,7 +29,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
}
const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({
const fp = await write({
ctx,
content: `
<!DOCTYPE html>
<html lang="en-us">

View File

@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
// glob all non MD/MDX/HTML files in content folder and copy it over
const assetsPath = argv.output
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])

View File

@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []

View File

@ -13,6 +13,7 @@ import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
type ComponentResources = {
css: string[]
@ -93,7 +94,6 @@ function addGlobalPageResources(
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "${tagId}", { send_page_view: false });
document.addEventListener("nav", () => {
gtag("event", "page_view", {
page_title: document.title,
@ -118,10 +118,10 @@ function addGlobalPageResources(
} else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script")
umamiScript.src = "https://analytics.umami.is/script.js"
umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.async = true
document.head.appendChild(umamiScript)
`)
}
@ -130,9 +130,11 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(spaRouterScript)
} else {
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)`)
window.spaNavigate = (url, _) => window.location.assign(url)
window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)
`)
}
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
@ -146,9 +148,9 @@ function addGlobalPageResources(
loadTime: "afterDOMReady",
contentType: "inline",
script: `
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
})
}
}
@ -168,7 +170,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
getQuartzComponents() {
return []
},
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
async emit(ctx, _content, resources): Promise<FilePath[]> {
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
// important that this goes *after* component scripts
@ -190,7 +192,8 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
])
const fps = await Promise.all([
emit({
write({
ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
@ -207,12 +210,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
include: Features.MediaQueries,
}).code.toString(),
}),
emit({
write({
ctx,
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
}),
emit({
write({
ctx,
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,

View File

@ -5,7 +5,8 @@ import { escapeHTML } from "../../util/escape"
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import path from "path"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = {
@ -38,7 +39,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
<lastmod>${content.date?.toISOString()}</lastmod>
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
@ -48,12 +49,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
const base = cfg.baseUrl ?? ""
const root = `https://${base}`
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
<title>${escapeHTML(content.title)}</title>
<link>${joinSegments(root, encodeURI(slug))}</link>
<guid>${joinSegments(root, encodeURI(slug))}</guid>
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toUTCString()}</pubDate>
</item>`
@ -78,8 +78,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
<rss version="2.0">
<channel>
<title>${escapeHTML(cfg.pageTitle)}</title>
<link>${root}</link>
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
<link>https://${base}</link>
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
cfg.pageTitle,
)}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator>
@ -92,7 +92,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
name: "ContentIndex",
async emit(ctx, content, _resources, emit) {
async emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map()
@ -116,7 +116,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableSiteMap) {
emitted.push(
await emit({
await write({
ctx,
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
@ -126,7 +127,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableRSS) {
emitted.push(
await emit({
await write({
ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: "index" as FullSlug,
ext: ".xml",
@ -134,7 +136,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
)
}
const fp = path.join("static", "contentIndex") as FullSlug
const fp = joinSegments("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries(
Array.from(linkIndex).map(([slug, content]) => {
// remove description and from content index as nothing downstream
@ -147,7 +149,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
)
emitted.push(
await emit({
await write({
ctx,
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",

View File

@ -8,6 +8,7 @@ import { FilePath, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
import chalk from "chalk"
import { write } from "./helpers"
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
@ -48,8 +49,9 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,
slug,
ext: ".html",

View File

@ -17,8 +17,10 @@ import {
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@ -35,7 +37,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@ -56,7 +58,10 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
frontmatter: { title: `Folder: ${folder}`, tags: [] },
frontmatter: {
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
tags: [],
},
}),
]),
)
@ -81,8 +86,9 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,
slug,
ext: ".html",

View File

@ -0,0 +1,19 @@
import path from "path"
import fs from "fs"
import { BuildCtx } from "../../util/ctx"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
const dir = path.dirname(pathToPage)
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(pathToPage, content)
return pathToPage
}

View File

@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {

View File

@ -14,8 +14,10 @@ import {
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@ -32,7 +34,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@ -46,7 +48,10 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => {
const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
const title =
tag === "index"
? i18n(cfg.locale).pages.tagContent.tagIndex
: `${i18n(cfg.locale).pages.tagContent.tag}: #${tag}`
return [
tag,
defaultProcessedContent({
@ -80,8 +85,9 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({
ctx,
content,
slug: file.data.slug!,
ext: ".html",

View File

@ -3,7 +3,6 @@ import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) {
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
return publishFlag
return vfile.data?.frontmatter?.publish ?? false
},
})

View File

@ -5,6 +5,7 @@ import yaml from "js-yaml"
import toml from "toml"
import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n"
export interface Options {
delims: string | string[]
@ -18,13 +19,34 @@ const defaultOptions: Options = {
oneLineTagDelim: ",",
}
function coalesceAliases(data: { [key: string]: any }, aliases: string[]) {
for (const alias of aliases) {
if (data[alias] !== undefined && data[alias] !== null) return data[alias]
}
}
function coerceToArray(input: string | string[]): string[] | undefined {
if (input === undefined || input === null) return undefined
// coerce to array
if (!Array.isArray(input)) {
input = input
.toString()
.split(",")
.map((tag: string) => tag.trim())
}
// remove all non-strings
return input
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
.map((tag: string | number) => tag.toString())
}
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "FrontMatter",
markdownPlugins() {
const { oneLineTagDelim } = opts
markdownPlugins({ cfg }) {
return [
[remarkFrontmatter, ["yaml", "toml"]],
() => {
@ -37,35 +59,19 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
},
})
// tag is an alias for tags
if (data.tag) {
data.tags = data.tag
}
// coerce title to string
if (data.title) {
data.title = data.title.toString()
} else if (data.title === null || data.title === undefined) {
data.title = file.stem ?? "Untitled"
data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
}
if (data.tags) {
// coerce to array
if (!Array.isArray(data.tags)) {
data.tags = data.tags
.toString()
.split(oneLineTagDelim)
.map((tag: string) => tag.trim())
}
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
// remove all non-string tags
data.tags = data.tags
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
.map((tag: string | number) => tag.toString())
}
// slug them all!!
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
if (aliases) data.aliases = aliases
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
if (cssclasses) data.cssclasses = cssclasses
// fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
@ -78,9 +84,16 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
declare module "vfile" {
interface DataMap {
frontmatter: { [key: string]: any } & {
frontmatter: { [key: string]: unknown } & {
title: string
tags: string[]
}
} & Partial<{
tags: string[]
aliases: string[]
description: string
publish: boolean
draft: boolean
enableToc: string
cssclasses: string[]
}>
}
}

View File

@ -37,8 +37,36 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
"data-no-popover": true,
},
content: {
type: "text",
value: " §",
type: "element",
tagName: "svg",
properties: {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
},
children: [],
},
{
type: "element",
tagName: "path",
properties: {
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
},
children: [],
},
],
},
},
],

View File

@ -43,24 +43,36 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
let published: MaybeDate = undefined
const fp = file.data.filePath!
const fullFp = path.posix.join(file.cwd, fp)
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
for (const source of opts.priority) {
if (source === "filesystem") {
const st = await fs.promises.stat(fullFp)
created ||= st.birthtimeMs
modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date
modified ||= file.data.frontmatter.lastmod
modified ||= file.data.frontmatter.updated
modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate
created ||= file.data.frontmatter.date as MaybeDate
modified ||= file.data.frontmatter.lastmod as MaybeDate
modified ||= file.data.frontmatter.updated as MaybeDate
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
published ||= file.data.frontmatter.publishDate as MaybeDate
} else if (source === "git") {
if (!repo) {
repo = new Repository(file.cwd)
// Get a reference to the main git repo.
// It's either the same as the workdir,
// or 1+ level higher in case of a submodule/subtree setup
repo = Repository.discover(file.cwd)
}
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
try {
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
} catch {
console.log(
chalk.yellow(
`\nWarning: ${file.data
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
),
)
}
}
}

View File

@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
return {
css: [
// base css
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css",
],
js: [
{
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
src: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/copy-tex.min.js",
loadTime: "afterDOMReady",
contentType: "external",
},

View File

@ -21,6 +21,7 @@ interface Options {
prettyLinks: boolean
openLinksInNewTab: boolean
lazyLoad: boolean
externalLinkIcon: boolean
}
const defaultOptions: Options = {
@ -28,6 +29,7 @@ const defaultOptions: Options = {
prettyLinks: true,
openLinksInNewTab: false,
lazyLoad: false,
externalLinkIcon: true,
}
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -55,7 +57,29 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
) {
let dest = node.properties.href as RelativeURL
const classes = (node.properties.className ?? []) as string[]
classes.push(isAbsoluteUrl(dest) ? "external" : "internal")
const isExternal = isAbsoluteUrl(dest)
classes.push(isExternal ? "external" : "internal")
if (isExternal && opts.externalLinkIcon) {
node.children.push({
type: "element",
tagName: "svg",
properties: {
class: "external-icon",
viewBox: "0 0 512 512",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
},
children: [],
},
],
})
}
// Check if the link has alias text
if (

View File

@ -4,7 +4,7 @@ import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { JSResource } from "../../util/resources"
// @ts-ignore
@ -23,8 +23,11 @@ export interface Options {
callouts: boolean
mermaid: boolean
parseTags: boolean
parseArrows: boolean
parseBlockReferences: boolean
enableInHtmlEmbed: boolean
enableYouTubeEmbed: boolean
enableVideoEmbed: boolean
}
const defaultOptions: Options = {
@ -34,43 +37,14 @@ const defaultOptions: Options = {
callouts: true,
mermaid: true,
parseTags: true,
parseArrows: true,
parseBlockReferences: true,
enableInHtmlEmbed: false,
enableYouTubeEmbed: true,
enableVideoEmbed: true,
}
const icons = {
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
}
const callouts = {
note: icons.pencilIcon,
abstract: icons.clipboardListIcon,
info: icons.infoIcon,
todo: icons.checkCircleIcon,
tip: icons.flameIcon,
success: icons.checkIcon,
question: icons.helpCircleIcon,
warning: icons.alertTriangleIcon,
failure: icons.xIcon,
danger: icons.zapIcon,
bug: icons.bugIcon,
example: icons.listIcon,
quote: icons.quoteIcon,
}
const calloutMapping: Record<string, keyof typeof callouts> = {
const calloutMapping = {
note: "note",
abstract: "abstract",
summary: "abstract",
@ -98,26 +72,40 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
example: "example",
quote: "quote",
cite: "quote",
} as const
const arrowMapping: Record<string, string> = {
"->": "&rarr;",
"-->": "&rArr;",
"=>": "&rArr;",
"==>": "&rArr;",
"<-": "&larr;",
"<--": "&lArr;",
"<=": "&lArr;",
"<==": "&lArr;",
}
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
return calloutMapping[callout] ?? "note"
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
// if callout is not recognized, make it a custom one
return calloutMapping[normalizedCallout] ?? calloutName
}
export const externalLinkRegex = /^https?:\/\//i
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g")
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias)
export const wikilinkRegex = new RegExp(
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
"g",
)
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g")
const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
@ -125,8 +113,13 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// #(...) -> capturing group, tag itself must start with #
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
const wikilinkImageEmbedRegex = new RegExp(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
@ -141,13 +134,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return {
name: "ObsidianFlavoredMarkdown",
textTransform(_ctx, src) {
// do comments at text level
if (opts.comments) {
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replace(commentRegex, "")
}
// pre-transform blockquotes
if (opts.callouts) {
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replaceAll(calloutLineRegex, (value) => {
src = src.replace(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
@ -159,7 +161,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
src = src.toString()
}
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
src = src.replace(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const fp = rawFp ?? ""
@ -201,11 +203,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (value.startsWith("!")) {
const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath)
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const dims = alias ?? ""
let [width, height] = dims.split("x", 2)
width ||= "auto"
height ||= "auto"
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
const match = wikilinkImageEmbedRegex.exec(alias ?? "")
const alt = match?.groups?.alt ?? ""
const width = match?.groups?.width ?? "auto"
const height = match?.groups?.height ?? "auto"
return {
type: "image",
url,
@ -213,6 +215,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
hProperties: {
width,
height,
alt,
},
},
}
@ -233,7 +236,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
type: "html",
value: `<iframe src="${url}"></iframe>`,
}
} else if (ext === "") {
} else {
const block = anchor
return {
type: "html",
@ -276,13 +279,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
])
}
if (opts.comments) {
if (opts.parseArrows) {
replacements.push([
commentRegex,
(_value: string, ..._capture: string[]) => {
arrowRegex,
(value: string, ..._capture: string[]) => {
const maybeArrow = arrowMapping[value]
if (maybeArrow === undefined) return SKIP
return {
type: "text",
value: "",
type: "html",
value: `<span>${maybeArrow}</span>`,
}
},
])
@ -298,8 +303,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
tag = slugTag(tag)
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
file.data.frontmatter.tags.push(tag)
if (file.data.frontmatter) {
const noteTags = file.data.frontmatter.tags ?? []
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
}
return {
@ -327,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (typeof replace === "string") {
node.value = node.value.replace(regex, replace)
} else {
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
node.value = node.value.replace(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
@ -343,11 +349,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
})
}
mdastFindReplace(tree, replacements)
}
})
if (opts.enableVideoEmbed) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, "image", (node, index, parent) => {
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
const newNode: Html = {
type: "html",
value: `<video controls src="${node.url}"></video>`,
}
parent.children.splice(index, 1, newNode)
return SKIP
}
})
}
})
}
if (opts.callouts) {
plugins.push(() => {
return (tree: Root, _file) => {
@ -363,36 +386,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
const text = firstChild.children[0].value
const restChildren = firstChild.children.slice(1)
const restOfTitle = firstChild.children.slice(1)
const [firstLine, ...remainingLines] = text.split("\n")
const remainingText = remainingLines.join("\n")
const match = firstLine.match(calloutRegex)
if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match
const calloutType = canonicalizeCallout(
typeString.toLowerCase() as keyof typeof calloutMapping,
)
const calloutType = canonicalizeCallout(typeString.toLowerCase())
const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const titleContent =
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const titleContent = match.input.slice(calloutDirective.length).trim()
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
const titleNode: Paragraph = {
type: "paragraph",
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
},
...restOfTitle,
],
}
const title = mdastToHtml(titleNode)
const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>`
const toggleIcon = `<div class="fold-callout-icon"></div>`
const titleHtml: Html = {
type: "html",
value: `<div
class="callout-title"
>
<div class="callout-icon">${callouts[calloutType]}</div>
<div class="callout-icon"></div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`,
@ -418,7 +443,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
"data-callout": calloutType,
@ -505,6 +530,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
})
}
if (opts.enableYouTubeEmbed) {
plugins.push(() => {
return (tree: HtmlRoot) => {
visit(tree, "element", (node) => {
if (node.tagName === "img" && typeof node.properties.src === "string") {
const match = node.properties.src.match(ytLinkRegex)
const videoId = match && match[2].length == 11 ? match[2] : null
if (videoId) {
node.tagName = "iframe"
node.properties = {
class: "external-embed",
allow: "fullscreen",
frameborder: 0,
width: "600px",
height: "350px",
src: `https://www.youtube.com/embed/${videoId}`,
}
}
}
})
}
})
}
return plugins
},
externalResources() {
@ -550,4 +599,4 @@ declare module "vfile" {
blocks: Record<string, Element>
htmlAst: HtmlRoot
}
}
}

View File

@ -26,6 +26,7 @@ interface TocEntry {
}
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
const slugAnchor = new Slugger()
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
@ -38,7 +39,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) {
const slugAnchor = new Slugger()
slugAnchor.reset()
const toc: TocEntry[] = []
let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => {

View File

@ -2,7 +2,7 @@ import { PluggableList } from "unified"
import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types"
import { FilePath, FullSlug } from "../util/path"
import { FilePath } from "../util/path"
import { BuildCtx } from "../util/ctx"
export interface PluginTypes {
@ -36,19 +36,6 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
emit(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
emitCallback: EmitCallback,
): Promise<FilePath[]>
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
}
export interface EmitOptions {
slug: FullSlug
ext: `.${string}` | ""
content: string
}
export type EmitCallback = (data: EmitOptions) => Promise<FilePath>