Compare commits

..

45 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
Jacky Zhao
76f2664277 versioning: bump to v4.1.1 2023-11-13 22:57:05 -08:00
Jacky Zhao
74777118a7 feat: header and full-page transcludes (closes #557) 2023-11-13 22:51:40 -08:00
Jacky Zhao
8223465bda fix: make :has img selector direct 2023-11-12 14:33:19 -08:00
Jacky Zhao
cf6ab9e933 feat: option to specify npx quartz sync message (closes #583) 2023-11-12 14:27:53 -08:00
Jacky Zhao
74c63e448e fix(style): dont internal-link highlight when image (closes #581) 2023-11-11 21:13:10 -08:00
Jacky Zhao
43d638a6de perf: compute mapping of folder name to file data for faster breadcrumbs 2023-11-11 21:06:37 -08:00
Jacky Zhao
d1551872ff fix: check if popover exists after fetching and before inserting 2023-11-11 20:46:57 -08:00
Jacky Zhao
275bea3051 style + cfg: resolve breadcrumb titles by default and change arrow character 2023-11-11 20:46:29 -08:00
Jacky Zhao
bc02791734 fix: .date.getTime() based sort 2023-11-11 20:28:26 -08:00
Jacky Zhao
bf603c49c2 fix: sort rss feed by date 2023-11-11 12:08:54 -08:00
Jacky Zhao
f67356c3d2 lint: format 2023-11-11 12:02:34 -08:00
Jacky Zhao
5d666d1860 fix: normalize relative urls (closes #569) 2023-11-11 11:59:05 -08:00
Jacky Zhao
22b7cf135e types: cast in jsx.tsx to avoid @ts-ignore 2023-11-11 11:41:44 -08:00
Jacky Zhao
50a87d0d86 style: scrollable tables 2023-11-11 11:39:56 -08:00
Jacky Zhao
134b6ed582 fix: anchors links shouldnt cause reload (closes #574) 2023-11-11 10:11:31 -08:00
Jacky Zhao
99e8f5944f fix: trailing slash aliases (closes #577) 2023-11-11 09:56:30 -08:00
Yes365
e9f4e28a2d fix: adapt vercel cleanurls (#487)
Co-authored-by: Harrison <Harrison@fanruan.com>
2023-11-09 19:44:16 -08:00
Niklas Schröder
2a6b9a9ea0 docs: fix property name for ToC toggle (#573) 2023-11-07 09:16:48 -08:00
Mau Camargo
e806c30fa1 docs: Add Mau Camargo's Notkesto to showcase (#570) 2023-11-05 11:30:10 -08:00
Anson Yu
aac7b7e97d docs: Update making plugins.md (#567)
:)
2023-11-04 14:20:16 -07:00
Jacky Zhao
101e9946bd feat: add collapseByDefault option to TableOfContents (closes #566) 2023-11-04 12:11:42 -07:00
Emil Rofors
a62a97c7ab docs: add GitLab pages CI (#549)
* add .gitlab-ci.yml

* move GitLab CI to hosting.md

* remove extra folder name

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

* remove test from gitlab instructions

* run prettier

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2023-11-03 16:40:43 -07:00
33 changed files with 467 additions and 137 deletions

View File

@@ -228,7 +228,7 @@ export type QuartzEmitterPluginInstance = {
An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created. An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. It's interface looks something like this: Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. Its interface looks something like this:
```ts ```ts
export type EmitCallback = (data: { export type EmitCallback = (data: {

View File

@@ -16,10 +16,11 @@ For example, here's what the default configuration looks like:
```typescript title="quartz.layout.ts" ```typescript title="quartz.layout.ts"
Component.Breadcrumbs({ Component.Breadcrumbs({
spacerSymbol: ">", // symbol between crumbs spacerSymbol: "", // symbol between crumbs
rootName: "Home", // name of first/root element rootName: "Home", // name of first/root element
resolveFrontmatterTitle: false, // wether to resolve folder names through frontmatter titles (more computationally expensive) resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
hideOnRoot: true, // wether 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

@@ -33,7 +33,7 @@ See [documentation on supported types and syntax here](https://help.obsidian.md
> [!question]+ Can callouts be nested? > [!question]+ Can callouts be nested?
> >
> > [!todo]- Yes!, they can. > > [!todo]- Yes!, they can. And collapsed!
> > > >
> > > [!example] You can even use multiple layers of nesting. > > > [!example] You can even use multiple layers of nesting.

View File

@@ -8,7 +8,7 @@ tags:
Quartz can automatically generate a table of contents from a list of headings on each page. It will also show you your current scroll position on the site by marking headings you've scrolled through with a different colour. Quartz can automatically generate a table of contents from a list of headings on each page. It will also show you your current scroll position on the site by marking headings you've scrolled through with a different colour.
By default, it will show all headers from H1 (`# Title`) all the way to H3 (`### Title`) and will only show the table of contents if there is more than 1 header on the page. By default, it will show all headers from H1 (`# Title`) all the way to H3 (`### Title`) and will only show the table of contents if there is more than 1 header on the page.
You can also hide the table of contents on a page by adding `showToc: false` to the frontmatter for that page. You can also hide the table of contents on a page by adding `enableToc: false` to the frontmatter for that page.
> [!info] > [!info]
> This feature requires both `Plugin.TableOfContents` in your `quartz.config.ts` and `Component.TableOfContents` in your `quartz.layout.ts` to function correctly. > This feature requires both `Plugin.TableOfContents` in your `quartz.config.ts` and `Component.TableOfContents` in your `quartz.layout.ts` to function correctly.
@@ -18,6 +18,7 @@ You can also hide the table of contents on a page by adding `showToc: false` to
- Removing table of contents: remove all instances of `Plugin.TableOfContents()` from `quartz.config.ts`. and `Component.TableOfContents()` from `quartz.layout.ts` - Removing table of contents: remove all instances of `Plugin.TableOfContents()` from `quartz.config.ts`. and `Component.TableOfContents()` from `quartz.layout.ts`
- Changing the max depth: pass in a parameter to `Plugin.TableOfContents({ maxDepth: 4 })` - Changing the max depth: pass in a parameter to `Plugin.TableOfContents({ maxDepth: 4 })`
- Changing the minimum number of entries in the Table of Contents before it renders: pass in a parameter to `Plugin.TableOfContents({ minEntries: 3 })` - Changing the minimum number of entries in the Table of Contents before it renders: pass in a parameter to `Plugin.TableOfContents({ minEntries: 3 })`
- Collapse the table of content by default: pass in a parameter to `Plugin.TableOfContents({ collapseByDefault: true })`
- Component: `quartz/components/TableOfContents.tsx` - Component: `quartz/components/TableOfContents.tsx`
- Style: - Style:
- Modern (default): `quartz/components/styles/toc.scss` - Modern (default): `quartz/components/styles/toc.scss`

View File

@@ -166,3 +166,67 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
3. Go to the [Vercel Dashboard](https://vercel.com/dashboard) and select your Quartz project. 3. Go to the [Vercel Dashboard](https://vercel.com/dashboard) and select your Quartz project.
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
You can configure GitLab CI to build and deploy a Quartz 4 project.
In your local Quartz, create a new file `.gitlab-ci.yaml`.
```yaml title=".gitlab-ci.yaml"
stages:
- build
- deploy
variables:
NODE_VERSION: "18.14"
build:
stage: build
rules:
- if: '$CI_COMMIT_REF_NAME == "v4"'
before_script:
- apt-get update -q && apt-get install -y nodejs npm
- npm install -g n
- n $NODE_VERSION
- hash -r
- npm ci
script:
- npx prettier --write .
- npm run check
- npx quartz build
artifacts:
paths:
- public
cache:
paths:
- ~/.npm/
key: "${CI_COMMIT_REF_SLUG}-node-${CI_COMMIT_REF_NAME}"
tags:
- docker
pages:
stage: deploy
rules:
- if: '$CI_COMMIT_REF_NAME == "v4"'
script:
- echo "Deploying to GitLab Pages..."
artifacts:
paths:
- public
```
When `.gitlab-ci.yaml` is commited, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy` -> `Pages` in the sidebar.
By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`.

View File

@@ -19,5 +19,7 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Vince Imbat's Talahardin](https://vinceimbat.com/) - [Vince Imbat's Talahardin](https://vinceimbat.com/)
- [🧠🌳 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/)
- [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.0", "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

@@ -41,6 +41,11 @@ export const SyncArgv = {
default: true, default: true,
describe: "create a git commit for your unsaved changes", describe: "create a git commit for your unsaved changes",
}, },
message: {
string: true,
alias: ["m"],
describe: "option to override the default Quartz commit message",
},
push: { push: {
boolean: true, boolean: true,
default: true, default: true,

View File

@@ -483,8 +483,9 @@ export async function handleSync(argv) {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short", timeStyle: "short",
}) })
const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}`
spawnSync("git", ["add", "."], { stdio: "inherit" }) spawnSync("git", ["add", "."], { stdio: "inherit" })
spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" }) spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" })
if (contentStat.isSymbolicLink()) { if (contentStat.isSymbolicLink()) {
// put symlink back // put symlink back

View File

@@ -25,13 +25,18 @@ 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 = {
spacerSymbol: ">", spacerSymbol: "",
rootName: "Home", rootName: "Home",
resolveFrontmatterTitle: false, 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 {
@@ -41,25 +46,13 @@ function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: Simpl
} }
} }
// given a folderName (e.g. "features"), search for the corresponding `index.md` file
function findCurrentFile(allFiles: QuartzPluginData[], folderName: string) {
return allFiles.find((file) => {
if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/")
if (folderParts) {
const name = folderParts[folderParts?.length - 2]
if (name === folderName) {
return true
}
}
}
})
}
export default ((opts?: Partial<BreadcrumbOptions>) => { export default ((opts?: Partial<BreadcrumbOptions>) => {
// Merge options with defaults // Merge options with defaults
const options: BreadcrumbOptions = { ...defaultOptions, ...opts } const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
// computed index of folder name to its associated file data
let folderIndex: Map<string, QuartzPluginData> | undefined
function Breadcrumbs({ fileData, allFiles, displayClass }: QuartzComponentProps) { function Breadcrumbs({ fileData, allFiles, displayClass }: QuartzComponentProps) {
// Hide crumbs on root if enabled // Hide crumbs on root if enabled
if (options.hideOnRoot && fileData.slug === "index") { if (options.hideOnRoot && fileData.slug === "index") {
@@ -70,37 +63,50 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug) const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
const crumbs: CrumbData[] = [firstEntry] const crumbs: CrumbData[] = [firstEntry]
if (!folderIndex && options.resolveFrontmatterTitle) {
folderIndex = new Map()
// construct the index for the first time
for (const file of allFiles) {
if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/")
if (folderParts) {
const folderName = folderParts[folderParts?.length - 2]
folderIndex.set(folderName, file)
}
}
}
}
// Split slug into hierarchy/parts // Split slug into hierarchy/parts
const slugParts = fileData.slug?.split("/") const slugParts = fileData.slug?.split("/")
if (slugParts) { if (slugParts) {
// full path until current part // full path until current part
let currentPath = "" let currentPath = ""
for (let i = 0; i < slugParts.length - 1; i++) { for (let i = 0; i < slugParts.length - 1; i++) {
let currentTitle = slugParts[i] let curPathSegment = slugParts[i]
// TODO: performance optimizations/memoizing
// Try to resolve frontmatter folder title // Try to resolve frontmatter folder title
if (options?.resolveFrontmatterTitle) { const currentFile = folderIndex?.get(curPathSegment)
// try to find file for current path
const currentFile = findCurrentFile(allFiles, currentTitle)
if (currentFile) { if (currentFile) {
currentTitle = currentFile.frontmatter!.title curPathSegment = currentFile.frontmatter!.title
}
} }
// Add current slug to full path // Add current slug to full path
currentPath += slugParts[i] + "/" currentPath += slugParts[i] + "/"
// Format and add current crumb // Format and add current crumb
const crumb = formatCrumb(currentTitle, fileData.slug!, currentPath as SimpleSlug) const crumb = formatCrumb(curPathSegment, fileData.slug!, currentPath as SimpleSlug)
crumbs.push(crumb) crumbs.push(crumb)
} }
// 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

@@ -20,7 +20,7 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
return ( return (
<div class={`toc ${displayClass ?? ""}`}> <div class={`toc ${displayClass ?? ""}`}>
<button type="button" id="toc"> <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<h3>Table of Contents</h3> <h3>Table of Contents</h3>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -60,7 +60,7 @@ function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
} }
return ( return (
<details id="toc" open> <details id="toc" open={!fileData.collapseToc}>
<summary> <summary>
<h3>Table of Contents</h3> <h3>Table of Contents</h3>
</summary> </summary>

View File

@@ -53,7 +53,7 @@ function TagContent(props: QuartzComponentProps) {
return ( return (
<div> <div>
<h2> <h2>
<a class="internal tag-link" href={`./${tag}`}> <a class="internal tag-link" href={`../tags/${tag}`}>
#{tag} #{tag}
</a> </a>
</h2> </h2>

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 } 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,
@@ -61,11 +74,17 @@ export function renderPage(
const classNames = (node.properties?.className ?? []) as string[] const classNames = (node.properties?.className ?? []) as string[]
if (classNames.includes("transclude")) { if (classNames.includes("transclude")) {
const inner = node.children[0] as Element const inner = node.children[0] as Element
const blockSlug = inner.properties?.["data-slug"] as FullSlug const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
const blockRef = node.properties!.dataBlock as string const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
if (!page) {
return
}
// TODO: avoid this expensive find operation and construct an index ahead of time let blockRef = node.properties?.dataBlock as string | undefined
let blockNode = componentData.allFiles.find((f) => f.slug === blockSlug)?.blocks?.[blockRef] if (blockRef?.startsWith("#^")) {
// block transclude
blockRef = blockRef.slice("#^".length)
let blockNode = page.blocks?.[blockRef]
if (blockNode) { if (blockNode) {
if (blockNode.tagName === "li") { if (blockNode.tagName === "li") {
blockNode = { blockNode = {
@@ -76,7 +95,62 @@ export function renderPage(
} }
node.children = [ node.children = [
blockNode, normalizeHastElement(blockNode, slug, transcludeTarget),
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
},
]
}
} else if (blockRef?.startsWith("#") && page.htmlAst) {
// header transclude
blockRef = blockRef.slice(1)
let startIdx = undefined
let endIdx = undefined
for (const [i, el] of page.htmlAst.children.entries()) {
if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
if (endIdx) {
break
}
if (startIdx !== undefined) {
endIdx = i
} else if (el.properties?.id === blockRef) {
startIdx = i
}
}
}
if (startIdx === undefined) {
return
}
node.children = [
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
normalizeHastElement(child as Element, slug, transcludeTarget),
),
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
},
]
} else if (page.htmlAst) {
// page transclude
node.children = [
{
type: "element",
tagName: "h1",
children: [
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
],
},
...(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(
@@ -28,8 +17,11 @@ async function mouseEnterHandler(
}) })
} }
const hasAlreadyBeenFetched = () =>
[...link.children].some((child) => child.classList.contains("popover"))
// dont refetch if there's already a popover // dont refetch if there's already a popover
if ([...link.children].some((child) => child.classList.contains("popover"))) { if (hasAlreadyBeenFetched()) {
return setPosition(link.lastChild as HTMLElement) return setPosition(link.lastChild as HTMLElement)
} }
@@ -40,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())
@@ -49,6 +39,11 @@ async function mouseEnterHandler(
console.error(err) console.error(err)
}) })
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
if (!contents) return if (!contents) return
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl) normalizeRelativeURLs(html, targetUrl)

View File

@@ -1,9 +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"
// 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 =>
@@ -18,6 +17,12 @@ const isLocalUrl = (href: string) => {
return false return false
} }
const isSamePage = (url: URL): boolean => {
const sameOrigin = url.origin === window.location.origin
const samePath = url.pathname === window.location.pathname
return sameOrigin && samePath
}
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => { const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return if (!isElement(target)) return
if (target.attributes.getNamedItem("target")?.value === "_blank") return if (target.attributes.getNamedItem("target")?.value === "_blank") return
@@ -38,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)
}) })
@@ -46,6 +58,8 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return if (!contents) return
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url)
let title = html.querySelector("title")?.textContent let title = html.querySelector("title")?.textContent
if (title) { if (title) {
document.title = title document.title = title
@@ -93,8 +107,17 @@ function createRouter() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => { window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {} const { url } = getOpts(event) ?? {}
// dont hijack behaviour, just let browser act normally
if (!url || event.ctrlKey || event.metaKey) return if (!url || event.ctrlKey || event.metaKey) return
event.preventDefault() event.preventDefault()
if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
history.pushState({}, "", url)
return
}
try { try {
navigate(url, false) navigate(url, false)
} catch (e) { } catch (e) {
@@ -140,6 +163,7 @@ if (!customElements.get("route-announcer")) {
style: style:
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px", "position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
} }
customElements.define( customElements.define(
"route-announcer", "route-announcer",
class RouteAnnouncer extends HTMLElement { class RouteAnnouncer extends HTMLElement {

View File

@@ -24,8 +24,9 @@ function toggleToc(this: HTMLElement) {
function setupToc() { function setupToc() {
const toc = document.getElementById("toc") const toc = document.getElementById("toc")
if (toc) { if (toc) {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement const content = toc.nextElementSibling as HTMLElement
content.style.maxHeight = content.scrollHeight + "px" content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.removeEventListener("click", toggleToc) toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc) toc.addEventListener("click", toggleToc)
} }

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

@@ -1,4 +1,4 @@
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import path from "path" import path from "path"
@@ -25,7 +25,12 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
slugs.push(permalink as FullSlug) slugs.push(permalink as FullSlug)
} }
for (const slug of slugs) { for (let slug of slugs) {
// fix any slugs that have trailing slash
if (slug.endsWith("/")) {
slug = joinSegments(slug, "index") as FullSlug
}
const redirUrl = resolveRelative(slug, file.data.slug!) const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({ const fp = await emit({
content: ` content: `

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

@@ -59,6 +59,17 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
</item>` </item>`
const items = Array.from(idx) const items = Array.from(idx)
.sort(([_, f1], [__, f2]) => {
if (f1.date && f2.date) {
return f2.date.getTime() - f1.date.getTime()
} else if (f1.date && !f2.date) {
return -1
} else if (!f1.date && f2.date) {
return 1
}
return f1.title.localeCompare(f2.title)
})
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.slice(0, limit ?? idx.size) .slice(0, limit ?? idx.size)
.join("") .join("")

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

@@ -1,7 +1,7 @@
import { PluggableList } from "unified" import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Element, Literal } from "hast" import { Element, Literal, Root as HtmlRoot } from "hast"
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger" import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
@@ -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}]]`
@@ -236,13 +240,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
value: `<iframe src="${url}"></iframe>`, value: `<iframe src="${url}"></iframe>`,
} }
} else if (ext === "") { } else if (ext === "") {
const block = anchor.slice(1) const block = anchor
return { return {
type: "html", type: "html",
data: { hProperties: { transclude: true } }, data: { hProperties: { transclude: true } },
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${ value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
url + anchor url + anchor
}" class="transclude-inner">Transclude of block ${block}</a></blockquote>`, }" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
} }
} }
@@ -477,6 +481,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
} }
}) })
file.data.htmlAst = tree
} }
}) })
} }
@@ -524,5 +530,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
blocks: Record<string, Element> blocks: Record<string, Element>
htmlAst: HtmlRoot
} }
} }

