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

Add code block hyperlinks to each test case documentation #97

Merged
merged 2 commits into from
Jul 3, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 38 additions & 17 deletions packages/web/src/components/CodeListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeBlockProps, "language" | "children">
Expand All @@ -12,34 +13,54 @@ export interface CodeListingProps
sourceFile: SourceFile,
project: Project
) => Pick<ts.Node, "getFullText">;
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 <CodeBlock
title={sourcePath}
language="typescript"
showLineNumbers
>{
sourceFile.getFullText()
}</CodeBlock>;
}
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 <CodeBlock
language="typescript"
{...props}
>{
node.getFullText().trim()
}</CodeBlock>;
if (Object.keys(links).length > 0) {
return (
<LinkedCodeBlock
code={code}
links={links}
language="typescript"
{...codeBlockProps}
/>
);
}

return (
<CodeBlock
language="typescript"
{...{
...(
listingFullSource
? { title: sourcePath, showLineNumbers: true }
: { showLineNumbers: false }
),
...codeBlockProps
}}
>{
node.getFullText().trim()
}</CodeBlock>
);
}
183 changes: 183 additions & 0 deletions packages/web/src/components/LinkedCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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<CodeBlockProps, "children"> {
code: string;
links: Links;
}

export default function LinkedCodeBlock({
code,
links,
...codeBlockProps
}: Props): JSX.Element {
const codeRef = useRef<HTMLDivElement>(null);
const [processedCode, setProcessedCode] = useState<JSX.Element | null>(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(
<pre className={codeElement.parentElement?.className}>
<code className={codeElement.className}>{processedNodes}</code>
</pre>
);
}
}
}

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 (
<div ref={codeRef}>
<CodeBlock {...codeBlockProps}>{code}</CodeBlock>
</div>
);
}

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(
<Link
key={`${linkText}-${index}`}
to={url}
className="linked-code-block-link"
>
{linkText}
</Link>
);
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;
}
10 changes: 10 additions & 0 deletions packages/web/src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading