Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#237] Removed reliance on custom Shiki code for rehype code block styling #269

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 28 additions & 233 deletions app/lib/md.server.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
/*!
* Forked from https://github.com/ryanflorence/md/blob/master/index.ts
*
* Adapted from
* - ggoodman/nostalgie
* - MIT https://github.com/ggoodman/nostalgie/blob/45f3f6356684287a214dab667064ec9776def933/LICENSE
* - https://github.com/ggoodman/nostalgie/blob/45f3f6356684287a214dab667064ec9776def933/src/worker/mdxCompiler.ts
* - remix/remix-website
*/
import { getHighlighter, toShikiTheme } from "shiki";
import rangeParser from "parse-numeric-range";
import parseFrontMatter from "front-matter";
import type * as Hast from "hast";
import themeJson from "../../data/base16.json";
import { getHighlighter } from "shiki";
import { transformerCopyButton } from '@rehype-pretty/transformers';
import type * as Unist from "unist";
import type * as Shiki from "shiki";
import type * as Unified from "unified";
import themeJson from "../../data/base16.json";

export interface ProcessorOptions {
resolveHref?(href: string): string;
Expand All @@ -40,6 +37,7 @@ export async function getProcessor(options?: ProcessorOptions) {
{ default: rehypeSlug },
{ default: rehypeStringify },
{ default: rehypeAutolinkHeadings },
{ default: rehypePrettyCode },
plugins,
] = await Promise.all([
import("unified"),
Expand All @@ -49,20 +47,40 @@ export async function getProcessor(options?: ProcessorOptions) {
import("rehype-slug"),
import("rehype-stringify"),
import("rehype-autolink-headings"),
import("rehype-pretty-code"),
loadPlugins(),
]);

// The theme actually stores #FFFF${base-16-color-id} because vscode-textmate
// requires colors to be valid hex codes, if they aren't, it changes them to a
// default, so this is a mega hack to trick it.
const themeString = JSON.stringify(themeJson).replace(/#FFFF(.{2})/g, "var(--base$1)");

return unified()
.use(remarkParse)
.use(plugins.stripLinkExtPlugin, options)
.use(plugins.remarkCodeBlocksShiki, options)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })
.use(rehypeSlug)
.use(rehypeAutolinkHeadings);
.use(rehypeAutolinkHeadings)
.use(rehypePrettyCode, {
keepBackground: false,
theme: JSON.parse(themeString),
filterMetaString: (str) => str.replace(/lines=\[([^]*)\]/g, '{$1}').replace(/filename=([^ ]*)/g, 'title="$1"'),
transformers: [
transformerCopyButton({
visibility: 'always',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this completely addresses my 2nd request

Suggested change
visibility: 'always',
visibility: 'hover',

feedbackDuration: 3_000,
copyIcon: "data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16' fill='none' stroke='rgb(9, 9, 11)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Crect width='13' height='13' x='9' y='9' rx='2' ry='2' vector-effect='non-scaling-stroke'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E",
successIcon: "data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16' fill='none' stroke='rgb(9, 9, 11)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Cpath d='M20 6 9 17l-5-5' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E",
}),
],
});
}



type InternalPlugin<
Input extends string | Unist.Node | undefined,
Output,
Expand Down Expand Up @@ -96,236 +114,13 @@ export async function loadPlugins() {
};
};

