diff --git a/gatsby/lib/blank-span.mjs b/gatsby/lib/blank-span.mjs new file mode 100644 index 0000000..ceb5a0c --- /dev/null +++ b/gatsby/lib/blank-span.mjs @@ -0,0 +1,73 @@ +import { toString } from 'hast-util-to-string'; +import { visit } from 'unist-util-visit'; + +// Special characters for marking blanks and separators +const BLANK_MARKER = '\u2420'; +const SEPARATOR = '\u2063'; + +export const preserveCustomSpans = () => (tree) => { + visit(tree, 'element', (node, index, parent) => { + if (isBlankSpan(node)) { + const reservedString = toString(node); + parent.properties['data-blanks'] = parent.properties['data-blanks'] + ? `${parent.properties['data-blanks']}${SEPARATOR}${reservedString}` + : reservedString; + + parent.children.splice(index, 1, { type: 'text', value: BLANK_MARKER }); + } + }); +}; + +export const restoreCustomSpans = () => (tree) => { + visit(tree, 'element', (node) => { + if (node.tagName === 'codesplit') { + let containsBlank = false; + + visit(node, 'element', (nodeInCodesplit) => { + if ( + nodeInCodesplit.tagName === 'code' && + nodeInCodesplit.properties['data-blanks'] + ) { + const blanks = + nodeInCodesplit.properties['data-blanks'].split(SEPARATOR); + restoreBlankSpans(nodeInCodesplit, blanks); + delete nodeInCodesplit.properties['data-blanks']; + containsBlank = true; + } + }); + + if (containsBlank) { + node.properties['data-contains-blank'] = true; + } + } + }); +}; + +const isBlankSpan = (node) => + node.tagName === 'span' && + Array.isArray(node.properties.className) && + node.properties.className.includes('blank'); + +const restoreBlankSpans = (node, blanks) => { + let blankIndex = 0; + + visit(node, 'text', (textNode, index, parent) => { + if (textNode.value.includes(BLANK_MARKER)) { + const parts = textNode.value.split(BLANK_MARKER); + const newNodes = parts.flatMap((part, i) => { + const nodes = part ? [{ type: 'text', value: part }] : []; + if (i < parts.length - 1 && blankIndex < blanks.length) { + nodes.push({ + type: 'element', + tagName: 'span', + properties: { className: ['blank'] }, + children: [{ type: 'text', value: blanks[blankIndex++] }], + }); + } + return nodes; + }); + parent.children.splice(index, 1, ...newNodes); + return index + newNodes.length; + } + }); +}; diff --git a/gatsby/lib/codesplit.mjs b/gatsby/lib/codesplit.mjs index d667ca0..1b71683 100644 --- a/gatsby/lib/codesplit.mjs +++ b/gatsby/lib/codesplit.mjs @@ -1,6 +1,7 @@ import { h } from 'hastscript'; import { visit } from 'unist-util-visit'; import { toHtml } from 'hast-util-to-html'; +import { toString } from 'hast-util-to-string'; import { fromHtml } from 'hast-util-from-html'; /** @@ -22,6 +23,7 @@ export const rehypeCodesplit = () => (tree) => { node.properties.className && node.properties.className.includes('codesplit') ) { + const raw = toString(node); const lines = toHtml(node.children).split('\n'); const lang = node.properties.dataCodeLanguage; @@ -114,11 +116,16 @@ export const rehypeCodesplit = () => (tree) => { return h('div', { className }, [ h('pre', [h('code', { class: ['code', `language-${lang}`] }, code)]), - h('div', { class: ['comment'] }, h('p', fromHtml(comments.join('\n'), { fragment: true }))), + h( + 'div', + { class: ['comment'] }, + h('p', fromHtml(comments.join('\n'), { fragment: true })), + ), ]); }); - node.tagName = 'div'; + node.tagName = 'codesplit'; + node.properties['data-raw'] = raw; node.properties.className = ['codesplit', 'callout', 'not-prose']; node.children = children; } diff --git a/gatsby/lib/parse-content.mjs b/gatsby/lib/parse-content.mjs index 94e52a7..278e038 100644 --- a/gatsby/lib/parse-content.mjs +++ b/gatsby/lib/parse-content.mjs @@ -10,6 +10,7 @@ import rehypeHighlight from 'rehype-highlight'; import { toString } from 'hast-util-to-string'; import { rehypeCodesplit } from './codesplit.mjs'; +import { preserveCustomSpans, restoreCustomSpans } from './blank-span.mjs'; function isHeading(node) { return node.type === 'element' && /^h[1-6]$/i.test(node.tagName); @@ -178,7 +179,9 @@ export function parseContent(html) { .use(replaceMedia) .use(externalLinkInNewTab) .use(rehypeCodesplit) + .use(preserveCustomSpans) .use(rehypeHighlight) + .use(restoreCustomSpans) .use(rehypeSlug) .use(rehypeKatex) .runSync(ast); diff --git a/src/components/Codesplit.js b/src/components/Codesplit.js new file mode 100644 index 0000000..720d30f --- /dev/null +++ b/src/components/Codesplit.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi'; +import { RiFileCopyLine } from 'react-icons/ri'; + +const LANGUAGE_NAME_MAP = { + javascript: 'JavaScript', + html: 'HTML', +}; + +const Codesplit = (props) => { + const [isAnswerVisible, setIsAnswerVisible] = React.useState(false); + const [isCopied, setIsCopied] = React.useState(false); + + const toggleAnswerHiddenStatus = () => { + setIsAnswerVisible((lastState) => !lastState); + }; + + const copyRaw = () => { + if (isCopied) return; + if (!navigator.clipboard || !navigator.clipboard.writeText) return; + + navigator.clipboard.writeText(props['data-raw']); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + const displayedLanguageName = + LANGUAGE_NAME_MAP[props['data-code-language']] || + props['data-code-language']; + + const containsBlank = !!props['data-contains-blank']; + + return ( +