View File

@@ -3,17 +3,20 @@ 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
minEntries: 1 minEntries: 1
showByDefault: boolean showByDefault: boolean
collapseByDefault: boolean
} }
const defaultOptions: Options = { const defaultOptions: Options = {
maxDepth: 3, maxDepth: 3,
minEntries: 1, minEntries: 1,
showByDefault: true, showByDefault: true,
collapseByDefault: false,
} }
interface TocEntry { interface TocEntry {
@@ -22,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,
) => { ) => {
@@ -39,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,
@@ -54,6 +67,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
...entry, ...entry,
depth: entry.depth - highestDepth, depth: entry.depth - highestDepth,
})) }))
file.data.collapseToc = opts.collapseByDefault
} }
} }
} }
@@ -66,5 +80,6 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
toc: TocEntry[] toc: TocEntry[]
collapseToc: boolean
} }
} }

View File

@@ -64,7 +64,7 @@ a {
color: var(--tertiary) !important; color: var(--tertiary) !important;
} }
&.internal { &.internal:not(:has(> img)) {
text-decoration: none; text-decoration: none;
background-color: var(--highlight); background-color: var(--highlight);
padding: 0 0.1rem; padding: 0 0.1rem;
@@ -390,23 +390,33 @@ p {
line-height: 1.6rem; line-height: 1.6rem;
} }
table { .table-container {
overflow-x: auto;
& > table {
margin: 1rem; margin: 1rem;
padding: 1.5rem; padding: 1.5rem;
border-collapse: collapse; border-collapse: collapse;
th,
td {
min-width: 75px;
}
& > * { & > * {
line-height: 2rem; line-height: 2rem;
} }
} }
}
th { th {
text-align: left; text-align: left;
padding: 0.4rem 1rem; padding: 0.4rem 0.7rem;
border-bottom: 2px solid var(--gray); border-bottom: 2px solid var(--gray);
} }
td { td {
padding: 0.2rem 1rem; padding: 0.2rem 0.7rem;
} }
tr { tr {

View File

@@ -1,15 +0,0 @@
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import { QuartzPluginData } from "../plugins/vfile"
import { Node, Root } from "hast"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { trace } from "./trace"
import { type FilePath } from "./path"
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
try {
// @ts-ignore (preact makes it angry)
return toJsxRuntime(tree as Root, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
} catch (e) {
trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
}
}

28
quartz/util/jsx.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
import { QuartzPluginData } from "../plugins/vfile"
import { Node, Root } from "hast"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { trace } from "./trace"
import { type FilePath } from "./path"
const customComponents: Components = {
table: (props) => (
<div class="table-container">
<table {...props} />
</div>
),
}
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
try {
return toJsxRuntime(tree as Root, {
Fragment,
jsx: jsx as Jsx,
jsxs: jsxs as Jsx,
elementAttributeNameCase: "html",
components: customComponents,
})
} catch (e) {
trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
}
}

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