feat(v5): add plugin system (#2295)
* feat(plugins): v5 plugin system * feat(plugins): explorer as community plugin * feat(plugins): graph as community plugin * chore: update package-lock.json * chore: update package-lock.json * docs: updated plugin-specific docs * chore: update package-lock.json * chore: update package-lock.json * chore: update package-lock.json * Implement Git-based plugin system with dogfooding for community plugins - Remove npm dependencies for @quartz-community/* plugins - Add gitLoader.ts for installing plugins from GitHub - Update quartz.layout.ts to import from .quartz/plugins/ - Add install-plugins.ts script for prebuild hook - Add .quartz/ to .gitignore * Add comprehensive Git-based plugin CLI with lockfile support - Create quartz.lock.json format for tracking exact plugin commits - Add 'npx quartz plugin' commands: install, add, remove, update, list, restore - Plugin state is fully reproducible via lockfile - No npm dependencies required for community plugins * Fix TypeScript errors in git-installed plugins - Install @quartz-community/types as devDependency - Fix plugin imports to define types locally - Fix search inline script fetchData bug - Format code with prettier * fix(types): install types from github * docs: updated plugin-specific docs * Update Dockerfile and add CI/CD documentation - Add plugin install step to Dockerfile - Create docs/ci-cd.md with pipeline configuration guide * Update GitHub Actions workflows for v5 branch and Git-based plugins - Change branch references from v4 to v5 - Add plugin caching to speed up builds - Use 'npx quartz plugin install' instead of 'restore' - Update Docker workflow branch trigger * Update quartz.lock.json with fixed plugin versions * fix(docker): install command * docs: add plugin migration analysis document Comprehensive analysis of which Quartz v4 components and plugins can be migrated to separate repositories, including: - Component analysis (25 components) - Plugin analysis (transformers, emitters, filters) - Migration strategies for different plugin types - Lessons learned from Explorer/Graph/Search migrations - Recommended migration order * chore: updated plugins * chore: updated plugins * chore: updated dependencies * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: tsconfig * feat: build installed plugins * chore: updated plugins * chore: updated plugins * chore: update explorer plugin with duplication fix * docs: Quartz v5 * chore: update graph plugin with navigation fix * fix: update explorer plugin with toggle fix * fix: update explorer plugin - ensure toggle buttons always work * fix: create plugin components once to prevent duplicate script registration * chore: updated plugins * chore: updated plugins * feat: migrate 7 feature components to community plugins (Phase B) Migrate ArticleTitle, TagList, PageTitle, Darkmode, ReaderMode, ContentMeta, and Footer from internal components to community plugins. Update layout to use Plugin.X() pattern, remove internal component files and their styles/scripts. Add MIGRATION_TASKS.md documenting the full migration roadmap. * chore: updated plugins * refactor: delete 6 internal component duplicates (Phase A) Remove Backlinks, Breadcrumbs, RecentNotes, Search, TableOfContents, Comments, and OverflowList — all replaced by community plugins. Delete associated styles (6) and scripts (3). Switch layout to use Plugin.Breadcrumbs() instead of Component.Breadcrumbs(). * refactor: unify QuartzComponent type to structural interface (Phase C) - Changed QuartzComponent from ComponentType<QuartzComponentProps> to callable type ((props: QuartzComponentProps) => any) - Added optional displayName property for better debugging - Removed ComponentType import from preact - Removed all 13 'as QuartzComponent' type casts from quartz.layout.ts - Community plugin components now directly assignable without casts * feat: add PageType plugin infrastructure (Phase D Step 4) * feat: add PageTypePluginEntry for cross-boundary type compatibility Introduce PageTypePluginEntry with never[] parameter types to accept both internal and community PageType plugins in config arrays without casts, working around branded FullSlug contravariance mismatch. * refactor: update dispatcher to cast PageTypePluginEntry at boundary Add getPageTypes() helper that casts config's PageTypePluginEntry[] to QuartzPageTypePluginInstance[] in one place. Cast VirtualPage.slug to FullSlug at emitPage/defaultProcessedContent call sites. * feat: integrate community PageType plugins (Phase D Step 6) Replace old page-rendering emitters with PageTypeDispatcher emitter and pageTypes array. Restructure quartz.layout.ts from three separate exports to unified layout object with defaults and byPageType record. Install content-page, folder-page, tag-page community plugins. * refactor: delete old page-rendering emitters Remove ContentPage, FolderPage, TagPage, and NotFoundPage emitters now replaced by community PageType plugins and the PageTypeDispatcher. * refactor: remove migrated page body components Delete Content, FolderContent, TagContent page components now provided by community PageType plugins. Update components barrel export. * fix: update lockfile to fixed folder-page and tag-page commits Points to commits that remove duplicate PageList/SortFn re-exports, fixing TS2300 duplicate identifier errors in generated plugin index. * chore: updated plugins * fix: populate ctx.trie in PageTypeDispatcher before rendering Components like FolderContent depend on ctx.trie for folder hierarchy. The dispatcher now lazily initializes it via trieFromAllFiles in emit and force-rebuilds it in partialEmit to reflect file changes. * chore: update lockfile to fixed folder-page commit * chore: updated plugins * chore: update explorer plugin to fix SPA folder navigation * feat: extract transformers to community plugins and fix type compatibility - Delete 12 internal transformer files (keep FrontMatter as internal) - Switch quartz.config.ts to use ExternalPlugin.* for all transformers - Align branded types with @quartz-community/types (_brand, FullSlug etc.) - Add vfile DataMap augmentations for fields from extracted transformers - Update all 29 plugins to @quartz-community/types v0.2.1 * Migrate filters to external plugins (remove-draft, explicit-publish) Delete internal RemoveDrafts and ExplicitPublish filter implementations, install them as community plugins, and update quartz.config.ts to use ExternalPlugin.RemoveDrafts(). * Migrate emitters to external plugins (alias-redirects, cname, favicon, content-index, og-image) * refactor: remove inline scripts/styles migrated to plugins Delete dead code: callout, checkbox, mermaid inline scripts and styles are now bundled by the obsidian-flavored-markdown plugin. Clipboard script and styles moved to the syntax-highlighting plugin. listPage.scss was unreferenced. Body.tsx simplified to a pure layout wrapper. * refactor: consolidate utils to re-export from @quartz-community/utils * fix: use dangerouslySetInnerHTML for inline CSS to prevent HTML-escaping Preact was escaping & characters in SCSS-compiled CSS (e.g. & nesting) into &, breaking CSS rules. Using dangerouslySetInnerHTML bypasses the escaping, matching how browsers expect style element content. * chore: update plugins with inline script transpilation fix * chore: updated plugins * docs: update plugin API sections for v5 community plugins * docs: rewrite documentation for v5 plugin system Update feature docs, hosting, CI/CD, getting started, configuration, layout, architecture, creating components, making plugins, and migration guide to reflect the v5 community plugin architecture. * docs: fix outdated v4 references in documentation * chore: remove completed migration planning docs * chore: updated plugins * chore: cleanup * chore: cleanup * chore: bump version to 5.0.0 * chore: updated dependencies * feat: integrate CanvasPage plugin with types, assets, config, layout, and documentation * chore: updated dependencies * chore: updated dependencies * chore: updated linter * chore: update canvas-page plugin to c942fcb * chore: updated plugins * chore: update canvas-page plugin to f88f1b9 * chore: updated plugins * chore: update canvas-page plugin to 079304c * chore: updated plugins * chore: canvas layout * chore: update canvas-page plugin to 38d49e1 * chore: updated plugins * chore: update canvas-page plugin to 505c099 * chore: updated plugins * chore: updated plugins * fix: Obsidian flavored markdown * fix: Obsidian flavored markdown * fix: Obsidian flavored markdown * chore: cleanup * chore: updated plugins * feat: configuration files * feat: Quartz TUI * feat(tui): YAML configuration * chore: tsup * chore: tsup * feat: support array categories in plugin manifests Plugins like note-properties export both transformer and component functionality. Allow PluginManifest.category to be a single value or an array, with config-loader resolving to the first processing category (transformer/filter/emitter/pageType) for dispatch. * refactor: remove built-in FrontMatter transformer Frontmatter processing is now handled by the note-properties plugin, which provides the same YAML/TOML parsing plus link extraction and a visual properties panel. The built-in transformer is no longer needed. * feat: add note-properties plugin to default configuration Register note-properties as the first plugin (order 5) in both the user config and the default config. Placed in beforeBody layout zone with priority 15 (between article-title at 10 and content-meta at 20). * docs: add plugin management strategy and syncer v5 notes Document the plugin management system design decisions and provide implementation guidance for the Quartz Syncer v5 integration. * feat: add bases-page plugin to default configuration Enable Obsidian Bases (.base) file support with bases page type and layout entry in both user and default config. * docs: update syncer notes with bases-page, note-properties, and spacer Add all three new plugins to the quick reference table (40 total). Add content, canvas, and bases page types to byPageType documentation. * chore: updated plugins * fix: update CI to Node 24 and regenerate lockfiles for clean install * fix: resolve type errors for CI checks * chore: updated plugins * chore: updated plugins * fix: plugin mapping from configuration * fix: CI * fix: CI * docs: rewrite Frontmatter documentation for note-properties plugin * chore: updated plugins * docs: Quartz v5 * chore: updated plugins * chore: updated plugins * refactor: extract TUI to standalone plugin repository * chore: linting * docs: Quartz v5 * feat: update and upgrade commands * chore: updated plugins * chore: updated plugins * chore: cleanup * chore: cleanup * chore: cleanup * chore: cleanup * chore: cleanup * fix: layout group priority * fix: view classes * fix: include virtual pages in content index for explorer visibility * docs: add board, gallery, and cards view examples to navigation page * chore: updated plugins * fix: include virtualPages in worker serializable build context * fix: set relativePath on virtual pages to prevent explorer crash * fix: exclude 404 * fix(links): virtual page links * fix(links): virtual page transclusion * docs: architecture overview * fix: only call scripts one per page * fix: type error in component registry instantiate method * fix: left layout order * fix(layout): remove tag-list by default * docs(plugins): updated plugin list defaults * fix(layout): priorities * feat: add PageFrame system for custom page layouts * feat: integrate PageFrame into rendering pipeline * feat: add frame resolution to page type dispatcher and config loader * style: add CSS grid overrides for full-width and minimal page frames * feat: set minimal frame for 404 and update canvas-page plugin * docs: add PageFrame system to architecture overview * fix: wrap frame.render() in array to satisfy Body children type * chore: format * fix: use absolute asset paths for 404 page so it works in subdirectories * fix(layout): priorities * docs: page frames * feat: add FrameRegistry for plugin-provided page frames Plugins can now register custom page frames via their manifest's 'frames' field. Frames are loaded alongside components during plugin initialization and resolved by name at render time with fallback to built-in frames. * feat(layout): page frames * fix(layout): linting * fix: inject frame CSS into page so plugin-provided frames render correctly * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * docs: canvas * chore: updated plugins * chore: updated plugins * chore: updated plugins * feat: add TreeTransform hook, fix multi-category plugins, and resolve cross-plugin dependencies - Add TreeTransform type and treeTransforms hook to pageType plugins, enabling render-time HAST tree mutations (e.g. bases-page inline codeblock resolution) - Fix config-loader to push multi-category plugins into ALL matching processing buckets instead of only the first match - Add side-effect import for component-only plugins so view registrations (e.g. leaflet-map via globalThis ViewRegistry) execute at load time - Add npm prune --omit=dev and cross-plugin peer dependency symlinking to buildPlugin() to prevent duplicate-singleton issues from nested node_modules * chore: format * chore: test docs * chore: updated plugins * fix: prevent HTML-escaping of inline style and script content in htmlToJsx Add dangerouslySetInnerHTML overrides for <style> and <script> elements so that CSS/JS injected by tree transforms is not HTML-escaped during preact-render-to-string serialization. * chore: update plugin lockfile for htmlToJsx migration * chore: update leaflet-map plugin (fix deferred L.Control) * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: test npx quartz upgrade * feat(templates): add obsidian, ttrpg, blog templates * docs: move bases * docs: removed leaflet demo * feat(cli): configure baseUrl during create * docs: updated cli commands * docs: updated documentation for v5 * feat(cli): prune and resolve * chore: rebuild lockfile * docs: cli documentation * docs: plugin development and setup guide * chore: deleted redundant files * fix(build): fallback config * chore: updated lockfile * docs: removed outdated v3 setup * feat(cli): allow non-default branch plugins * docs: install branch commands * feat(cli): allow local plugins * docs: install local commands * feat: add render event type and listener for in-place DOM re-initialization * docs: add EncryptedPages plugin documentation * docs: add encrypted pages live demo page - New password-protected demo page (password: quartz) showing the plugin in action - Link to demo from EncryptedPages plugin page with password hint callout * feat: add encrypted-pages plugin to all templates - Enabled by default in default, obsidian, and ttrpg templates - Disabled by default in blog template * chore: updated plugins * chore: updated layouts * chore: updated plugins * feat: stacked pages * feat: added stacked page panes * docs: touch-ups
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
import rehypeCitation from "rehype-citation"
|
||||
import { PluggableList } from "unified"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
|
||||
export interface Options {
|
||||
bibliographyFile: string
|
||||
suppressBibliography: boolean
|
||||
linkCitations: boolean
|
||||
csl: string
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
bibliographyFile: "./bibliography.bib",
|
||||
suppressBibliography: false,
|
||||
linkCitations: false,
|
||||
csl: "apa",
|
||||
}
|
||||
|
||||
export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "Citations",
|
||||
htmlPlugins(ctx) {
|
||||
const plugins: PluggableList = []
|
||||
// per default, rehype-citations only supports en-US
|
||||
// see: https://github.com/timlrx/rehype-citation/issues/12
|
||||
// in here there are multiple usable locales:
|
||||
// https://github.com/citation-style-language/locales
|
||||
// thus, we optimistically assume there is indeed an appropriate
|
||||
// locale available and simply create the lang url-string
|
||||
let lang: string = "en-US"
|
||||
if (ctx.cfg.configuration.locale !== "en-US") {
|
||||
lang = `https://raw.githubusercontent.com/citation-stylelanguage/locales/refs/heads/master/locales-${ctx.cfg.configuration.locale}.xml`
|
||||
}
|
||||
// Add rehype-citation to the list of plugins
|
||||
plugins.push([
|
||||
rehypeCitation,
|
||||
{
|
||||
bibliography: opts.bibliographyFile,
|
||||
suppressBibliography: opts.suppressBibliography,
|
||||
linkCitations: opts.linkCitations,
|
||||
csl: opts.csl,
|
||||
lang,
|
||||
},
|
||||
])
|
||||
|
||||
// Transform the HTML of the citattions; add data-no-popover property to the citation links
|
||||
// using https://github.com/syntax-tree/unist-util-visit as they're just anochor links
|
||||
plugins.push(() => {
|
||||
return (tree, _file) => {
|
||||
visit(tree, "element", (node, _index, _parent) => {
|
||||
if (node.tagName === "a" && node.properties?.href?.startsWith("#bib")) {
|
||||
node.properties["data-no-popover"] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return plugins
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
maxDescriptionLength: number
|
||||
replaceExternalLinks: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
descriptionLength: 150,
|
||||
maxDescriptionLength: 300,
|
||||
replaceExternalLinks: true,
|
||||
}
|
||||
|
||||
const urlRegex = new RegExp(
|
||||
/(https?:\/\/)?(?<domain>([\da-z\.-]+)\.([a-z\.]{2,6})(:\d+)?)(?<path>[\/\w\.-]*)(\?[\/\w\.=&;-]*)?/,
|
||||
"g",
|
||||
)
|
||||
|
||||
export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "Description",
|
||||
htmlPlugins() {
|
||||
return [
|
||||
() => {
|
||||
return async (tree: HTMLRoot, file) => {
|
||||
let frontMatterDescription = file.data.frontmatter?.description
|
||||
let text = escapeHTML(toString(tree))
|
||||
|
||||
if (opts.replaceExternalLinks) {
|
||||
frontMatterDescription = frontMatterDescription?.replace(
|
||||
urlRegex,
|
||||
"$<domain>" + "$<path>",
|
||||
)
|
||||
text = text.replace(urlRegex, "$<domain>" + "$<path>")
|
||||
}
|
||||
|
||||
if (frontMatterDescription) {
|
||||
file.data.description = frontMatterDescription
|
||||
file.data.text = text
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise, use the text content
|
||||
const desc = text
|
||||
const sentences = desc.replace(/\s+/g, " ").split(/\.\s/)
|
||||
let finalDesc = ""
|
||||
let sentenceIdx = 0
|
||||
|
||||
// Add full sentences until we exceed the guideline length
|
||||
while (sentenceIdx < sentences.length) {
|
||||
const sentence = sentences[sentenceIdx]
|
||||
if (!sentence) break
|
||||
|
||||
const currentSentence = sentence.endsWith(".") ? sentence : sentence + "."
|
||||
const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0)
|
||||
|
||||
// Add the sentence if we're under the guideline length
|
||||
// or if this is the first sentence (always include at least one)
|
||||
if (nextLength <= opts.descriptionLength || sentenceIdx === 0) {
|
||||
finalDesc += (finalDesc ? " " : "") + currentSentence
|
||||
sentenceIdx++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// truncate to max length if necessary
|
||||
file.data.description =
|
||||
finalDesc.length > opts.maxDescriptionLength
|
||||
? finalDesc.slice(0, opts.maxDescriptionLength) + "..."
|
||||
: finalDesc
|
||||
file.data.text = text
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
description: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import matter from "gray-matter"
|
||||
import remarkFrontmatter from "remark-frontmatter"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import yaml from "js-yaml"
|
||||
import toml from "toml"
|
||||
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export interface Options {
|
||||
delimiters: string | [string, string]
|
||||
language: "yaml" | "toml"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
delimiters: "---",
|
||||
language: "yaml",
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
function getAliasSlugs(aliases: string[]): FullSlug[] {
|
||||
const res: FullSlug[] = []
|
||||
for (const alias of aliases) {
|
||||
const isMd = getFileExtension(alias) === "md"
|
||||
const mockFp = isMd ? alias : alias + ".md"
|
||||
const slug = slugifyFilePath(mockFp as FilePath)
|
||||
res.push(slug)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "FrontMatter",
|
||||
markdownPlugins(ctx) {
|
||||
const { cfg, allSlugs } = ctx
|
||||
return [
|
||||
[remarkFrontmatter, ["yaml", "toml"]],
|
||||
() => {
|
||||
return (_, file) => {
|
||||
const fileData = Buffer.from(file.value as Uint8Array)
|
||||
const { data } = matter(fileData, {
|
||||
...opts,
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||
toml: (s) => toml.parse(s) as object,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.title != null && data.title.toString() !== "") {
|
||||
data.title = data.title.toString()
|
||||
} else {
|
||||
data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
|
||||
}
|
||||
|
||||
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
|
||||
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
|
||||
|
||||
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
||||
if (aliases) {
|
||||
data.aliases = aliases // frontmatter
|
||||
file.data.aliases = getAliasSlugs(aliases)
|
||||
allSlugs.push(...file.data.aliases)
|
||||
}
|
||||
|
||||
if (data.permalink != null && data.permalink.toString() !== "") {
|
||||
data.permalink = data.permalink.toString() as FullSlug
|
||||
const aliases = file.data.aliases ?? []
|
||||
aliases.push(data.permalink)
|
||||
file.data.aliases = aliases
|
||||
allSlugs.push(data.permalink)
|
||||
}
|
||||
|
||||
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
||||
if (cssclasses) data.cssclasses = cssclasses
|
||||
|
||||
const socialImage = coalesceAliases(data, ["socialImage", "image", "cover"])
|
||||
|
||||
const created = coalesceAliases(data, ["created", "date"])
|
||||
if (created) {
|
||||
data.created = created
|
||||
}
|
||||
|
||||
const modified = coalesceAliases(data, [
|
||||
"modified",
|
||||
"lastmod",
|
||||
"updated",
|
||||
"last-modified",
|
||||
])
|
||||
if (modified) data.modified = modified
|
||||
data.modified ||= created // if modified is not set, use created
|
||||
|
||||
const published = coalesceAliases(data, ["published", "publishDate", "date"])
|
||||
if (published) data.published = published
|
||||
|
||||
if (socialImage) data.socialImage = socialImage
|
||||
|
||||
// Remove duplicate slugs
|
||||
const uniqueSlugs = [...new Set(allSlugs)]
|
||||
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
|
||||
|
||||
// fill in frontmatter
|
||||
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
aliases: FullSlug[]
|
||||
frontmatter: { [key: string]: unknown } & {
|
||||
title: string
|
||||
} & Partial<{
|
||||
tags: string[]
|
||||
aliases: string[]
|
||||
modified: string
|
||||
created: string
|
||||
published: string
|
||||
description: string
|
||||
socialDescription: string
|
||||
publish: boolean | string
|
||||
draft: boolean | string
|
||||
lang: string
|
||||
enableToc: string
|
||||
cssclasses: string[]
|
||||
socialImage: string
|
||||
comments: boolean | string
|
||||
}>
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import remarkGfm from "remark-gfm"
|
||||
import smartypants from "remark-smartypants"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import rehypeSlug from "rehype-slug"
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||
|
||||
export interface Options {
|
||||
enableSmartyPants: boolean
|
||||
linkHeadings: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
enableSmartyPants: true,
|
||||
linkHeadings: true,
|
||||
}
|
||||
|
||||
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "GitHubFlavoredMarkdown",
|
||||
markdownPlugins() {
|
||||
return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm]
|
||||
},
|
||||
htmlPlugins() {
|
||||
if (opts.linkHeadings) {
|
||||
return [
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "append",
|
||||
properties: {
|
||||
role: "anchor",
|
||||
ariaHidden: true,
|
||||
tabIndex: -1,
|
||||
"data-no-popover": true,
|
||||
},
|
||||
content: {
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1 @@
|
||||
export { FrontMatter } from "./frontmatter"
|
||||
export { GitHubFlavoredMarkdown } from "./gfm"
|
||||
export { Citations } from "./citations"
|
||||
export { CreatedModifiedDate } from "./lastmod"
|
||||
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"
|
||||
export { RoamFlavoredMarkdown } from "./roam"
|
||||
export {}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import fs from "fs"
|
||||
import { Repository } from "@napi-rs/simple-git"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import path from "path"
|
||||
import { styleText } from "util"
|
||||
|
||||
export interface Options {
|
||||
priority: ("frontmatter" | "git" | "filesystem")[]
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
priority: ["frontmatter", "git", "filesystem"],
|
||||
}
|
||||
|
||||
// YYYY-MM-DD
|
||||
const iso8601DateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function coerceDate(fp: string, d: any): Date {
|
||||
// check ISO8601 date-only format
|
||||
// we treat this one as local midnight as the normal
|
||||
// js date ctor treats YYYY-MM-DD as UTC midnight
|
||||
if (typeof d === "string" && iso8601DateOnlyRegex.test(d)) {
|
||||
d = `${d}T00:00:00`
|
||||
}
|
||||
|
||||
const dt = new Date(d)
|
||||
const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
|
||||
if (invalidDate && d !== undefined) {
|
||||
console.log(
|
||||
styleText(
|
||||
"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>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "CreatedModifiedDate",
|
||||
markdownPlugins(ctx) {
|
||||
return [
|
||||
() => {
|
||||
let repo: Repository | undefined = undefined
|
||||
let repositoryWorkdir: string
|
||||
if (opts.priority.includes("git")) {
|
||||
try {
|
||||
repo = Repository.discover(ctx.argv.directory)
|
||||
repositoryWorkdir = repo.workdir() ?? ctx.argv.directory
|
||||
} catch (e) {
|
||||
console.log(
|
||||
styleText(
|
||||
"yellow",
|
||||
`\nWarning: couldn't find git repository for ${ctx.argv.directory}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return async (_tree, file) => {
|
||||
let created: MaybeDate = undefined
|
||||
let modified: MaybeDate = undefined
|
||||
let published: MaybeDate = undefined
|
||||
|
||||
const fp = file.data.relativePath!
|
||||
const fullFp = file.data.filePath!
|
||||
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.created as MaybeDate
|
||||
modified ||= file.data.frontmatter.modified as MaybeDate
|
||||
published ||= file.data.frontmatter.published as MaybeDate
|
||||
} else if (source === "git" && repo) {
|
||||
try {
|
||||
const relativePath = path.relative(repositoryWorkdir, fullFp)
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(relativePath)
|
||||
} catch {
|
||||
console.log(
|
||||
styleText(
|
||||
"yellow",
|
||||
`\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.data.dates = {
|
||||
created: coerceDate(fp, created),
|
||||
modified: coerceDate(fp, modified),
|
||||
published: coerceDate(fp, published),
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
dates: {
|
||||
created: Date
|
||||
modified: Date
|
||||
published: Date
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import remarkMath from "remark-math"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import rehypeMathjax from "rehype-mathjax/svg"
|
||||
//@ts-ignore
|
||||
import rehypeTypst from "@myriaddreamin/rehype-typst"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { KatexOptions } from "katex"
|
||||
import { Options as MathjaxOptions } from "rehype-mathjax/svg"
|
||||
//@ts-ignore
|
||||
import { Options as TypstOptions } from "@myriaddreamin/rehype-typst"
|
||||
|
||||
interface Options {
|
||||
renderEngine: "katex" | "mathjax" | "typst"
|
||||
customMacros: MacroType
|
||||
katexOptions: Omit<KatexOptions, "macros" | "output">
|
||||
mathJaxOptions: Omit<MathjaxOptions, "macros">
|
||||
typstOptions: TypstOptions
|
||||
}
|
||||
|
||||
// mathjax macros
|
||||
export type Args = boolean | number | string | null
|
||||
interface MacroType {
|
||||
[key: string]: string | Args[]
|
||||
}
|
||||
|
||||
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
|
||||
const engine = opts?.renderEngine ?? "katex"
|
||||
const macros = opts?.customMacros ?? {}
|
||||
return {
|
||||
name: "Latex",
|
||||
markdownPlugins() {
|
||||
return [remarkMath]
|
||||
},
|
||||
htmlPlugins() {
|
||||
switch (engine) {
|
||||
case "katex": {
|
||||
return [[rehypeKatex, { output: "html", macros, ...(opts?.katexOptions ?? {}) }]]
|
||||
}
|
||||
case "typst": {
|
||||
return [[rehypeTypst, opts?.typstOptions ?? {}]]
|
||||
}
|
||||
default:
|
||||
case "mathjax": {
|
||||
return [
|
||||
[
|
||||
rehypeMathjax,
|
||||
{
|
||||
...(opts?.mathJaxOptions ?? {}),
|
||||
tex: {
|
||||
...(opts?.mathJaxOptions?.tex ?? {}),
|
||||
macros,
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
externalResources() {
|
||||
switch (engine) {
|
||||
case "katex":
|
||||
return {
|
||||
css: [{ content: "https://cdn.jsdelivr.net/npm/katex@0.16.11/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.11/dist/contrib/copy-tex.min.js",
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
|
||||
export const HardLineBreaks: QuartzTransformerPlugin = () => {
|
||||
return {
|
||||
name: "HardLineBreaks",
|
||||
markdownPlugins() {
|
||||
return [remarkBreaks]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import {
|
||||
FullSlug,
|
||||
RelativeURL,
|
||||
SimpleSlug,
|
||||
TransformOptions,
|
||||
stripSlashes,
|
||||
simplifySlug,
|
||||
splitAnchor,
|
||||
transformLink,
|
||||
} from "../../util/path"
|
||||
import path from "path"
|
||||
import { visit } from "unist-util-visit"
|
||||
import isAbsoluteUrl from "is-absolute-url"
|
||||
import { Root } from "hast"
|
||||
|
||||
interface Options {
|
||||
/** How to resolve Markdown paths */
|
||||
markdownLinkResolution: TransformOptions["strategy"]
|
||||
/** Strips folders from a link so that it looks nice */
|
||||
prettyLinks: boolean
|
||||
openLinksInNewTab: boolean
|
||||
lazyLoad: boolean
|
||||
externalLinkIcon: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
markdownLinkResolution: "absolute",
|
||||
prettyLinks: true,
|
||||
openLinksInNewTab: false,
|
||||
lazyLoad: false,
|
||||
externalLinkIcon: true,
|
||||
}
|
||||
|
||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "LinkProcessing",
|
||||
htmlPlugins(ctx) {
|
||||
return [
|
||||
() => {
|
||||
return (tree: Root, file) => {
|
||||
const curSlug = simplifySlug(file.data.slug!)
|
||||
const outgoing: Set<SimpleSlug> = new Set()
|
||||
|
||||
const transformOptions: TransformOptions = {
|
||||
strategy: opts.markdownLinkResolution,
|
||||
allSlugs: ctx.allSlugs,
|
||||
}
|
||||
|
||||
visit(tree, "element", (node, _index, _parent) => {
|
||||
// rewrite all links
|
||||
if (
|
||||
node.tagName === "a" &&
|
||||
node.properties &&
|
||||
typeof node.properties.href === "string"
|
||||
) {
|
||||
let dest = node.properties.href as RelativeURL
|
||||
const classes = (node.properties.className ?? []) as string[]
|
||||
const isExternal = isAbsoluteUrl(dest, { httpOnly: false })
|
||||
classes.push(isExternal ? "external" : "internal")
|
||||
|
||||
if (isExternal && opts.externalLinkIcon) {
|
||||
node.children.push({
|
||||
type: "element",
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
"aria-hidden": "true",
|
||||
class: "external-icon",
|
||||
style: "max-width:0.8em;max-height:0.8em",
|
||||
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 (
|
||||
node.children.length === 1 &&
|
||||
node.children[0].type === "text" &&
|
||||
node.children[0].value !== dest
|
||||
) {
|
||||
// Add the 'alias' class if the text content is not the same as the href
|
||||
classes.push("alias")
|
||||
}
|
||||
node.properties.className = classes
|
||||
|
||||
if (isExternal && opts.openLinksInNewTab) {
|
||||
node.properties.target = "_blank"
|
||||
}
|
||||
|
||||
// don't process external links or intra-document anchors
|
||||
const isInternal = !(
|
||||
isAbsoluteUrl(dest, { httpOnly: false }) || dest.startsWith("#")
|
||||
)
|
||||
if (isInternal) {
|
||||
dest = node.properties.href = transformLink(
|
||||
file.data.slug!,
|
||||
dest,
|
||||
transformOptions,
|
||||
)
|
||||
|
||||
// url.resolve is considered legacy
|
||||
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
|
||||
const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
|
||||
const canonicalDest = url.pathname
|
||||
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
|
||||
if (destCanonical.endsWith("/")) {
|
||||
destCanonical += "index"
|
||||
}
|
||||
|
||||
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
|
||||
const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug
|
||||
const simple = simplifySlug(full)
|
||||
outgoing.add(simple)
|
||||
node.properties["data-slug"] = full
|
||||
}
|
||||
|
||||
// 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("#")
|
||||
) {
|
||||
node.children[0].value = path.basename(node.children[0].value)
|
||||
}
|
||||
}
|
||||
|
||||
// transform all other resources that may use links
|
||||
if (
|
||||
["img", "video", "audio", "iframe"].includes(node.tagName) &&
|
||||
node.properties &&
|
||||
typeof node.properties.src === "string"
|
||||
) {
|
||||
if (opts.lazyLoad) {
|
||||
node.properties.loading = "lazy"
|
||||
}
|
||||
|
||||
if (!isAbsoluteUrl(node.properties.src, { httpOnly: false })) {
|
||||
let dest = node.properties.src as RelativeURL
|
||||
dest = node.properties.src = transformLink(
|
||||
file.data.slug!,
|
||||
dest,
|
||||
transformOptions,
|
||||
)
|
||||
node.properties.src = dest
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
file.data.links = [...outgoing]
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
links: SimpleSlug[]
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import {
|
||||
Root,
|
||||
Html,
|
||||
BlockContent,
|
||||
PhrasingContent,
|
||||
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 rehypeRaw from "rehype-raw"
|
||||
import { SKIP, visit } from "unist-util-visit"
|
||||
import path from "path"
|
||||
import { splitAnchor } from "../../util/path"
|
||||
import { JSResource, CSSResource } from "../../util/resources"
|
||||
// @ts-ignore
|
||||
import calloutScript from "../../components/scripts/callout.inline"
|
||||
// @ts-ignore
|
||||
import checkboxScript from "../../components/scripts/checkbox.inline"
|
||||
// @ts-ignore
|
||||
import mermaidScript from "../../components/scripts/mermaid.inline"
|
||||
import mermaidStyle from "../../components/styles/mermaid.inline.scss"
|
||||
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
||||
import { toHast } from "mdast-util-to-hast"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { capitalize } from "../../util/lang"
|
||||
import { PluggableList } from "unified"
|
||||
|
||||
export interface Options {
|
||||
comments: boolean
|
||||
highlight: boolean
|
||||
wikilinks: boolean
|
||||
callouts: boolean
|
||||
mermaid: boolean
|
||||
parseTags: boolean
|
||||
parseArrows: boolean
|
||||
parseBlockReferences: boolean
|
||||
enableInHtmlEmbed: boolean
|
||||
enableYouTubeEmbed: boolean
|
||||
enableVideoEmbed: boolean
|
||||
enableCheckbox: boolean
|
||||
disableBrokenWikilinks: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
comments: true,
|
||||
highlight: true,
|
||||
wikilinks: true,
|
||||
callouts: true,
|
||||
mermaid: true,
|
||||
parseTags: true,
|
||||
parseArrows: true,
|
||||
parseBlockReferences: true,
|
||||
enableInHtmlEmbed: false,
|
||||
enableYouTubeEmbed: true,
|
||||
enableVideoEmbed: true,
|
||||
enableCheckbox: false,
|
||||
disableBrokenWikilinks: false,
|
||||
}
|
||||
|
||||
const calloutMapping = {
|
||||
note: "note",
|
||||
abstract: "abstract",
|
||||
summary: "abstract",
|
||||
tldr: "abstract",
|
||||
info: "info",
|
||||
todo: "todo",
|
||||
tip: "tip",
|
||||
hint: "tip",
|
||||
important: "tip",
|
||||
success: "success",
|
||||
check: "success",
|
||||
done: "success",
|
||||
question: "question",
|
||||
help: "question",
|
||||
faq: "question",
|
||||
warning: "warning",
|
||||
attention: "warning",
|
||||
caution: "warning",
|
||||
failure: "failure",
|
||||
missing: "failure",
|
||||
fail: "failure",
|
||||
danger: "danger",
|
||||
error: "danger",
|
||||
bug: "bug",
|
||||
example: "example",
|
||||
quote: "quote",
|
||||
cite: "quote",
|
||||
} as const
|
||||
|
||||
const arrowMapping: Record<string, string> = {
|
||||
"->": "→",
|
||||
"-->": "⇒",
|
||||
"=>": "⇒",
|
||||
"==>": "⇒",
|
||||
"<-": "←",
|
||||
"<--": "⇐",
|
||||
"<=": "⇐",
|
||||
"<==": "⇐",
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
// (\\?\|[^\[\]\#]+)? -> optional escape \ then | then zero or more non-special characters (alias)
|
||||
export const wikilinkRegex = new RegExp(
|
||||
/!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g,
|
||||
)
|
||||
|
||||
// ^\|([^\n])+\|\n(\|) -> matches the header row
|
||||
// ( ?:?-{3,}:? ?\|)+ -> matches the header row separator
|
||||
// (\|([^\n])+\|\n)+ -> matches the body rows
|
||||
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm)
|
||||
|
||||
// matches any wikilink, only used for escaping wikilinks inside tables
|
||||
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g)
|
||||
|
||||
const highlightRegex = 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)
|
||||
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
|
||||
// #(...) -> capturing group, tag itself must start with #
|
||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||
const tagRegex = new RegExp(
|
||||
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu,
|
||||
)
|
||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g)
|
||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||
const ytPlaylistLinkRegex = /[?&]list=([^#?&]*)/
|
||||
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>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
|
||||
const mdastToHtml = (ast: PhrasingContent | Paragraph) => {
|
||||
const hast = toHast(ast, { allowDangerousHtml: true })!
|
||||
return toHtml(hast, { allowDangerousHtml: true })
|
||||
}
|
||||
|
||||
return {
|
||||
name: "ObsidianFlavoredMarkdown",
|
||||
textTransform(_ctx, src) {
|
||||
// do comments at text level
|
||||
if (opts.comments) {
|
||||
src = src.replace(commentRegex, "")
|
||||
}
|
||||
|
||||
// pre-transform blockquotes
|
||||
if (opts.callouts) {
|
||||
src = src.replace(calloutLineRegex, (value) => {
|
||||
// force newline after title of callout
|
||||
return value + "\n> "
|
||||
})
|
||||
}
|
||||
|
||||
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
||||
if (opts.wikilinks) {
|
||||
// replace all wikilinks inside a table first
|
||||
src = src.replace(tableRegex, (value) => {
|
||||
// escape all aliases and headers in wikilinks inside a table
|
||||
return value.replace(tableWikilinkRegex, (_value, raw) => {
|
||||
// const [raw]: (string | undefined)[] = capture
|
||||
let escaped = raw ?? ""
|
||||
escaped = escaped.replace("#", "\\#")
|
||||
// escape pipe characters if they are not already escaped
|
||||
escaped = escaped.replace(/((^|[^\\])(\\\\)*)\|/g, "$1\\|")
|
||||
|
||||
return escaped
|
||||
})
|
||||
})
|
||||
|
||||
// replace all other wikilinks
|
||||
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||
|
||||
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
|
||||
const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : ""
|
||||
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
|
||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||
|
||||
if (rawFp?.match(externalLinkRegex)) {
|
||||
return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
|
||||
}
|
||||
|
||||
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
||||
})
|
||||
}
|
||||
|
||||
return src
|
||||
},
|
||||
markdownPlugins(ctx) {
|
||||
const plugins: PluggableList = []
|
||||
|
||||
// regex replacements
|
||||
plugins.push(() => {
|
||||
return (tree: Root, file) => {
|
||||
const replacements: [RegExp, string | ReplaceFunction][] = []
|
||||
const base = pathToRoot(file.data.slug!)
|
||||
|
||||
if (opts.wikilinks) {
|
||||
replacements.push([
|
||||
wikilinkRegex,
|
||||
(value: string, ...capture: string[]) => {
|
||||
let [rawFp, rawHeader, rawAlias] = capture
|
||||
const fp = rawFp?.trim() ?? ""
|
||||
const anchor = rawHeader?.trim() ?? ""
|
||||
const alias: string | undefined = rawAlias?.slice(1).trim()
|
||||
|
||||
// embed cases
|
||||
if (value.startsWith("!")) {
|
||||
const ext: string = path.extname(fp).toLowerCase()
|
||||
const url = slugifyFilePath(fp as FilePath)
|
||||
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,
|
||||
data: {
|
||||
hProperties: {
|
||||
width,
|
||||
height,
|
||||
alt,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<video src="${url}" controls></video>`,
|
||||
}
|
||||
} else if (
|
||||
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
||||
) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<audio src="${url}" controls></audio>`,
|
||||
}
|
||||
} else if ([".pdf"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<iframe src="${url}" class="pdf"></iframe>`,
|
||||
}
|
||||
} else {
|
||||
const block = anchor
|
||||
return {
|
||||
type: "html",
|
||||
data: { hProperties: { transclude: true } },
|
||||
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
|
||||
url + anchor
|
||||
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, fall through to regular link
|
||||
}
|
||||
|
||||
// treat as broken link if slug not in ctx.allSlugs
|
||||
if (opts.disableBrokenWikilinks) {
|
||||
const slug = slugifyFilePath(fp as FilePath)
|
||||
const exists = ctx.allSlugs && ctx.allSlugs.includes(slug)
|
||||
if (!exists) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<a class=\"internal broken\">${alias ?? fp}</a>`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// internal link
|
||||
const url = fp + anchor
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
url,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: alias ?? fp,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.highlight) {
|
||||
replacements.push([
|
||||
highlightRegex,
|
||||
(_value: string, ...capture: string[]) => {
|
||||
const [inner] = capture
|
||||
return {
|
||||
type: "html",
|
||||
value: `<span class="text-highlight">${inner}</span>`,
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.parseArrows) {
|
||||
replacements.push([
|
||||
arrowRegex,
|
||||
(value: string, ..._capture: string[]) => {
|
||||
const maybeArrow = arrowMapping[value]
|
||||
if (maybeArrow === undefined) return SKIP
|
||||
return {
|
||||
type: "html",
|
||||
value: `<span>${maybeArrow}</span>`,
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.parseTags) {
|
||||
replacements.push([
|
||||
tagRegex,
|
||||
(_value: string, tag: string) => {
|
||||
// Check if the tag only includes numbers and slashes
|
||||
if (/^[\/\d]+$/.test(tag)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tag = slugTag(tag)
|
||||
if (file.data.frontmatter) {
|
||||
const noteTags = file.data.frontmatter.tags ?? []
|
||||
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
url: base + `/tags/${tag}`,
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["tag-link"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: tag,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.enableInHtmlEmbed) {
|
||||
visit(tree, "html", (node: Html) => {
|
||||
for (const [regex, replace] of replacements) {
|
||||
if (typeof replace === "string") {
|
||||
node.value = node.value.replace(regex, replace)
|
||||
} else {
|
||||
node.value = node.value.replace(regex, (substring: string, ...args) => {
|
||||
const replaceValue = replace(substring, ...args)
|
||||
if (typeof replaceValue === "string") {
|
||||
return replaceValue
|
||||
} else if (Array.isArray(replaceValue)) {
|
||||
return replaceValue.map(mdastToHtml).join("")
|
||||
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
||||
return mdastToHtml(replaceValue)
|
||||
} else {
|
||||
return substring
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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) => {
|
||||
visit(tree, "blockquote", (node) => {
|
||||
if (node.children.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// find first line and callout content
|
||||
const [firstChild, ...calloutContent] = node.children
|
||||
if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
|
||||
return
|
||||
}
|
||||
|
||||
const text = firstChild.children[0].value
|
||||
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, calloutMetaData, collapseChar] = match
|
||||
const calloutType = canonicalizeCallout(typeString.toLowerCase())
|
||||
const collapse = collapseChar === "+" || collapseChar === "-"
|
||||
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
||||
const titleContent = match.input.slice(calloutDirective.length).trim()
|
||||
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
|
||||
const titleNode: Paragraph = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: useDefaultTitle
|
||||
? capitalize(typeString).replace(/-/g, " ")
|
||||
: titleContent + " ",
|
||||
},
|
||||
...restOfTitle,
|
||||
],
|
||||
}
|
||||
const title = mdastToHtml(titleNode)
|
||||
|
||||
const toggleIcon = `<div class="fold-callout-icon"></div>`
|
||||
|
||||
const titleHtml: Html = {
|
||||
type: "html",
|
||||
value: `<div
|
||||
class="callout-title"
|
||||
>
|
||||
<div class="callout-icon"></div>
|
||||
<div class="callout-title-inner">${title}</div>
|
||||
${collapse ? toggleIcon : ""}
|
||||
</div>`,
|
||||
}
|
||||
|
||||
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml]
|
||||
if (remainingText.length > 0) {
|
||||
blockquoteContent.push({
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: remainingText,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// For the rest of the MD callout elements other than the title, wrap them with
|
||||
// two nested HTML <div>s (use some hacked mdhast component to achieve this) of
|
||||
// class `callout-content` and `callout-content-inner` respectively for
|
||||
// grid-based collapsible animation.
|
||||
if (calloutContent.length > 0) {
|
||||
node.children = [
|
||||
node.children[0],
|
||||
{
|
||||
data: { hProperties: { className: ["callout-content"] }, hName: "div" },
|
||||
type: "blockquote",
|
||||
children: [...calloutContent],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// replace first line of blockquote with title and rest of the paragraph text
|
||||
node.children.splice(0, 1, ...blockquoteContent)
|
||||
|
||||
const classNames = ["callout", calloutType]
|
||||
if (collapse) {
|
||||
classNames.push("is-collapsible")
|
||||
}
|
||||
if (defaultState === "collapsed") {
|
||||
classNames.push("is-collapsed")
|
||||
}
|
||||
|
||||
// add properties to base blockquote
|
||||
node.data = {
|
||||
hProperties: {
|
||||
...(node.data?.hProperties ?? {}),
|
||||
className: classNames.join(" "),
|
||||
"data-callout": calloutType,
|
||||
"data-callout-fold": collapse,
|
||||
"data-callout-metadata": calloutMetaData,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.mermaid) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, file) => {
|
||||
visit(tree, "code", (node: Code) => {
|
||||
if (node.lang === "mermaid") {
|
||||
file.data.hasMermaidDiagram = true
|
||||
node.data = {
|
||||
hProperties: {
|
||||
className: ["mermaid"],
|
||||
"data-clipboard": JSON.stringify(node.value),
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
htmlPlugins() {
|
||||
const plugins: PluggableList = [rehypeRaw]
|
||||
|
||||
if (opts.parseBlockReferences) {
|
||||
plugins.push(() => {
|
||||
const inlineTagTypes = new Set(["p", "li"])
|
||||
const blockTagTypes = new Set(["blockquote"])
|
||||
return (tree: HtmlRoot, file) => {
|
||||
file.data.blocks = {}
|
||||
|
||||
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 (last.value === "") {
|
||||
// this is an inline block ref but the actual block
|
||||
// is the previous element above it
|
||||
let idx = (index ?? 1) - 1
|
||||
while (idx >= 0) {
|
||||
const element = parent?.children.at(idx)
|
||||
if (!element) break
|
||||
if (element.type !== "element") {
|
||||
idx -= 1
|
||||
} else {
|
||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||
element.properties = {
|
||||
...element.properties,
|
||||
id: block,
|
||||
}
|
||||
file.data.blocks![block] = element
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// normal paragraph transclude
|
||||
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
id: block,
|
||||
}
|
||||
file.data.blocks![block] = node
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
file.data.htmlAst = tree
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
const playlistId = node.properties.src.match(ytPlaylistLinkRegex)?.[1]
|
||||
if (videoId) {
|
||||
// YouTube video (with optional playlist)
|
||||
node.tagName = "iframe"
|
||||
node.properties = {
|
||||
class: "external-embed youtube",
|
||||
allow: "fullscreen",
|
||||
frameborder: 0,
|
||||
width: "600px",
|
||||
src: playlistId
|
||||
? `https://www.youtube.com/embed/${videoId}?list=${playlistId}`
|
||||
: `https://www.youtube.com/embed/${videoId}`,
|
||||
}
|
||||
} else if (playlistId) {
|
||||
// YouTube playlist only.
|
||||
node.tagName = "iframe"
|
||||
node.properties = {
|
||||
class: "external-embed youtube",
|
||||
allow: "fullscreen",
|
||||
frameborder: 0,
|
||||
width: "600px",
|
||||
src: `https://www.youtube.com/embed/videoseries?list=${playlistId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.enableCheckbox) {
|
||||
plugins.push(() => {
|
||||
return (tree: HtmlRoot, _file) => {
|
||||
visit(tree, "element", (node) => {
|
||||
if (node.tagName === "input" && node.properties.type === "checkbox") {
|
||||
const isChecked = node.properties?.checked ?? false
|
||||
node.properties = {
|
||||
type: "checkbox",
|
||||
disabled: false,
|
||||
checked: isChecked,
|
||||
class: "checkbox-toggle",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.mermaid) {
|
||||
plugins.push(() => {
|
||||
return (tree: HtmlRoot, _file) => {
|
||||
visit(tree, "element", (node: Element, _idx, parent) => {
|
||||
if (
|
||||
node.tagName === "code" &&
|
||||
((node.properties?.className ?? []) as string[])?.includes("mermaid")
|
||||
) {
|
||||
parent!.children = [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "button",
|
||||
properties: {
|
||||
className: ["expand-button"],
|
||||
"aria-label": "Expand mermaid diagram",
|
||||
"data-view-component": true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 16 16",
|
||||
fill: "currentColor",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "path",
|
||||
properties: {
|
||||
fillRule: "evenodd",
|
||||
d: "M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
node,
|
||||
{
|
||||
type: "element",
|
||||
tagName: "div",
|
||||
properties: { id: "mermaid-container", role: "dialog" },
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "div",
|
||||
properties: { id: "mermaid-space" },
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "div",
|
||||
properties: { className: ["mermaid-content"] },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
externalResources() {
|
||||
const js: JSResource[] = []
|
||||
const css: CSSResource[] = []
|
||||
|
||||
if (opts.enableCheckbox) {
|
||||
js.push({
|
||||
script: checkboxScript,
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.callouts) {
|
||||
js.push({
|
||||
script: calloutScript,
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.mermaid) {
|
||||
js.push({
|
||||
script: mermaidScript,
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
moduleType: "module",
|
||||
})
|
||||
|
||||
css.push({
|
||||
content: mermaidStyle,
|
||||
inline: true,
|
||||
})
|
||||
}
|
||||
|
||||
return { js, css }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
blocks: Record<string, Element>
|
||||
htmlAst: HtmlRoot
|
||||
hasMermaidDiagram: boolean | undefined
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import { PluggableList } from "unified"
|
||||
|
||||
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>> = (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
|
||||
},
|
||||
htmlPlugins() {
|
||||
const plugins: PluggableList = [rehypeRaw]
|
||||
return plugins
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { PluggableList } from "unified"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
|
||||
import { BuildVisitor } from "unist-util-visit"
|
||||
|
||||
export interface Options {
|
||||
orComponent: boolean
|
||||
TODOComponent: boolean
|
||||
DONEComponent: boolean
|
||||
videoComponent: boolean
|
||||
audioComponent: boolean
|
||||
pdfComponent: boolean
|
||||
blockquoteComponent: boolean
|
||||
tableComponent: boolean
|
||||
attributeComponent: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
orComponent: true,
|
||||
TODOComponent: true,
|
||||
DONEComponent: true,
|
||||
videoComponent: true,
|
||||
audioComponent: true,
|
||||
pdfComponent: true,
|
||||
blockquoteComponent: true,
|
||||
tableComponent: true,
|
||||
attributeComponent: true,
|
||||
}
|
||||
|
||||
const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
|
||||
const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
|
||||
const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
|
||||
|
||||
const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
|
||||
const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
|
||||
const roamItalicRegex = new RegExp(/__(.+)__/, "g")
|
||||
|
||||
function isSpecialEmbed(node: Paragraph): boolean {
|
||||
if (node.children.length !== 2) return false
|
||||
|
||||
const [textNode, linkNode] = node.children
|
||||
return (
|
||||
textNode.type === "text" &&
|
||||
textNode.value.startsWith("{{[[") &&
|
||||
linkNode.type === "link" &&
|
||||
linkNode.children[0].type === "text" &&
|
||||
linkNode.children[0].value.endsWith("}}")
|
||||
)
|
||||
}
|
||||
|
||||
function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null {
|
||||
const [textNode, linkNode] = node.children as [Text, Link]
|
||||
const embedType = textNode.value.match(/\{\{\[\[(.*?)\]\]:/)?.[1]?.toLowerCase()
|
||||
const url = linkNode.url.slice(0, -2) // Remove the trailing '}}'
|
||||
|
||||
switch (embedType) {
|
||||
case "audio":
|
||||
return opts.audioComponent
|
||||
? {
|
||||
type: "html",
|
||||
value: `<audio controls>
|
||||
<source src="${url}" type="audio/mpeg">
|
||||
<source src="${url}" type="audio/ogg">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>`,
|
||||
}
|
||||
: null
|
||||
case "video":
|
||||
if (!opts.videoComponent) return null
|
||||
// Check if it's a YouTube video
|
||||
const youtubeMatch = url.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/,
|
||||
)
|
||||
if (youtubeMatch) {
|
||||
const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters
|
||||
const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/)
|
||||
const playlistId = playlistMatch ? playlistMatch[1] : null
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: `<iframe
|
||||
class="external-embed youtube"
|
||||
width="600px"
|
||||
height="350px"
|
||||
src="https://www.youtube.com/embed/${videoId}${playlistId ? `?list=${playlistId}` : ""}"
|
||||
frameborder="0"
|
||||
allow="fullscreen"
|
||||
></iframe>`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<video controls>
|
||||
<source src="${url}" type="video/mp4">
|
||||
<source src="${url}" type="video/webm">
|
||||
Your browser does not support the video tag.
|
||||
</video>`,
|
||||
}
|
||||
}
|
||||
case "pdf":
|
||||
return opts.pdfComponent
|
||||
? {
|
||||
type: "html",
|
||||
value: `<embed src="${url}" type="application/pdf" width="100%" height="600px" />`,
|
||||
}
|
||||
: null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
|
||||
return {
|
||||
name: "RoamFlavoredMarkdown",
|
||||
markdownPlugins() {
|
||||
const plugins: PluggableList = []
|
||||
|
||||
plugins.push(() => {
|
||||
return (tree: Root) => {
|
||||
const replacements: [RegExp, ReplaceFunction][] = []
|
||||
|
||||
// Handle special embeds (audio, video, PDF)
|
||||
if (opts.audioComponent || opts.videoComponent || opts.pdfComponent) {
|
||||
visit(tree, "paragraph", ((node: Paragraph, index: number, parent: Parent | null) => {
|
||||
if (isSpecialEmbed(node)) {
|
||||
const transformedNode = transformSpecialEmbed(node, opts)
|
||||
if (transformedNode && parent) {
|
||||
parent.children[index] = transformedNode
|
||||
}
|
||||
}
|
||||
}) as BuildVisitor<Root, "paragraph">)
|
||||
}
|
||||
|
||||
// Roam italic syntax
|
||||
replacements.push([
|
||||
roamItalicRegex,
|
||||
(_value: string, match: string) => ({
|
||||
type: "emphasis",
|
||||
children: [{ type: "text", value: match }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Roam highlight syntax
|
||||
replacements.push([
|
||||
roamHighlightRegex,
|
||||
(_value: string, inner: string) => ({
|
||||
type: "html",
|
||||
value: `<span class="text-highlight">${inner}</span>`,
|
||||
}),
|
||||
])
|
||||
|
||||
if (opts.orComponent) {
|
||||
replacements.push([
|
||||
orRegex,
|
||||
(match: string) => {
|
||||
const matchResult = match.match(/{{or:(.*?)}}/)
|
||||
if (matchResult === null) {
|
||||
return { type: "html", value: "" }
|
||||
}
|
||||
const optionsString: string = matchResult[1]
|
||||
const options: string[] = optionsString.split("|")
|
||||
const selectHtml: string = `<select>${options.map((option: string) => `<option value="${option}">${option}</option>`).join("")}</select>`
|
||||
return { type: "html", value: selectHtml }
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.TODOComponent) {
|
||||
replacements.push([
|
||||
TODORegex,
|
||||
() => ({
|
||||
type: "html",
|
||||
value: `<input type="checkbox" disabled>`,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.DONEComponent) {
|
||||
replacements.push([
|
||||
DONERegex,
|
||||
() => ({
|
||||
type: "html",
|
||||
value: `<input type="checkbox" checked disabled>`,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.blockquoteComponent) {
|
||||
replacements.push([
|
||||
blockquoteRegex,
|
||||
(_match: string, _marker: string, content: string) => ({
|
||||
type: "html",
|
||||
value: `<blockquote>${content.trim()}</blockquote>`,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
mdastFindReplace(tree, replacements)
|
||||
}
|
||||
})
|
||||
|
||||
return plugins
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code"
|
||||
|
||||
interface Theme extends Record<string, CodeTheme> {
|
||||
light: CodeTheme
|
||||
dark: CodeTheme
|
||||
}
|
||||
|
||||
interface Options {
|
||||
theme?: Theme
|
||||
keepBackground?: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
theme: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
keepBackground: false,
|
||||
}
|
||||
|
||||
export const SyntaxHighlighting: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts: CodeOptions = { ...defaultOptions, ...userOpts }
|
||||
|
||||
return {
|
||||
name: "SyntaxHighlighting",
|
||||
htmlPlugins() {
|
||||
return [[rehypePrettyCode, opts]]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { Root } from "mdast"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { toString } from "mdast-util-to-string"
|
||||
import Slugger from "github-slugger"
|
||||
|
||||
export interface Options {
|
||||
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
|
||||
minEntries: number
|
||||
showByDefault: boolean
|
||||
collapseByDefault: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
maxDepth: 3,
|
||||
minEntries: 1,
|
||||
showByDefault: true,
|
||||
collapseByDefault: false,
|
||||
}
|
||||
|
||||
interface TocEntry {
|
||||
depth: number
|
||||
text: string
|
||||
slug: string // this is just the anchor (#some-slug), not the canonical slug
|
||||
}
|
||||
|
||||
const slugAnchor = new Slugger()
|
||||
export const TableOfContents: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
name: "TableOfContents",
|
||||
markdownPlugins() {
|
||||
return [
|
||||
() => {
|
||||
return async (tree: Root, file) => {
|
||||
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
|
||||
if (display) {
|
||||
slugAnchor.reset()
|
||||
const toc: TocEntry[] = []
|
||||
let highestDepth: number = opts.maxDepth
|
||||
visit(tree, "heading", (node) => {
|
||||
if (node.depth <= opts.maxDepth) {
|
||||
const text = toString(node)
|
||||
highestDepth = Math.min(highestDepth, node.depth)
|
||||
toc.push({
|
||||
depth: node.depth,
|
||||
text,
|
||||
slug: slugAnchor.slug(text),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (toc.length > 0 && toc.length > opts.minEntries) {
|
||||
file.data.toc = toc.map((entry) => ({
|
||||
...entry,
|
||||
depth: entry.depth - highestDepth,
|
||||
}))
|
||||
file.data.collapseToc = opts.collapseByDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
toc: TocEntry[]
|
||||
collapseToc: boolean
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user