From 3883cefcdd4dc96cc8c98389e11898375df34791 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 3 Jul 2024 19:05:43 -0400 Subject: [PATCH 1/2] Define LinkedCodeBlock --- .../web/src/components/LinkedCodeBlock.tsx | 183 ++++++++++++++++++ packages/web/src/css/custom.css | 10 + 2 files changed, 193 insertions(+) create mode 100644 packages/web/src/components/LinkedCodeBlock.tsx diff --git a/packages/web/src/components/LinkedCodeBlock.tsx b/packages/web/src/components/LinkedCodeBlock.tsx new file mode 100644 index 00000000..203e1535 --- /dev/null +++ b/packages/web/src/components/LinkedCodeBlock.tsx @@ -0,0 +1,183 @@ +/** + * POSSIBLE HACK WARNING + * + * This component (LinkedCodeBlock) manipulates the DOM directly to create + * linked code blocks. While effective, this approach bypasses React's virtual + * DOM, which could potentially lead to unexpected behavior or performance + * issues. + * + * The component works by: + * 1. Rendering a standard CodeBlock component + * 2. Using a ref to access the rendered DOM + * 3. Processing the DOM to replace specified text with Link components + * 4. Re-rendering the processed code + * + * This approach is used because the CodeBlock component doesn't provide a way + * to inject custom elements (like links) into its rendered output. + * + * Potential issues: + * - May break if the internal structure of CodeBlock changes + * - Could cause performance issues with large code blocks or many links + * - Might interfere with other components or scripts that manipulate the + * same DOM elements + * + * Use with caution and consider alternatives if available. + */ +import React, { useRef, useEffect, useState } from "react"; +import CodeBlock, { type Props as CodeBlockProps } from "@theme/CodeBlock"; +import Link from "@docusaurus/Link"; + +export interface Links { + [key: string]: string; +} + +export interface Props extends Omit { + code: string; + links: Links; +} + +export default function LinkedCodeBlock({ + code, + links, + ...codeBlockProps +}: Props): JSX.Element { + const codeRef = useRef(null); + const [processedCode, setProcessedCode] = useState(null); + + useEffect(() => { + function processCodeElement() { + if (codeRef.current) { + const codeElement = codeRef.current.querySelector("pre > code"); + if (codeElement) { + const processedNodes = Array.from(codeElement.childNodes).flatMap( + (node) => processNode(node, links) + ); + setProcessedCode( +
+              {processedNodes}
+            
+ ); + } + } + } + + processCodeElement(); + + // Re-process if the ref's content changes + const observer = new MutationObserver(processCodeElement); + if (codeRef.current) { + observer.observe(codeRef.current, { childList: true, subtree: true }); + } + + return () => observer.disconnect(); + }, [code, links]); + + if (!processedCode) { + return ( +
+ {code} +
+ ); + } + + return processedCode; +} + +type ProcessedNode = + | JSX.Element + | (JSX.Element | string)[]; + +// Recursively process a DOM node and its children +function processNode(node: Node, links: Links): ProcessedNode { + if (node.nodeType === Node.TEXT_NODE) { + return processTextNode(node as Text, links); + } + + if (node.nodeType === Node.ELEMENT_NODE) { + return processElementNode(node as HTMLElement, links); + } + + return []; +} + +// Process a text node to replace linkable text with Link components +function processTextNode(node: Text, links: Links): ProcessedNode { + const result: (JSX.Element | string)[] = []; + let text = node.textContent || ""; + let lastIndex = 0; + + for (const [linkText, url] of Object.entries(links)) { + let index = text.indexOf(linkText, lastIndex); + while (index !== -1) { + if (index > lastIndex) { + result.push(text.slice(lastIndex, index)); + } + result.push( + + {linkText} + + ); + lastIndex = index + linkText.length; + index = text.indexOf(linkText, lastIndex); + } + } + + if (lastIndex < text.length) { + result.push(text.slice(lastIndex)); + } + + return result; +} + +// counter for `key` prop to ensure uniqueness +let nodeKey: number = 0; +// elements that do not allow closing tags +const voidElements = new Set([ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", + "param", "source", "track", "wbr" +]); + +function processElementNode(node: HTMLElement, links: Links): ProcessedNode { + const tagName = node.tagName.toLowerCase(); + + const props: any = { + // increment the node key so everything is unique + key: nodeKey++, + className: node.className, + }; + + if (node.style && node.style.cssText) { + props.style = convertStyleToObject(node.style.cssText); + } + + if (voidElements.has(tagName)) { + return React.createElement(tagName, props); + } + + const children = Array.from(node.childNodes) + .flatMap((child) => processNode(child, links)); + + return React.createElement(tagName, props, children); +} + +function convertStyleToObject(style: string): React.CSSProperties { + const styleObject: React.CSSProperties = {}; + for (const declaration of style.split(";")) { + if (declaration) { + const [property, value] = declaration.split(":"); + if (property && value) { + const camelCaseProperty = property.trim().replace( + /-./g, + (x) => x[1].toUpperCase() + ); + // HACK coerce object instead of key to avoid a giant keyof union + (styleObject as any)[camelCaseProperty] = value.trim(); + } + } + } + return styleObject; +} diff --git a/packages/web/src/css/custom.css b/packages/web/src/css/custom.css index 5f008cc2..ed7727ac 100644 --- a/packages/web/src/css/custom.css +++ b/packages/web/src/css/custom.css @@ -49,3 +49,13 @@ [data-theme="dark"] .playground-container { background: rgb(25, 60, 71); } + +.linked-code-block-link { + color: inherit !important; + font-weight: inherit !important; + font-style: inherit !important; + font-family: inherit !important; + font-size: inherit !important; + text-decoration: underline !important; + background-color: inherit !important; +} From 17d03e7c7757af367b6ccaaa19729d2f0314d195 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 3 Jul 2024 19:07:30 -0400 Subject: [PATCH 2/2] Hook up code links in the test cases page --- .../pointers/testing/test-cases.mdx | 11 +++- packages/web/src/components/CodeListing.tsx | 55 +++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/web/docs/implementation-guides/pointers/testing/test-cases.mdx b/packages/web/docs/implementation-guides/pointers/testing/test-cases.mdx index a17df09a..8e1387fc 100644 --- a/packages/web/docs/implementation-guides/pointers/testing/test-cases.mdx +++ b/packages/web/docs/implementation-guides/pointers/testing/test-cases.mdx @@ -16,13 +16,18 @@ Test cases are aggregated into the `observeTraceTests` variable: packageName="@ethdebug/pointers" sourcePath="src/test-cases.ts" extract={sourceFile => sourceFile.getVariableStatement("observeTraceTests")} + links={{ + structStorageTest: "#struct-storage", + stringStorageTest: "#string-storage", + uint256ArrayMemoryTest: "#uint256-array-memory", + }} /> This variable will be used to generate automated tests dynamically, as will be described on the [next page](/docs/implementation-guides/pointers/testing/jest). -## Structs in storage +## Structs in storage {#struct-storage} Solidity tightly packs struct storage words starting from the right-hand side. This test ensures that relative offsets are computed properly for a struct that @@ -42,7 +47,7 @@ defines a few small fields (`struct Record { uint8 x; uint8 y; bytes4 salt; }`). pointerQuery="struct-storage-contract-variable-slot" /> -## Storage strings +## Storage strings {#string-storage} Representing a Solidity `string storage` using an **ethdebug/format/pointer** requires the use of conditional logic to identify the one or more regions that @@ -64,7 +69,7 @@ value. pointerQuery="string-storage-contract-variable-slot" /> -## Memory arrays of word-sized items +## Memory arrays of word-sized items {#uint256-array-memory} Memory arrays are primarily referenced using stack-located memory offset values, and so this test case ensures that stack slot indexes are properly adjusted over diff --git a/packages/web/src/components/CodeListing.tsx b/packages/web/src/components/CodeListing.tsx index a32c1c08..0e89374e 100644 --- a/packages/web/src/components/CodeListing.tsx +++ b/packages/web/src/components/CodeListing.tsx @@ -2,6 +2,7 @@ import CodeBlock, { type Props as CodeBlockProps } from "@theme/CodeBlock"; import { Project, type SourceFile, type ts } from "ts-morph"; import useProjectCode from "@site/src/hooks/useProjectCode"; +import LinkedCodeBlock from "./LinkedCodeBlock"; export interface CodeListingProps extends Omit @@ -12,34 +13,54 @@ export interface CodeListingProps sourceFile: SourceFile, project: Project ) => Pick; + links?: { [key: string]: string; }; } export default function CodeListing({ packageName, sourcePath, extract, - ...props + links = {}, + ...codeBlockProps }: CodeListingProps): JSX.Element { const project = useProjectCode(packageName); const sourceFile = project.getSourceFileOrThrow(sourcePath); - if (!extract) { - return { - sourceFile.getFullText() - }; - } + const node = !extract + ? sourceFile + : extract(sourceFile, project); + + const code = node.getFullText().trim(); + + // bit of a HACK + const listingFullSource = !extract; - const node = extract(sourceFile, project); - return { - node.getFullText().trim() - }; + if (Object.keys(links).length > 0) { + return ( + + ); + } + + return ( + { + node.getFullText().trim() + } + ); }