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 ( +
+
+
+ {displayedLanguageName} +
+ +
+ {containsBlank && ( + + )} + + +
+
+ + {props.children} +
+ ); +}; + +export default Codesplit; diff --git a/src/layouts/ChapterLayout.js b/src/layouts/ChapterLayout.js index f3265d2..313216b 100644 --- a/src/layouts/ChapterLayout.js +++ b/src/layouts/ChapterLayout.js @@ -9,6 +9,7 @@ import PrevNextButtons from '../components/PrevNextButtons'; import Image from '../components/Image'; import Example from '../components/Example'; import VideoLink from '../components/VideoLink'; +import Codesplit from '../components/Codesplit'; const renderAst = ({ ast, images }) => { visit(ast, { tagName: 'img' }, (node) => { @@ -31,6 +32,7 @@ const renderAst = ({ ast, images }) => { 'gatsby-image': Image, 'embed-example': Example, 'video-link': VideoLink, + 'codesplit': Codesplit, }, }); diff --git a/src/styles/codesplit.css b/src/styles/codesplit.css index 266b39a..3ea99f9 100644 --- a/src/styles/codesplit.css +++ b/src/styles/codesplit.css @@ -9,7 +9,7 @@ } .snip-above > .codesplit::before { - @apply content-icon-scissors absolute -left-3.5 -top-3 h-5 w-5; + @apply absolute -left-3.5 -top-3 h-5 w-5 content-icon-scissors; } .snip-below > .codesplit { @@ -17,7 +17,7 @@ } .snip-below > .codesplit::after { - @apply content-icon-scissors absolute -bottom-2.5 -left-3.5 h-5 w-5; + @apply absolute -bottom-2.5 -left-3.5 h-5 w-5 content-icon-scissors; } .pair { @@ -25,13 +25,21 @@ } .pair.split { - @apply border-noc-200 my-1 flex flex-col-reverse flex-wrap justify-between gap-x-8 gap-y-2 border-l-2 bg-gray-200 py-2 lg:flex-row lg:flex-wrap-reverse; + @apply my-1 flex flex-col-reverse flex-wrap justify-between gap-x-8 gap-y-2 border-l-2 border-noc-200 bg-gray-200 py-2 lg:flex-row lg:flex-wrap-reverse; } .pair code.hljs { @apply bg-transparent p-0 text-sm; } +.pair span.blank { + @apply border-b border-gray-400 text-sm text-transparent; +} + +.is-answer-visible .pair span.blank { + @apply text-[#24292e]; +} + .comment { @apply flex flex-grow text-sm text-gray-600 lg:justify-end; }