Merge branch 'v4' into bak

This commit is contained in:
DongDong Chen 2023-12-17 01:23:51 +08:00 committed by GitHub
commit 0c92dd7b74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 390 additions and 105 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

@ -4,7 +4,10 @@ title: Hosting
Quartz effectively turns your Markdown files and other resources into a bundle of HTML, JS, and CSS files (a website!). Quartz effectively turns your Markdown files and other resources into a bundle of HTML, JS, and CSS files (a website!).
However, if you'd like to publish your site to the world, you need a way to host it online. This guide will detail how to deploy with either GitHub Pages or Cloudflare pages but any service that allows you to deploy static HTML should work as well (e.g. Netlify, Replit, etc.) However, if you'd like to publish your site to the world, you need a way to host it online. This guide will detail how to deploy with common hosting providers but any service that allows you to deploy static HTML should work as well.
> [!warning]
> The rest of this guide assumes that you've already created your own GitHub repository for Quartz. If you haven't already, [[setting up your GitHub repository|make sure you do so]].
> [!hint] > [!hint]
> Some Quartz features (like [[RSS Feed]] and sitemap generation) require `baseUrl` to be configured properly in your [[configuration]] to work properly. Make sure you set this before deploying! > Some Quartz features (like [[RSS Feed]] and sitemap generation) require `baseUrl` to be configured properly in your [[configuration]] to work properly. Make sure you set this before deploying!
@ -26,12 +29,10 @@ Press "Save and deploy" and Cloudflare should have a deployed version of your si
To add a custom domain, check out [Cloudflare's documentation](https://developers.cloudflare.com/pages/platform/custom-domains/). To add a custom domain, check out [Cloudflare's documentation](https://developers.cloudflare.com/pages/platform/custom-domains/).
## GitHub Pages
Like Quartz 3, you can deploy the site generated by Quartz 4 via GitHub Pages.
> [!warning] > [!warning]
> Quartz generates files in the format of `file.html` instead of `file/index.html` which means the trailing slashes for _non-folder paths_ are dropped. As GitHub pages does not do this redirect, this may cause existing links to your site that use trailing slashes to break. If not breaking existing links is important to you, consider using [[#Cloudflare Pages]]. > Cloudflare Pages only allows shallow `git` clones so if you rely on `git` for timestamps, it is recommended you either add dates to your frontmatter (see [[authoring content#Syntax]]) or use another hosting provider.
## GitHub Pages
In your local Quartz, create a new file `quartz/.github/workflows/deploy.yml`. In your local Quartz, create a new file `quartz/.github/workflows/deploy.yml`.
@ -93,6 +94,9 @@ Then:
> >
> You can do this by going to your Settings page on your GitHub fork and going to the Environments tab and pressing the trash icon. The GitHub action will recreate the environment for you correctly the next time you sync your Quartz. > You can do this by going to your Settings page on your GitHub fork and going to the Environments tab and pressing the trash icon. The GitHub action will recreate the environment for you correctly the next time you sync your Quartz.
> [!info]
> Quartz generates files in the format of `file.html` instead of `file/index.html` which means the trailing slashes for _non-folder paths_ are dropped. As GitHub pages does not do this redirect, this may cause existing links to your site that use trailing slashes to break. If not breaking existing links is important to you (e.g. you are migrating from Quartz 3), consider using [[#Cloudflare Pages]].
### Custom Domain ### Custom Domain
Here's how to add a custom domain to your GitHub pages deployment. Here's how to add a custom domain to your GitHub pages deployment.
@ -167,9 +171,16 @@ 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
## GitLab Pages ## Netlify
You can configure GitLab CI to build and deploy a Quartz 4 project. 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
In your local Quartz, create a new file `.gitlab-ci.yaml`. In your local Quartz, create a new file `.gitlab-ci.yaml`.
@ -192,8 +203,6 @@ build:
- hash -r - hash -r
- npm ci - npm ci
script: script:
- npx prettier --write .
- npm run check
- npx quartz build - npx quartz build
artifacts: artifacts:
paths: paths:
@ -216,6 +225,6 @@ pages:
- public - 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. 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`. 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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -2,7 +2,7 @@
title: Welcome to Quartz 4 title: Welcome to Quartz 4
--- ---
Quartz is a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites. Thousands of students, developers, and teachers are [[showcase|already using Quartz]] to publish personal notes, wikis, and [digital gardens](https://jzhao.xyz/posts/networked-thought) to the web. Quartz is a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites. Thousands of students, developers, and teachers are [[showcase|already using Quartz]] to publish personal notes, websites, and [digital gardens](https://jzhao.xyz/posts/networked-thought) to the web.
## 🪴 Get Started ## 🪴 Get Started
@ -19,7 +19,7 @@ npx quartz create
This will guide you through initializing your Quartz with content. Once you've done so, see how to: This will guide you through initializing your Quartz with content. Once you've done so, see how to:
1. [[authoring content|Author content]] in Quartz 1. [[authoring content|Writing content]] in Quartz
2. [[configuration|Configure]] Quartz's behaviour 2. [[configuration|Configure]] Quartz's behaviour
3. Change Quartz's [[layout]] 3. Change Quartz's [[layout]]
4. [[build|Build and preview]] Quartz 4. [[build|Build and preview]] Quartz

View File

@ -0,0 +1,35 @@
---
title: Setting up your GitHub repository
---
First, make sure you have Quartz [[index#🪴 Get Started|cloned and setup locally]].
Then, create a new repository on GitHub.com. Do **not** initialize the new repository with `README`, license, or `gitignore` files.
![[github-init-repo-options.png]]
At the top of your repository on GitHub.com's Quick Setup page, click the clipboard to copy the remote repository URL.
![[github-quick-setup.png]]
In your terminal of choice, navigate to the root of your Quartz folder. Then, run the following commands, replacing `REMOTE-URL` with the URL you just copied from the previous step.
```bash
# add your repository
git remote add origin REMOTE-URL
# track the main quartz repository for updates
git remote add upstream https://github.com/jackyzha0/quartz.git
```
To verify that you set the remote URL correctly, run the following command.
```bash
git remote -v
```
Then, you can sync the content to upload it to your repository.
```bash
npx quartz sync
```

View File

@ -6,9 +6,9 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/) - [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/) - [Jacky Zhao's Garden](https://jzhao.xyz/)
- [Socratica Toolbox](https://toolbox.socratica.info/)
- [Brandon Boswell's Garden](https://brandonkboswell.com) - [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/) - [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [AWAGMI Intern Notes](https://notes.awagmi.xyz/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/) - [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/) - [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [oldwinter の数字花园](https://garden.oldwinter.top/) - [oldwinter の数字花园](https://garden.oldwinter.top/)
@ -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)!

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.1.0", "version": "4.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.1.0", "version": "4.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.6.3", "@clack/prompts": "^0.6.3",

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

@ -196,6 +196,11 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
) )
await fs.promises.writeFile(configFilePath, configContent) await fs.promises.writeFile(configFilePath, configContent)
// setup remote
execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
)
outro(`You're all set! Not sure what to do next? Try: outro(`You're all set! Not sure what to do next? Try:
Customizing Quartz a bit more by editing \`quartz.config.ts\` Customizing Quartz a bit more by editing \`quartz.config.ts\`
Running \`npx quartz build --serve\` to preview your Quartz locally Running \`npx quartz build --serve\` to preview your Quartz locally
@ -438,11 +443,23 @@ export async function handleUpdate(argv) {
console.log( console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
) )
try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
await popContentFolder(contentFolder)
return
}
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date") console.log("Ensuring dependencies are up to date")
spawnSync("npm", ["i"], { stdio: "inherit" }) const res = spawnSync("npm", ["i"], { stdio: "inherit" })
if (res.status === 0) {
console.log(chalk.green("Done!")) console.log(chalk.green("Done!"))
} else {
console.log(chalk.red("An error occurred above while installing dependencies."))
}
} }
/** /**
@ -483,8 +500,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
@ -498,13 +516,25 @@ export async function handleSync(argv) {
console.log( console.log(
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.", "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
) )
try {
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
await popContentFolder(contentFolder)
return
}
} }
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
if (argv.push) { if (argv.push) {
console.log("Pushing your changes") console.log("Pushing your changes")
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" }) const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], {
stdio: "inherit",
})
if (res.status !== 0) {
console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`))
return
}
} }
console.log(chalk.green("Done!")) console.log(chalk.green("Done!"))