const remarkCodeBlocksShiki: InternalPlugin<
UnistNode.Root,
UnistNode.Root
> = (options) => {
let theme: ReturnType<typeof toShikiTheme>;
let highlighterPromise: ReturnType<typeof getHighlighter>;

return async function transformer(tree: UnistNode.Root) {
theme = theme || toShikiTheme(themeJson as any);
highlighterPromise =
highlighterPromise || getHighlighter({ themes: [theme] });
let highlighter = await highlighterPromise;
let fgColor = convertFakeHexToCustomProp(
highlighter.getForegroundColor(theme.name) || "",
);
let langs: Shiki.Lang[] = [
"js",
"json",
"jsx",
"ts",
"tsx",
"markdown",
"shellscript",
"html",
"css",
"diff",
"mdx",
"prisma",
];
let langSet = new Set(langs);
let transformTasks: Array<() => Promise<void>> = [];

visit(tree, "code", (node) => {
if (
!node.lang ||
!node.value ||
!langSet.has(node.lang as Shiki.Lang)
) {
return;
}

if (node.lang === "js") node.lang = "javascript";
if (node.lang === "ts") node.lang = "typescript";
let language = node.lang;
let code = node.value;
let {
addedLines,
highlightLines,
nodeProperties,
removedLines,
startingLineNumber,
usesLineNumbers,
} = getCodeBlockMeta();

transformTasks.push(highlightNodes);
return SKIP;

async function highlightNodes() {
let tokens = getThemedTokens({ code, language });
let children = tokens.map(
(lineTokens, zeroBasedLineNumber): Hast.Element => {
let children = lineTokens.map(
(token): Hast.Text | Hast.Element => {
let color = convertFakeHexToCustomProp(token.color || "");
let content: Hast.Text = {
type: "text",
// Do not escape the _actual_ content
value: token.content,
};

return color && color !== fgColor
? {
type: "element",
tagName: "span",
properties: {
style: `color: ${htmlEscape(color)}`,
},
children: [content],
}
: content;
},
);

children.push({
type: "text",
value: "\n",
});

let isDiff = addedLines.length > 0 || removedLines.length > 0;
let diffLineNumber = startingLineNumber - 1;
let lineNumber = zeroBasedLineNumber + startingLineNumber;
let highlightLine = highlightLines?.includes(lineNumber);
let removeLine = removedLines.includes(lineNumber);
let addLine = addedLines.includes(lineNumber);
if (!removeLine) {
diffLineNumber++;
}

return {
type: "element",
tagName: "span",
properties: {
className: "codeblock-line",
dataHighlight: highlightLine ? "true" : undefined,
dataLineNumber: usesLineNumbers ? lineNumber : undefined,
dataAdd: isDiff ? addLine : undefined,
dataRemove: isDiff ? removeLine : undefined,
dataDiffLineNumber: isDiff ? diffLineNumber : undefined,
},
children,
};
},
);

let nodeValue = {
type: "element",
tagName: "pre",
properties: {
...nodeProperties,
dataLineNumbers: usesLineNumbers ? "true" : "false",
dataLang: htmlEscape(language),
style: `color: ${htmlEscape(fgColor)};`,
},
children: [
{
type: "element",
tagName: "code",
children,
},
],
};

let data = node.data ?? {};
(node as any).type = "element";
(node as any).tagName = "div";
let properties =
data.hProperties && typeof data.hProperties === "object"
? data.hProperties
: {};
data.hProperties = {
...properties,
dataCodeBlock: "",
...nodeProperties,
dataLineNumbers: usesLineNumbers ? "true" : "false",
dataLang: htmlEscape(language),
};
data.hChildren = [nodeValue];
node.data = data;
}

function getCodeBlockMeta() {
// TODO: figure out how this is ever an array?
let meta = Array.isArray(node.meta) ? node.meta[0] : node.meta;

let metaParams = new URLSearchParams();
if (meta) {
let linesHighlightsMetaShorthand = meta.match(/^\[(.+)\]$/);
if (linesHighlightsMetaShorthand) {
metaParams.set("lines", linesHighlightsMetaShorthand[0]);
} else {
metaParams = new URLSearchParams(meta.split(/\s+/).join("&"));
}
}

let addedLines = parseLineHighlights(metaParams.get("add"));
let removedLines = parseLineHighlights(metaParams.get("remove"));
let highlightLines = parseLineHighlights(metaParams.get("lines"));
let startValNum = metaParams.has("start")
? Number(metaParams.get("start"))
: 1;
let startingLineNumber = Number.isFinite(startValNum)
? startValNum
: 1;
let usesLineNumbers = !metaParams.has("nonumber");

let nodeProperties: { [key: string]: string } = {};
metaParams.forEach((val, key) => {
if (key === "lines") return;
nodeProperties[`data-${key}`] = val;
});

return {
addedLines,
highlightLines,
nodeProperties,
removedLines,
startingLineNumber,
usesLineNumbers,
};
}
});

await Promise.all(transformTasks.map((exec) => exec()));

function getThemedTokens({
code,
language,
}: {
code: string;
language: Shiki.Lang;
}) {
return highlighter.codeToThemedTokens(code, language, theme.name, {
includeExplanation: false,
});
}
};
};

