Compare commits

..

23 Commits

Author SHA1 Message Date
Jacky Zhao
0d8c025d6a deps: version bump 2023-12-02 17:00:06 -08:00
Jacky Zhao
54b4a5567c fix: fmt 2023-12-02 16:55:38 -08:00
Jacky Zhao
610b04406f fix: incorrect test 2023-12-02 16:54:09 -08:00
Jacky Zhao
82bd08d14a fix: transcludes and relative paths 2023-12-02 16:51:03 -08:00
mancuoj
649090de1b docs: add deploy with netlify (#613) 2023-12-01 22:59:02 -08:00
Jacky Zhao
b5fec6c87f feat: allow popovers on intrapage links (closes #243) 2023-12-01 09:00:47 -08:00
Jacky Zhao
0d314db1f8 fix(style): overflow on toc 2023-11-29 10:50:47 -08:00
Odaimoko
660aae62e0 docs: add Imk&Cc's homepage to showcase.md (#595)
* add Imk&Cc's homepage to showcase.md

* Update showcase.md

* Update showcase.md
2023-11-27 23:05:18 -08:00
Rune Antonsen
9a599aebea feat(breadcrumbs): add option to hide current page (#601)
* feat(breadcrumbs): add option to hide current page

* Remove debug lines

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

---------

Co-authored-by: ruant <ruant@ruant.net>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2023-11-20 08:28:16 -08:00
Jacky Zhao
296c1cf83f fix: spa shouldn't use popover script directly 2023-11-18 18:46:58 -08:00
Jacky Zhao
516d9a27e7 fix: explicit undefined check in header transclude 2023-11-18 18:27:44 -08:00
Jacky Zhao
6a05fa777c fix: bad transform in wikilink pre-transform (closes #598) 2023-11-17 14:00:49 -08:00
Jacky Zhao
3f0be7fbe4 fix: check content-type before applying spa patch (closes #597) 2023-11-17 10:46:23 -08:00
Jacky Zhao
ea08c0511a fix: dont run explorer scripts on non-explorer pages (closes #596) 2023-11-17 10:29:24 -08:00
Matt Vogel
727b9b5d72 feat: add class alias to aliases (#585) 2023-11-17 10:23:39 -08:00
Zijing Zhang
50f0ba29a2 feat: cname emitter (#590)
* feat: cname emitter

* feat: impl cname.ts

* Update cname.ts

* Update index.ts

* Update cname.ts

* Update cname.ts

* Update cname.ts

* Update cname.ts
2023-11-16 15:31:20 -08:00
Jacky Zhao
95b1141b9d fix: include anchor when normalizing urls for spa/popovers 2023-11-15 20:35:45 -08:00
Jacky Zhao
a26eb59392 feat: scrub link formatting from toc entries 2023-11-15 20:13:28 -08:00
Jacky Zhao
5befcf4780 fix: format 2023-11-15 19:32:25 -08:00
Jacky Zhao
f861a7c160 fix: regression where clicking anchors on the same page wouldn't set the anchor in the url 2023-11-15 19:31:18 -08:00
Jacky Zhao
06426c8f7e feat: support repeated anchor tag (closes #592) 2023-11-15 19:27:54 -08:00
Jacky Zhao
8fc7b9f4c6 feat: deref symlinks when copying static assets (closes #588) 2023-11-15 09:43:30 -08:00
Jacky Zhao
2de48b267a fix: set htmlAst after walking tree in ofm (closes #589) 2023-11-14 20:01:48 -08:00
20 changed files with 209 additions and 70 deletions

View File

@@ -20,6 +20,7 @@ Component.Breadcrumbs({
rootName: "Home", // name of first/root element rootName: "Home", // name of first/root element
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
showCurrentPage: true, // wether to display the current page in the breadcrumbs
}) })
``` ```

View File

@@ -167,6 +167,17 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
4. Go to the Settings tab and then click Domains in the sidebar 4. Go to the Settings tab and then click Domains in the sidebar
5. Enter your subdomain into the field and press Add 5. Enter your subdomain into the field and press Add
## Netlify
Like Vercel, you can also deploy the site generated by Quartz 4 via Netlify.
1. Log in to the [Netlify dashboard](https://app.netlify.com/) and click "Add new site".
2. Select your Git provider and repository containing your Quartz project.
3. Under "Build command", enter `npx quartz build`.
4. Under "Publish directory", enter `public`.
5. Press Deploy. Once it's live, you'll have a `*.netlify.app` URL to view the page.
6. To add a custom domain, check "Domain management" in the left sidebar, just like with Vercel.
## GitLab Pages ## GitLab Pages
You can configure GitLab CI to build and deploy a Quartz 4 project. You can configure GitLab CI to build and deploy a Quartz 4 project.

View File

@@ -20,5 +20,6 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/) - [🧠🌳 Chad's Mind Garden](https://www.chadly.net/)
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/) - [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
- [Mau Camargo's Notkesto](https://notes.camargomau.com/) - [Mau Camargo's Notkesto](https://notes.camargomau.com/)
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!

7
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.11", "version": "4.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.11", "version": "4.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.6.3", "@clack/prompts": "^0.6.3",
@@ -85,7 +85,8 @@
"typescript": "^5.0.4" "typescript": "^5.0.4"
}, },
"engines": { "engines": {
"node": ">=18.14" "node": ">=18.14",
"npm": ">=9.3.1"
} }
}, },
"node_modules/@clack/core": { "node_modules/@clack/core": {

View File

@@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.1.1", "version": "4.1.2",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",

View File

@@ -25,6 +25,10 @@ interface BreadcrumbOptions {
* Wether to display breadcrumbs on root `index.md` * Wether to display breadcrumbs on root `index.md`
*/ */
hideOnRoot: boolean hideOnRoot: boolean
/**
* Wether to display the current page in the breadcrumbs.
*/
showCurrentPage: boolean
} }
const defaultOptions: BreadcrumbOptions = { const defaultOptions: BreadcrumbOptions = {
@@ -32,6 +36,7 @@ const defaultOptions: BreadcrumbOptions = {
rootName: "Home", rootName: "Home",
resolveFrontmatterTitle: true, resolveFrontmatterTitle: true,
hideOnRoot: true, hideOnRoot: true,
showCurrentPage: true,
} }
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData { function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
@@ -95,11 +100,13 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
} }
// Add current file to crumb (can directly use frontmatter title) // Add current file to crumb (can directly use frontmatter title)
if (options.showCurrentPage) {
crumbs.push({ crumbs.push({
displayName: fileData.frontmatter!.title, displayName: fileData.frontmatter!.title,
path: "", path: "",
}) })
} }
}
return ( return (
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs"> <nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => ( {crumbs.map((crumb, index) => (

View File

@@ -3,9 +3,10 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header" import HeaderConstructor from "./Header"
import BodyConstructor from "./Body" import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { FullSlug, RelativeURL, joinSegments } from "../util/path" import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast" import { Root, Element, ElementContent } from "hast"
import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@@ -49,6 +50,18 @@ export function pageResources(
} }
} }
let pageIndex: Map<FullSlug, QuartzPluginData> | undefined = undefined
function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map<FullSlug, QuartzPluginData> {
if (!pageIndex) {
pageIndex = new Map()
for (const file of allFiles) {
pageIndex.set(file.slug!, file)
}
}
return pageIndex
}
export function renderPage( export function renderPage(
slug: FullSlug, slug: FullSlug,
componentData: QuartzComponentProps, componentData: QuartzComponentProps,
@@ -62,17 +75,15 @@ export function renderPage(
if (classNames.includes("transclude")) { if (classNames.includes("transclude")) {
const inner = node.children[0] as Element const inner = node.children[0] as Element
const transcludeTarget = inner.properties?.["data-slug"] as FullSlug const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
// TODO: avoid this expensive find operation and construct an index ahead of time
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) { if (!page) {
return return
} }
let blockRef = node.properties?.dataBlock as string | undefined let blockRef = node.properties?.dataBlock as string | undefined
if (blockRef?.startsWith("^")) { if (blockRef?.startsWith("#^")) {
// block transclude // block transclude
blockRef = blockRef.slice(1) blockRef = blockRef.slice("#^".length)
let blockNode = page.blocks?.[blockRef] let blockNode = page.blocks?.[blockRef]
if (blockNode) { if (blockNode) {
if (blockNode.tagName === "li") { if (blockNode.tagName === "li") {
@@ -84,7 +95,7 @@ export function renderPage(
} }
node.children = [ node.children = [
blockNode, normalizeHastElement(blockNode, slug, transcludeTarget),
{ {
type: "element", type: "element",
tagName: "a", tagName: "a",
@@ -104,7 +115,7 @@ export function renderPage(
break break
} }
if (startIdx) { if (startIdx !== undefined) {
endIdx = i endIdx = i
} else if (el.properties?.id === blockRef) { } else if (el.properties?.id === blockRef) {
startIdx = i startIdx = i
@@ -112,12 +123,14 @@ export function renderPage(
} }
} }
if (!startIdx) { if (startIdx === undefined) {
return return
} }
node.children = [ node.children = [
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]), ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
normalizeHastElement(child as Element, slug, transcludeTarget),
),
{ {
type: "element", type: "element",
tagName: "a", tagName: "a",
@@ -135,7 +148,9 @@ export function renderPage(
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` }, { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
], ],
}, },
...(page.htmlAst.children as ElementContent[]), ...(page.htmlAst.children as ElementContent[]).map((child) =>
normalizeHastElement(child as Element, slug, transcludeTarget),
),
{ {
type: "element", type: "element",
tagName: "a", tagName: "a",

View File

@@ -120,9 +120,9 @@ function setupExplorer() {
} }
} }
}) })
} else { } else if (explorer?.dataset.tree) {
// If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
explorerState = JSON.parse(explorer?.dataset.tree as string) explorerState = JSON.parse(explorer.dataset.tree)
} }
} }
@@ -130,12 +130,13 @@ window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
setupExplorer() setupExplorer()
const explorerContent = document.getElementById("explorer-ul") observer.disconnect()
// select pseudo element at end of list // select pseudo element at end of list
const lastItem = document.getElementById("explorer-end") const lastItem = document.getElementById("explorer-end")
if (lastItem) {
observer.disconnect() observer.observe(lastItem)
observer.observe(lastItem as Element) }
}) })
/** /**

View File

@@ -1,4 +1,4 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex" import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
@@ -46,20 +46,22 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
showTags, showTags,
} = JSON.parse(graph.dataset["cfg"]!) } = JSON.parse(graph.dataset["cfg"]!)
const data = await fetchData const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
simplifySlug(k as FullSlug),
v,
]),
)
const links: LinkData[] = [] const links: LinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) const validLinks = new Set(data.keys())
for (const [source, details] of data.entries()) {
for (const [src, details] of Object.entries<ContentDetails>(data)) {
const source = simplifySlug(src as FullSlug)
const outgoing = details.links ?? [] const outgoing = details.links ?? []
for (const dest of outgoing) { for (const dest of outgoing) {
if (validLinks.has(dest)) { if (validLinks.has(dest)) {
links.push({ source, target: dest }) links.push({ source: source, target: dest })
} }
} }
@@ -71,7 +73,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
tags.push(...localTags.filter((tag) => !tags.includes(tag))) tags.push(...localTags.filter((tag) => !tags.includes(tag)))
for (const tag of localTags) { for (const tag of localTags) {
links.push({ source, target: tag }) links.push({ source: source, target: tag })
} }
} }
} }
@@ -93,17 +95,17 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
} }
} }
} else { } else {
Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) validLinks.forEach((id) => neighbourhood.add(id))
if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
} }
const graphData: { nodes: NodeData[]; links: LinkData[] } = { const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes: [...neighbourhood].map((url) => { nodes: [...neighbourhood].map((url) => {
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
return { return {
id: url, id: url,
text: text, text: text,
tags: data[url]?.tags ?? [], tags: data.get(url)?.tags ?? [],
} }
}), }),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
@@ -200,7 +202,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
}) })
.on("mouseover", function (_, d) { .on("mouseover", function (_, d) {
const neighbours: SimpleSlug[] = data[fullSlug].links ?? [] const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
const neighbourNodes = d3 const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node") .selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id)) .filter((d) => neighbours.includes(d.id))

View File

@@ -1,16 +1,5 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path"
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
const update = (el: Element, attr: string, base: string | URL) => {
el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
}
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
}
const p = new DOMParser() const p = new DOMParser()
async function mouseEnterHandler( async function mouseEnterHandler(
@@ -43,8 +32,6 @@ async function mouseEnterHandler(
const hash = targetUrl.hash const hash = targetUrl.hash
targetUrl.hash = "" targetUrl.hash = ""
targetUrl.search = "" targetUrl.search = ""
// prevent hover of the same page
if (thisUrl.toString() === targetUrl.toString()) return
const contents = await fetch(`${targetUrl}`) const contents = await fetch(`${targetUrl}`)
.then((res) => res.text()) .then((res) => res.text())

View File

@@ -1,10 +1,8 @@
import micromorph from "micromorph" import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { normalizeRelativeURLs } from "./popover.inline"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/micromorph // https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1 const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement("route-announcer") let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element => const isElement = (target: EventTarget | null): target is Element =>
@@ -45,7 +43,14 @@ let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) { async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser() p = p || new DOMParser()
const contents = await fetch(`${url}`) const contents = await fetch(`${url}`)
.then((res) => res.text()) .then((res) => {
const contentType = res.headers.get("content-type")
if (contentType?.startsWith("text/html")) {
return res.text()
} else {
window.location.assign(url)
}
})
.catch(() => { .catch(() => {
window.location.assign(url) window.location.assign(url)
}) })
@@ -109,6 +114,7 @@ function createRouter() {
if (isSamePage(url) && url.hash) { if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView() el?.scrollIntoView()
history.pushState({}, "", url)
return return
} }

View File

@@ -30,6 +30,7 @@ button#toc {
overflow: hidden; overflow: hidden;
max-height: none; max-height: none;
transition: max-height 0.5s ease; transition: max-height 0.5s ease;
position: relative;
&.collapsed > .overflow::after { &.collapsed > .overflow::after {
opacity: 0; opacity: 0;

View File

@@ -0,0 +1,29 @@
import { FilePath, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import chalk from "chalk"
export function extractDomainFromBaseUrl(baseUrl: string) {
const url = new URL(`https://${baseUrl}`)
return url.hostname
}
export const CNAME: QuartzEmitterPlugin = () => ({
name: "CNAME",
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []
}
const path = joinSegments(argv.output, "CNAME")
const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl)
if (!content) {
return []
}
fs.writeFileSync(path, content)
return [path] as FilePath[]
},
})

View File

@@ -7,3 +7,4 @@ export { Assets } from "./assets"
export { Static } from "./static" export { Static } from "./static"
export { ComponentResources } from "./componentResources" export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404" export { NotFoundPage } from "./404"
export { CNAME } from "./cname"

View File

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

View File

@@ -54,6 +54,16 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
node.properties.className ??= [] node.properties.className ??= []
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
// 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
node.properties.className.push("alias")
}
if (opts.openLinksInNewTab) { if (opts.openLinksInNewTab) {
node.properties.target = "_blank" node.properties.target = "_blank"
} }
@@ -71,14 +81,16 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, `https://base.com/${curSlug}`) const url = new URL(dest, `https://base.com/${curSlug}`)
const canonicalDest = url.pathname const canonicalDest = url.pathname
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest) let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) {
destCanonical += "index"
}
// need to decodeURIComponent here as WHATWG URL percent-encodes everything // need to decodeURIComponent here as WHATWG URL percent-encodes everything
const simple = decodeURIComponent( const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
simplifySlug(destCanonical as FullSlug), const simple = simplifySlug(full)
) as SimpleSlug
outgoing.add(simple) outgoing.add(simple)
node.properties["data-slug"] = simple node.properties["data-slug"] = full
} }
// rewrite link internals if prettylinks is on // rewrite link internals if prettylinks is on

View File

@@ -110,7 +110,10 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts {
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") export const wikilinkRegex = new RegExp(
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
"g",
)
const highlightRegex = new RegExp(/==([^=]+)==/, "g") const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g") const commentRegex = new RegExp(/%%(.+)%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
@@ -178,8 +181,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
src = src.replaceAll(wikilinkRegex, (value, ...capture) => { src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias] = capture const [rawFp, rawHeader, rawAlias] = capture
const fp = rawFp ?? "" const fp = rawFp ?? ""
const anchor = rawHeader?.trim().slice(1) const anchor = rawHeader?.trim().replace(/^#+/, "")
const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : "" const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : "" const embedDisplay = value.startsWith("!") ? "!" : ""
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
@@ -436,7 +440,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
const blockTagTypes = new Set(["blockquote"]) const blockTagTypes = new Set(["blockquote"])
return (tree, file) => { return (tree, file) => {
file.data.blocks = {} file.data.blocks = {}
file.data.htmlAst = tree
visit(tree, "element", (node, index, parent) => { visit(tree, "element", (node, index, parent) => {
if (blockTagTypes.has(node.tagName)) { if (blockTagTypes.has(node.tagName)) {
@@ -478,6 +481,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
} }
}) })
file.data.htmlAst = tree
} }
}) })
} }

View File

@@ -3,6 +3,7 @@ import { Root } from "mdast"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string" import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger" import Slugger from "github-slugger"
import { wikilinkRegex } from "./ofm"
export interface Options { export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6 maxDepth: 1 | 2 | 3 | 4 | 5 | 6
@@ -24,6 +25,7 @@ interface TocEntry {
slug: string // this is just the anchor (#some-slug), not the canonical slug slug: string // this is just the anchor (#some-slug), not the canonical slug
} }
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
) => { ) => {
@@ -41,7 +43,16 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
let highestDepth: number = opts.maxDepth let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => { visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) { if (node.depth <= opts.maxDepth) {
const text = toString(node) let text = toString(node)
// strip link formatting from toc entries
text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
const fp = rawFp?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
return alias ?? fp
})
text = text.replace(regexMdLinks, "$1")
highestDepth = Math.min(highestDepth, node.depth) highestDepth = Math.min(highestDepth, node.depth)
toc.push({ toc.push({
depth: node.depth, depth: node.depth,

View File

@@ -83,7 +83,7 @@ describe("transforms", () => {
test("simplifySlug", () => { test("simplifySlug", () => {
asserts( asserts(
[ [
["index", ""], ["index", "/"],
["abc", "abc"], ["abc", "abc"],
["abc/index", "abc/"], ["abc/index", "abc/"],
["abc/def", "abc/def"], ["abc/def", "abc/def"],

View File

@@ -1,4 +1,5 @@
import { slug } from "github-slugger" import { slug } from "github-slugger"
import type { Element as HastElement } from "hast"
// this file must be isomorphic so it can't use node libs (e.g. path) // this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz" export const QUARTZ = "quartz"
@@ -24,7 +25,7 @@ export function isFullSlug(s: string): s is FullSlug {
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
export type SimpleSlug = SlugLike<"simple"> export type SimpleSlug = SlugLike<"simple">
export function isSimpleSlug(s: string): s is SimpleSlug { export function isSimpleSlug(s: string): s is SimpleSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/")) const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
const validEnding = !(s.endsWith("/index") || s === "index") const validEnding = !(s.endsWith("/index") || s === "index")
return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
} }
@@ -65,7 +66,8 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
} }
export function simplifySlug(fp: FullSlug): SimpleSlug { export function simplifySlug(fp: FullSlug): SimpleSlug {
return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug const res = _stripSlashes(_trimSuffix(fp, "index"), true)
return (res.length === 0 ? "/" : res) as SimpleSlug
} }
export function transformInternalLink(link: string): RelativeURL { export function transformInternalLink(link: string): RelativeURL {
@@ -84,6 +86,49 @@ export function transformInternalLink(link: string): RelativeURL {
return res return res
} }
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => {
const rebased = new URL(el.getAttribute(attr)!, newBase)
el.setAttribute(attr, rebased.pathname + rebased.hash)
}
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "href", destination),
)
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "src", destination),
)
}
const _rebaseHastElement = (
el: HastElement,
attr: string,
curBase: FullSlug,
newBase: FullSlug,
) => {
if (el.properties?.[attr]) {
if (!isRelativeURL(String(el.properties[attr]))) {
return
}
const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string)
el.properties[attr] = rel
}
}
export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) {
_rebaseHastElement(el, "src", curBase, newBase)
_rebaseHastElement(el, "href", curBase, newBase)
if (el.children) {
el.children = el.children.map((child) =>
normalizeHastElement(child as HastElement, curBase, newBase),
)
}
return el
}
// resolve /a/b/c to ../.. // resolve /a/b/c to ../..
export function pathToRoot(slug: FullSlug): RelativeURL { export function pathToRoot(slug: FullSlug): RelativeURL {
let rootPath = slug let rootPath = slug