View File

@ -36,7 +36,9 @@ export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) { if (out.stderr) {
throw new Error(`Error while pulling updates: ${out.stderr}`) throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`))
} else if (out.status !== 0) {
throw new Error(chalk.red("Error while pulling updates"))
} }
} }

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

@ -27,7 +27,7 @@ function TagContent(props: QuartzComponentProps) {
? fileData.description ? fileData.description
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
if (tag === "") { if (tag === "/") {
const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))] const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
const tagItemMap: Map<string, QuartzPluginData[]> = new Map() const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
for (const tag of tags) { for (const tag of tags) {

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(
@ -18,6 +7,10 @@ async function mouseEnterHandler(
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
) { ) {
const link = this const link = this
if (link.dataset.noPopover === "true") {
return
}
async function setPosition(popoverElement: HTMLElement) { async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, { const { x, y } = await computePosition(link, popoverElement, {
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()], middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
@ -43,8 +36,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

@ -45,7 +45,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries( const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => { [...tags].map((tag) => {
const title = tag === "" ? "Tag Index" : `Tag: #${tag}` const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
return [ return [
tag, tag,
defaultProcessedContent({ defaultProcessedContent({

View File

@ -4,15 +4,18 @@ import { QuartzTransformerPlugin } from "../types"
import yaml from "js-yaml" import yaml from "js-yaml"
import toml from "toml" import toml from "toml"
import { slugTag } from "../../util/path" import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile"
export interface Options { export interface Options {
delims: string | string[] delims: string | string[]
language: "yaml" | "toml" language: "yaml" | "toml"
oneLineTagDelim: string
} }
const defaultOptions: Options = { const defaultOptions: Options = {
delims: "---", delims: "---",
language: "yaml", language: "yaml",
oneLineTagDelim: ",",
} }
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -20,6 +23,8 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
return { return {
name: "FrontMatter", name: "FrontMatter",
markdownPlugins() { markdownPlugins() {
const { oneLineTagDelim } = opts
return [ return [
[remarkFrontmatter, ["yaml", "toml"]], [remarkFrontmatter, ["yaml", "toml"]],
() => { () => {
@ -40,12 +45,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
// coerce title to string // coerce title to string
if (data.title) { if (data.title) {
data.title = data.title.toString() data.title = data.title.toString()
} else if (data.title === null || data.title === undefined) {
data.title = file.stem ?? "Untitled"
} }
if (data.tags && !Array.isArray(data.tags)) { if (data.tags && !Array.isArray(data.tags)) {
data.tags = data.tags data.tags = data.tags
.toString() .toString()
.split(",") .split(oneLineTagDelim)
.map((tag: string) => tag.trim()) .map((tag: string) => tag.trim())
} }
@ -53,11 +60,7 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? [] data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
// fill in frontmatter // fill in frontmatter
file.data.frontmatter = { file.data.frontmatter = data as QuartzPluginData["frontmatter"]
title: file.stem ?? "Untitled",
tags: [],
...data,
}
} }
}, },
] ]

View File

@ -31,6 +31,11 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
rehypeAutolinkHeadings, rehypeAutolinkHeadings,
{ {
behavior: "append", behavior: "append",
properties: {
ariaHidden: true,
tabIndex: -1,
"data-no-popover": true,
},
content: { content: {
type: "text", type: "text",
value: " §", value: " §",

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,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

@ -64,11 +64,17 @@ a {
color: var(--tertiary) !important; color: var(--tertiary) !important;
} }
&.internal:not(:has(img)) { &.internal {
text-decoration: none; text-decoration: none;
background-color: var(--highlight); background-color: var(--highlight);
padding: 0 0.1rem; padding: 0 0.1rem;
border-radius: 5px; border-radius: 5px;
&:has(> img) {
background-color: none;
border-radius: 0;
padding: 0;
}
} }
} }
@ -94,8 +100,6 @@ a {
} }
& article { & article {
position: relative;
& > h1 { & > h1 {
font-size: 2rem; font-size: 2rem;
} }

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