return {
stripLinkExtPlugin,
remarkCodeBlocksShiki,
};
}

////////////////////////////////////////////////////////////////////////////////

function parseLineHighlights(param: string | null) {
if (!param) return [];
let range = param.match(/^\[(.+)\]$/);
if (!range) return [];
return rangeParser(range[1]);
}

// The theme actually stores #FFFF${base-16-color-id} because vscode-textmate
// requires colors to be valid hex codes, if they aren't, it changes them to a
// default, so this is a mega hack to trick it.
function convertFakeHexToCustomProp(color: string) {
return color.replace(/^#FFFF(.+)/, "var(--base$1)");
}

function isRelativeUrl(test: string) {
// Probably fragile but should work well enough.
// It would be nice if the consumer could provide a baseURI we could do
Expand Down Expand Up @@ -431,4 +226,4 @@ export namespace UnistNode {
type: "text";
value: string;
}
}
}
63 changes: 0 additions & 63 deletions app/routes/docs.$lang.$ref.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type { LoaderFunctionArgs, HeadersFunction } from "@remix-run/node";
import cx from "clsx";
import { DocSearch } from "~/ui/docsearch";
import { useNavigate } from "react-router-dom";

import "~/styles/docs.css";
import { Wordmark } from "~/ui/logo";
import { DetailsMenu, DetailsPopup } from "~/ui/details-menu";
Expand Down Expand Up @@ -102,7 +101,6 @@ export default function DocsLayout() {
}, [location]);

let docsContainer = React.useRef<HTMLDivElement>(null);
useCodeBlockCopyButton(docsContainer);

return (
<div className="[--header-height:theme(spacing.16)] [--nav-width:theme(spacing.72)]">
Expand Down Expand Up @@ -884,64 +882,3 @@ function useIsActivePath(to: string) {
let match = matchPath(pathname + "/*", location.pathname);
return Boolean(match);
}

function useCodeBlockCopyButton(ref: React.RefObject<HTMLDivElement>) {
let location = useLocation();
React.useEffect(() => {
let container = ref.current;
if (!container) return;

let codeBlocks = container.querySelectorAll(
"[data-code-block][data-lang]:not([data-nocopy])",
);
let buttons = new Map<
HTMLButtonElement,
{ listener: (event: MouseEvent) => void; to: number }
>();

for (let codeBlock of codeBlocks) {
let button = document.createElement("button");
let label = document.createElement("span");
button.type = "button";
button.dataset.codeBlockCopy = "";
button.addEventListener("click", listener);

label.textContent = "Copy code to clipboard";
label.classList.add("sr-only");
button.appendChild(label);
codeBlock.appendChild(button);
buttons.set(button, { listener, to: -1 });

function listener(event: MouseEvent) {
event.preventDefault();
let pre = codeBlock.querySelector("pre");
let text = pre?.textContent;
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => {
button.dataset.copied = "true";
let to = window.setTimeout(() => {
window.clearTimeout(to);
if (button) {
button.dataset.copied = undefined;
}
}, 3000);
if (buttons.has(button)) {
buttons.get(button)!.to = to;
}
})
.catch((error) => {
console.error(error);
});
}
}
return () => {
for (let [button, props] of buttons) {
button.removeEventListener("click", props.listener);
button.parentElement?.removeChild(button);
window.clearTimeout(props.to);
}
};
}, [ref, location.pathname]);
}
Loading