<script lang="ts">
import dompurify from "dompurify";
import matter from "@radicle/gray-matter";
import { afterUpdate } from "svelte";
import { toDom } from "hast-util-to-dom";
import * as router from "@app/lib/router";
import * as modal from "@app/lib/modal";
import ErrorModal from "@app/modals/ErrorModal.svelte";
import { activeUnloadedRouteStore } from "@app/lib/router";
import { highlight } from "@app/lib/syntax";
import { mimes } from "@app/lib/file";
import {
isUrl,
twemoji,
scrollIntoView,
canonicalize,
isCommit,
} from "@app/lib/utils";
import { Renderer, markdown } from "@app/lib/markdown";
export let content: string;
export let path: string = "/";
export let rawPath: string;
// If true, add <br> on a single line break
export let breaks: boolean = false;
let container: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let frontMatter: [string, any][] | undefined = undefined;
$: {
try {
const doc = matter(content);
content = doc.content;
frontMatter = Object.entries(doc.data).filter(
([, val]) => typeof val === "string" || typeof val === "number",
);
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Not able to parse frontmatter",
subtitle: [
"There was an error while trying to parse the frontmatter in this document.",
"Check your dev console logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
}
}
/**
* Do internal navigation for clicks on anchor elements if possible
*/
function navigateInternalOnAnchor(event: MouseEvent) {
if (router.useDefaultNavigation(event)) {
return;
}
let url: URL;
if (!(event.target instanceof HTMLAnchorElement)) {
return;
}
const href = event.target?.getAttribute("href");
if (href === null || href.startsWith("#")) {
return;
}
try {
url = new URL(href, window.location.href);
} catch {
return;
}
if (url.origin === window.origin) {
event.preventDefault();
void router.navigateToUrl("push", url);
}
}
function render(content: string): string {
return dompurify.sanitize(
markdown({
katex: true,
emojis: true,
footnotes: true,
linkify: true,
}).parse(content, {
renderer: new Renderer($activeUnloadedRouteStore),
breaks,
}) as string,
);
}
afterUpdate(async () => {
for (const e of container.querySelectorAll("a")) {
try {
const url = new URL(e.href);
if (url.origin !== window.origin) {
e.target = "_blank";
}
} catch (e) {
console.warn("Not able to parse url", e);
}
const anchorHref = e.getAttribute("href");
// If the anchor is an oid embed
if (anchorHref && isCommit(anchorHref)) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const url = new URL(rawPath);
// deprecated with httpd 0.18.1
// For older httpd versions we still pass the mime type.
// On newer radicle-httpd instances we try to infer the file type on httpd.
const fileExtension = e.innerText.split(".").pop();
if (fileExtension && fileExtension in mimes) {
url.search = `?mime=${mimes[fileExtension]}`;
}
url.pathname = canonicalize(`blobs/${anchorHref}`, url.pathname);
e.setAttribute("href", url.toString());
// To determine the filetype of the embed we query the content-type of the resource URL.
const req = await fetch(url, { method: "HEAD" });
const mimeType = req.headers.get("Content-Type");
// Embed an img element below the link
if (mimeType?.startsWith("image")) {
const element = document.createElement("img");
element.setAttribute("src", url.toString());
element.style.display = "block";
e.style.display = "block";
e.insertAdjacentElement("afterend", element);
// Embed an iframe to display pdf correctly element below the link
} else if (mimeType?.startsWith("application/pdf")) {
const element = document.createElement("embed");
element.setAttribute("src", url.toString());
element.type = mimeType;
element.style.overflow = "scroll";
element.style.height = "40rem";
element.style.overscrollBehavior = "contain";
e.style.display = "block";
e.insertAdjacentElement("afterend", element);
} else if (mimeType?.startsWith("video")) {
const element = document.createElement("video");
const node = document.createElement("source");
node.src = url.toString();
element.controls = true;
node.type = mimeType;
element.style.width = "100%";
e.style.display = "block";
element.appendChild(node);
e.insertAdjacentElement("afterend", element);
} else if (mimeType?.startsWith("audio")) {
const element = document.createElement("audio");
element.style.display = "block";
element.src = url.toString();
element.controls = true;
e.style.display = "block";
e.insertAdjacentElement("afterend", element);
} else {
console.warn(`Not able to provide a preview for this file.`);
}
continue;
}
// Don't underline <a> tags that contain images.
// Make an exception for emojis.
if (
e.firstElementChild instanceof HTMLImageElement &&
!e.firstElementChild.classList.contains("txt-emoji")
) {
e.classList.add("no-underline");
}
}
// Replace standard HTML checkboxes with our custom radicle-icon-small element
for (const i of container.querySelectorAll('input[type="checkbox"]')) {
i.parentElement?.classList.add("task-item");
const checkbox = document.createElement("radicle-icon-small");
const checked = i.getAttribute("checked");
checkbox.setAttribute(
"name",
checked === null ? "checkbox-unchecked" : "checkbox-checked",
);
i.insertAdjacentElement("beforebegin", checkbox);
i.remove();
}
// Iterate over all images, and replace the source with a canonicalized URL
// pointing at the repos /raw endpoint.
for (const i of container.querySelectorAll("img")) {
const imagePath = i.getAttribute("src");
const imageClass = i.getAttribute("class");
// Make sure the source isn't a URL before trying to fetch it from the repo
const emoji = imageClass && imageClass === "txt-emoji";
if (imagePath && !isUrl(imagePath) && !emoji) {
i.setAttribute("src", `${rawPath}/${canonicalize(imagePath, path)}`);
}
}
// Replaces code blocks in the background with highlighted code.
const prefix = "language-";
const nodes = Array.from(document.body.querySelectorAll("pre code"));
const treeChanges: Promise<void>[] = [];
for (const node of nodes) {
const preElement = node.parentElement as HTMLElement;
const copyButton = document.createElement("radicle-clipboard");
copyButton.setAttribute("text", node.textContent || "");
// Create a wrapper around the pre element,
// so we can position the copy button that works even when scrolling horizontally.
const preWrapper = document.createElement("div");
preWrapper.classList.add("pre-wrapper");
preElement.parentNode?.insertBefore(preWrapper, preElement);
preWrapper.appendChild(preElement);
preWrapper.appendChild(copyButton);
const className = Array.from(node.classList).find(name =>
name.startsWith(prefix),
);
if (!className) continue;
treeChanges.push(
highlight(node.textContent ?? "", className.slice(prefix.length))
.then(tree => {
if (tree) {
node.replaceChildren(toDom(tree, { fragment: true }));
}
})
.catch(e => console.warn("Not able to highlight code block", e)),
);
}
await Promise.allSettled(treeChanges);
if (window.location.hash) {
scrollIntoView(window.location.hash.substring(1));
}
});
</script>
<style>
:global(html) {
scroll-padding-top: 4rem;
}
.markdown {
word-break: break-word;
}
.front-matter {
font: var(--txt-body-s-regular);
border: 1px dashed var(--color-border-mid);
padding: 0.5rem;
margin-bottom: 2rem;
}
.front-matter table {
border-collapse: collapse;
}
.front-matter table td {
padding: 0.125rem 1rem;
}
.front-matter table td:first-child {
padding-left: 0.5rem;
}
.markdown :global(h1) {
font: var(--txt-heading-l);
padding: 1rem 0 0.5rem 0;
margin: 0 0 0.75rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.markdown :global(h2) {
font: var(--txt-heading-m);
padding: 0.25rem 0;
margin: 2rem 0 0.5rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.markdown :global(.pre-wrapper) {
position: relative;
margin: 1rem 0;
}
.markdown :global(radicle-clipboard) {
display: none;
position: absolute;
right: 0.5rem;
top: 0.5rem;
}
.markdown :global(radicle-clipboard) {
background-color: var(--color-surface-alpha-mid);
border-radius: var(--border-radius-sm);
}
.markdown :global(.pre-wrapper:hover > radicle-clipboard) {
display: flex;
}
.markdown :global(h3) {
font: var(--txt-heading-m);
padding: 0.5rem 0;
margin: 1rem 0 0.25rem;
}
.markdown :global(h4) {
font: var(--txt-body-l-semibold);
padding: 0.5rem 0;
margin: 1rem 0 0.125rem;
}
.markdown :global(h5),
.markdown :global(h6) {
font: var(--txt-body-m-semibold);
padding: 0.35rem 0;
margin: 1rem 0 0.125rem;
}
.markdown :global(h6) {
color: var(--color-text-tertiary);
}
.markdown :global(p) {
line-height: 1.625rem;
margin-top: 0;
margin-bottom: 0.625rem;
}
.markdown :global(p:only-child) {
margin-bottom: 0;
}
.markdown :global(li.task-item) {
list-style-type: none;
color: var(--color-text-tertiary);
}
.markdown :global(li.task-item radicle-icon-small) {
margin-right: 0.2rem;
vertical-align: middle;
}
.markdown :global(li.task-item:not(:last-child)) {
margin-bottom: 0.25rem;
}
.markdown :global(blockquote) {
color: var(--color-text-tertiary);
border-left: 0.3rem solid var(--color-surface-alpha-mid);
padding: 0 0 0 1rem;
margin: 1rem 0 1rem 0;
}
.markdown :global(strong) {
font-weight: 600;
}
.markdown :global(.footnote-ref) {
vertical-align: top;
position: relative;
top: -0.4rem;
}
.markdown :global(.footnote-ref),
.markdown :global(.footnote > .marker),
.markdown :global(.footnote > .ref-arrow) {
color: var(--color-text-tertiary);
}
.markdown :global(.footnote) {
margin-bottom: 0;
}
.markdown :global(img) {
border-style: none;
max-width: 100%;
}
.markdown :global(img.txt-emoji) {
height: 1rem;
}
.markdown :global(code) {
font: var(--txt-code-regular);
background-color: var(--color-surface-alpha-mid);
border-radius: var(--border-radius-sm);
padding: 0.125rem 0.25rem;
}
.markdown :global(pre > code) {
background: none;
padding: 0;
}
.markdown :global(:not(pre) > code) {
font-size: inherit;
}
.markdown :global(pre) {
font: var(--txt-code-regular);
background-color: var(--color-surface-alpha-mid);
padding: 1rem !important;
border-radius: var(--border-radius-sm);
overflow: scroll;
scrollbar-width: none;
}
.markdown :global(pre::-webkit-scrollbar) {
display: none;
}
.markdown :global(a),
.markdown :global(a > code) {
background: none;
padding: 0;
}
.markdown :global(a) {
text-decoration: underline;
text-decoration-color: var(--color-text-tertiary);
}
.markdown :global(a.no-underline) {
text-decoration: none;
}
.markdown :global(a:hover) {
text-decoration-color: var(--color-text-primary);
}
.markdown :global(hr) {
height: 0;
margin: 1rem 0;
overflow: hidden;
background: transparent;
border: 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.markdown :global(ol) {
line-height: 1.625;
list-style-type: decimal;
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown :global(ul) {
line-height: 1.625;
padding-left: 1.25rem;
margin-bottom: 1rem;
}
.markdown :global(.list-content) {
margin: 1rem 0;
}
/* Allows the parent to specify its own bottom margin */
.markdown :global(> :last-child) {
margin-bottom: 0;
}
.markdown :global(li > ul) {
margin-bottom: 0rem;
}
.markdown :global(li > ol) {
margin-bottom: 0rem;
}
.markdown :global(table) {
margin: 1.5rem 0;
border-collapse: collapse;
border-radius: var(--border-radius-sm);
border-style: hidden;
box-shadow: 0 0 0 1px var(--color-border-subtle);
overflow: hidden;
}
.markdown :global(td) {
text-align: left;
text-overflow: ellipsis;
border: 1px solid var(--color-border-subtle);
padding: 0.5rem 1rem;
}
.markdown :global(tr:nth-child(even)) {
background-color: var(--color-surface-base);
}
.markdown :global(th) {
text-align: center;
padding: 0.5rem 1rem;
}
.markdown :global(*:first-child:not(pre)) {
padding-top: 0 !important;
}
.markdown :global(*:first-child) {
margin-top: 0 !important;
}
.markdown :global(dl dt) {
margin-top: 1rem;
}
.markdown :global(dl dd) {
margin: 0 0 0 2rem;
}
</style>
{#if frontMatter && frontMatter.length > 0}
<div class="front-matter">
<table>
<tbody>
{#each frontMatter as [key, val]}
<tr>
<td><span class="txt-body-m-semibold">{key}</span></td>
<td>{val}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- The click handler only handles bubbling events from anchor tags -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="markdown"
bind:this={container}
use:twemoji={{ exclude: ["21a9"] }}
on:click={navigateInternalOnAnchor}>
{@html render(content)}
</div>