diff --git a/packages/web/package.json b/packages/web/package.json index 305697325..e40a244ec 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -30,6 +30,7 @@ "@mischnic/json-sourcemap": "^0.1.1", "@monaco-editor/react": "^4.6.0", "@saucelabs/theme-github-codeblock": "^0.2.3", + "@shikijs/themes": "^2.2.0", "ajv": "^8.12.0", "clsx": "^1.2.1", "docusaurus-json-schema-plugin": "^1.12.1", @@ -38,6 +39,7 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-markdown": "^9.0.1", + "shiki": "^2.2.0", "ts-morph": "^22.0.0", "yaml-template": "^1.0.0" }, diff --git a/packages/web/spec/program/example.mdx b/packages/web/spec/program/example.mdx new file mode 100644 index 000000000..cddcd8230 --- /dev/null +++ b/packages/web/spec/program/example.mdx @@ -0,0 +1,309 @@ +--- +sidebar_position: 3 +--- + +import { ProgramExampleContextProvider, useProgramExampleContext, SourceContents, Opcodes, Viewer } from "@theme/ProgramExample"; + +# Example program + + ({ + code: findSourceRange("3 finney"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + remark: "hexadecimal for 3 finney" + }) + }, + { + operation: { + mnemonic: "CALLVALUE" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("msg.callvalue"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "LT" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("msg.callvalue < 3 finney"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "PUSH1", + arguments: ["0x13"] + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("if (msg.callvalue < 3 finney) {\n return;\n }"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "JUMPI" + }, + + context: ({ findSourceRange }) => ({ + code: findSourceRange("if (msg.callvalue < 3 finney) {\n return;\n }"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + remark: "jump to end unless sufficient fee" + }) + }, + { + operation: { + mnemonic: "PUSH0" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("storedValue", { after: "localValue =" }), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + remark: "push stack slot of state variable" + }) + }, + { + operation: { + mnemonic: "SLOAD" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("storedValue", { after: "let localValue" }), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "PUSH1", + arguments: ["0x01"] + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("1"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "ADD" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("let localValue = storedValue + 1;"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }, { + identifier: "localValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "stack", + slot: 0 + }, + declaration: findSourceRange("let localValue") + }], + }) + }, + { + operation: { + mnemonic: "PUSH0" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("storedValue ="), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }, { + identifier: "localValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "stack", + slot: 1 + }, + declaration: findSourceRange("let localValue") + }], + }) + }, + { + operation: { + mnemonic: "SSTORE" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("storedValue = localValue;"), + variables: [{ + identifier: "storedValue", + type: { + kind: "uint", + bits: 256 + }, + pointer: { + location: "storage", + slot: 0 + }, + declaration: findSourceRange("[0] storedValue: uint256") + }], + }) + }, + { + operation: { + mnemonic: "JUMPDEST" + }, + context: ({ findSourceRange }) => ({ + code: findSourceRange("return;"), + remark: "skip to here if not enough paid" + }) + } + ]} +> + + +This page helps illustrate the program schema's +[key concepts](/spec/program/concepts) by offering a fictional +pseudo-code example and its hypothetical compiled program. + +Assume this fictional [somewhat] high-level language expects one contract per +source file, where each contract defines its storage layout, high-level logic, +and other metadata as top-level statements or blocks. + +The following source code might be used to define a contract that +increments a state variable if the caller pays at least 1 finney (0.001 ETH). + + + + diff --git a/packages/web/src/theme/ProgramExample/HighlightedInstruction.tsx b/packages/web/src/theme/ProgramExample/HighlightedInstruction.tsx new file mode 100644 index 000000000..467b876c2 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/HighlightedInstruction.tsx @@ -0,0 +1,19 @@ +import React, { useEffect, useState } from "react"; +import Admonition from "@theme/Admonition"; +import Link from "@docusaurus/Link"; +import { useProgramExampleContext } from "./ProgramExampleContext"; + +import { ShikiCodeBlock } from "@theme/ShikiCodeBlock"; + +export function HighlightedInstruction(): JSX.Element { + const { highlightedInstruction } = useProgramExampleContext(); + + return <> + + ; +} diff --git a/packages/web/src/theme/ProgramExample/Opcodes.css b/packages/web/src/theme/ProgramExample/Opcodes.css new file mode 100644 index 000000000..1ea4eb5b7 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/Opcodes.css @@ -0,0 +1,38 @@ + + +dl.opcodes { + display: grid; + grid-template-columns: max-content max-content; + margin: 0; + padding: 0; + align-items: justify; +} + +dl.opcodes dt { + grid-column-start: 1; + border-radius: var(--ifm-global-radius); + padding: 5px 10px; + margin: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + cursor: pointer; +} + +dl.opcodes dt, dl.opcodes dt + dd{ + margin-top: 5px; + border-bottom: 1px solid var(--ifm-color-primary-light); +} + +dl.opcodes dd { + grid-column-start: 2; + margin: 0px; + padding: 5px 5px; +} + +dl.opcodes dt.active { + background-color: var(--ifm-color-primary-lighter); +} + +dl.opcodes dt:not(.active):hover { + background-color: var(--ifm-hover-overlay); +} diff --git a/packages/web/src/theme/ProgramExample/Opcodes.tsx b/packages/web/src/theme/ProgramExample/Opcodes.tsx new file mode 100644 index 000000000..28cc554a1 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/Opcodes.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from "react"; +import { useProgramExampleContext } from "./ProgramExampleContext"; + +import { Data, Program } from "@ethdebug/format"; + +import "./Opcodes.css"; + +export function Opcodes(): JSX.Element { + const { + instructions, + highlightedInstruction, + highlightInstruction, + highlightMode, + showDetails, + hideDetails + } = useProgramExampleContext(); + + const [activeOffset, setActiveOffset] = useState(); + const [hoverOffset, setHoverOffset] = useState(); + + useEffect(() => { + if (activeOffset !== undefined) { + if (highlightedInstruction?.offset !== activeOffset) { + highlightInstruction(activeOffset); + } + + if (highlightMode === "simple") { + showDetails(); + } + + return; + } + + if (highlightMode === "detailed") { + hideDetails(); + } + + if (hoverOffset !== undefined) { + if (highlightedInstruction?.offset !== hoverOffset) { + highlightInstruction(hoverOffset); + } + + return; + } + + highlightInstruction(undefined); + + }, [activeOffset, hoverOffset, highlightedInstruction, highlightMode]); + + const handleClick = (offset: Data.Value) => offset === activeOffset + ? setActiveOffset(undefined) + : setActiveOffset(offset); + + const handleMouseEnter = (offset: Data.Value) => setHoverOffset(offset); + // skipping the current hover offset check here and assuming that the mouse + // must leave the boundary of one offset before entering another + const handleMouseLeave = (offset: Data.Value) => setHoverOffset(undefined); + + const paddingLength = instructions.at(-1)!.offset.toString(16).length; + + return
{ + instructions.map((instruction) => + handleClick(instruction.offset)} + onMouseEnter={() => handleMouseEnter(instruction.offset)} + onMouseLeave={() => handleMouseLeave(instruction.offset)} + /> + ) + }
+} + + +function Opcode(props: { + instruction: Program.Instruction; + active: boolean; + paddingLength: number; + onClick: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; +}): JSX.Element { + const { + instruction, + active, + paddingLength, + onClick, + onMouseEnter, + onMouseLeave + } = props; + + const { offset, operation, context } = instruction; + + const offsetLabel = <> + 0x{offset.toString(16).padStart(paddingLength, "0")} + ; + + const commentLabel = context && "remark" in context + ? <> ({context.remark}) + : <> + + const operationLabel = <> + {operation && { + [operation.mnemonic, ...operation.arguments || []].join(" ") + }} + {commentLabel} + ; + + return <> +
{ + offsetLabel + }
+
{ + operationLabel + }
+ ; +} + diff --git a/packages/web/src/theme/ProgramExample/ProgramExampleContext.tsx b/packages/web/src/theme/ProgramExample/ProgramExampleContext.tsx new file mode 100644 index 000000000..7674f31e2 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/ProgramExampleContext.tsx @@ -0,0 +1,101 @@ +import React, { createContext, useContext, useState, useEffect } from "react"; + +import { Data, Materials, Program } from "@ethdebug/format"; +import { computeOffsets } from "./offsets"; +import { type DynamicInstruction, resolveDynamicInstruction } from "./dynamic"; + + +export interface ProgramExampleState { + // props + sources: Materials.Source[]; + instructions: Program.Instruction[]; + + // stateful stuff + highlightedInstruction: Program.Instruction | undefined; + highlightInstruction(offset: Data.Value | undefined): void; + highlightMode: "simple" | "detailed"; + showDetails(): void; + hideDetails(): void; +} + +const ProgramExampleContext = + createContext(undefined); + +export function useProgramExampleContext() { + const context = useContext(ProgramExampleContext); + if (context === undefined) { + throw new Error("useProgramExampleContext must be used within a ProgramExampleContextProvider"); + } + + return context; +} + +export interface ProgramExampleProps { + sources: Materials.Source[]; + instructions: Omit[]; +} + +export function ProgramExampleContextProvider({ + children, + ...props +}: ProgramExampleProps & { + children: React.ReactNode; +}): JSX.Element { + const { + sources, + instructions: dynamicInstructionsWithoutOffsets + } = props; + + const dynamicInstructions = computeOffsets( + dynamicInstructionsWithoutOffsets + ); + + const instructions = dynamicInstructions.map( + (dynamicInstruction) => + resolveDynamicInstruction(dynamicInstruction, { sources }) + ); + + const [ + highlightedOffset, + highlightInstruction + ] = useState(); + const [ + highlightedInstruction, + setHighlightedInstruction + ] = useState(); + const [ + highlightMode, + setHighlightMode + ] = useState<"simple" | "detailed">("simple"); + + const showDetails = () => setHighlightMode("detailed"); + const hideDetails = () => setHighlightMode("simple"); + + useEffect(() => { + if (typeof highlightedOffset === "undefined") { + setHighlightedInstruction(undefined); + return; + } + + const instruction = instructions + .find(({ offset }) => offset === highlightedOffset); + + if (!instruction) { + throw new Error(`Unexpected could not find instruction with offset ${highlightedOffset}`); + } + + setHighlightedInstruction(instruction); + }, [highlightedOffset, setHighlightedInstruction]); + + return + {children} + +} diff --git a/packages/web/src/theme/ProgramExample/SourceContents.css b/packages/web/src/theme/ProgramExample/SourceContents.css new file mode 100644 index 000000000..68912dd96 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/SourceContents.css @@ -0,0 +1,10 @@ + +.highlighted-code { + font-weight: bold; + background-color: var(--ifm-color-primary-lightest); +} + +.highlighted-variable-declaration { + text-decoration: underline; + text-decoration-style: wavy; +} diff --git a/packages/web/src/theme/ProgramExample/SourceContents.tsx b/packages/web/src/theme/ProgramExample/SourceContents.tsx new file mode 100644 index 000000000..b076f5583 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/SourceContents.tsx @@ -0,0 +1,100 @@ +import React, { useEffect } from "react"; + +import { + ShikiCodeBlock, + type Props as ShikiCodeBlockProps +} from "@theme/ShikiCodeBlock"; + +import "./SourceContents.css"; + +import type * as Shiki from "shiki/core"; +import { useProgramExampleContext } from "./ProgramExampleContext"; + +import { Materials, Program } from "@ethdebug/format"; + +export function SourceContents( + props: Omit +): JSX.Element { + const { + sources, + highlightedInstruction, + highlightMode + } = useProgramExampleContext(); + + if (sources.length !== 1) { + throw new Error("Multiple sources per example not currently supported"); + } + + const source = sources[0]; + + const context = highlightedInstruction?.context; + + const simpleDecorations = Program.Context.isCode(context) + ? decorateCodeContext(context, source) + : []; + + const detailedDecorations = [ + ...simpleDecorations, + ...(Program.Context.isVariables(context) + ? decorateVariablesContext(context, source) + : [] + ) + ]; + + const decorations = highlightMode === "detailed" + ? detailedDecorations + : simpleDecorations; + + return ; +} + +function decorateCodeContext( + { code }: Program.Context.Code, + source: Materials.Source +): Shiki.DecorationItem[] { + const { offset, length } = normalizeRange(code.range, source); + + return [ + { + start: offset, + end: offset + length, + properties: { + class: "highlighted-code" + } + } + ]; +} + +function decorateVariablesContext( + { variables }: Program.Context.Variables, + source: Materials.Source +): Shiki.DecorationItem[] { + + return variables.map(({ declaration }) => { + const { offset, length } = normalizeRange(declaration?.range, source); + return { + start: offset, + end: offset + length, + properties: { + class: "highlighted-variable-declaration" + } + }; + }); +} + +function normalizeRange( + range: Materials.SourceRange["range"], + source: Materials.Source +): Materials.SourceRange["range"] & { offset: number; length: number } { + const { offset, length } = range + ? { offset: Number(range.offset), length: Number(range.length) } + : { offset: 0, length: source.contents.length }; + + return { offset, length }; +} diff --git a/packages/web/src/theme/ProgramExample/Variables.tsx b/packages/web/src/theme/ProgramExample/Variables.tsx new file mode 100644 index 000000000..fbdf8c873 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/Variables.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from "react"; +import Admonition from "@theme/Admonition"; +import Link from "@docusaurus/Link"; +import { useProgramExampleContext } from "./ProgramExampleContext"; + +import { Program } from "@ethdebug/format"; +import { ShikiCodeBlock } from "@theme/ShikiCodeBlock"; + +export function Variables(): JSX.Element { + const { highlightedInstruction } = useProgramExampleContext(); + + const link = + ethdebug/format/program Variables context schema + ; + + if (highlightedInstruction === undefined) { + return + Hover or click on an offset above to see the {link} object + for that instruction. + ; + } + + const { context } = highlightedInstruction; + if (!(context && "variables" in context)) { + return + The highlighted instruction does not specify any variables in context + information. See other tab for full instruction object. + ; + } + + const { variables } = context; + + if (!variables.every(variable => "identifier" in variable)) { + throw new Error( + "Unnamed variables are currently unsupported by this documentation system" + ); + } + + return <> + + The following is the {link} object for the selected instruction. + + +
{ + variables.map((variable) => + + ) + }
+ ; +} + +interface VariableProps { + variable: Program.Context.Variables.Variable; +} + +function Variable(props: VariableProps): JSX.Element { + const { variable } = props; + + const details = (["type", "pointer"] as const) + .filter(detail => detail in variable) + .map((detail) =>
+

{`${detail.slice(0,1).toUpperCase()}${detail.slice(1)}`}

+ + +
) + return <> +
+ {variable.identifier} +
+
+
+ {details} +
+
+ ; +} diff --git a/packages/web/src/theme/ProgramExample/Viewer.css b/packages/web/src/theme/ProgramExample/Viewer.css new file mode 100644 index 000000000..db4d60832 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/Viewer.css @@ -0,0 +1,15 @@ + +.viewer-row { + display: flex; + + justify-content: space-between; + align-items: stretch; + + width: 100%; + gap: 5px; + +} + +.viewer-row > * { + flex-grow:1; +} diff --git a/packages/web/src/theme/ProgramExample/Viewer.tsx b/packages/web/src/theme/ProgramExample/Viewer.tsx new file mode 100644 index 000000000..91fd137be --- /dev/null +++ b/packages/web/src/theme/ProgramExample/Viewer.tsx @@ -0,0 +1,72 @@ +import Admonition from "@theme/Admonition"; +import Link from "@docusaurus/Link"; +import { useProgramExampleContext } from "./ProgramExampleContext"; +import { SourceContents } from "./SourceContents"; +import { Opcodes } from "./Opcodes"; +import { HighlightedInstruction } from "./HighlightedInstruction"; +import { Variables } from "./Variables"; + +import "./Viewer.css"; +import "./SourceContents.css"; + +export interface Props { +} + +export function Viewer(props: Props): JSX.Element { + const { highlightedInstruction, highlightMode } = useProgramExampleContext(); + + const basicAdmonition = + Select an instruction offset to see associated + ethdebug/format + debugging information. + ; + + const detailedAdmonition = +

+ The selected instruction provides the following + ethdebug/format Program contexts + : +

+
    +
  • + Code context is highlighted in this + style above. +
  • +
  • + Variables context is indicated by variable declarations + highlighted in this + style above. +
  • +
+
; + + const details = highlightedInstruction && highlightMode === "detailed" + ? <> +

Details

+ {detailedAdmonition} +
+ See full ethdebug/format/program/instruction object + +
+ + : <> +

Details

+ {basicAdmonition} + ; + + + return <> +

Interactive example

+
+
+

Source contents

+ +
+
+

Compiled opcodes

+ +
+
+ {details} + ; +} diff --git a/packages/web/src/theme/ProgramExample/dynamic.ts b/packages/web/src/theme/ProgramExample/dynamic.ts new file mode 100644 index 000000000..b8bc6a20b --- /dev/null +++ b/packages/web/src/theme/ProgramExample/dynamic.ts @@ -0,0 +1,94 @@ +import { Program, Materials } from "@ethdebug/format"; + +export type DynamicInstruction = + & Omit + & { operation: Program.Instruction.Operation; } + & { context: DynamicContext; }; + +export type DynamicContext = + | Program.Context + | ContextThunk; + +export type ContextThunk = (props: { + findSourceRange( + query: string, + options?: FindSourceRangeOptions + ): Materials.SourceRange | undefined; +}) => Program.Context; + +export interface FindSourceRangeOptions { + source?: Materials.Reference; + after?: string; +} + +export interface ResolverOptions { + sources: Materials.Source[]; +} + +export function resolveDynamicInstruction( + dynamicInstruction: DynamicInstruction, + options: ResolverOptions +): Program.Instruction { + const context = resolveDynamicContext( + dynamicInstruction.context, + options + ); + + const instruction = { + ...dynamicInstruction, + context + }; + + return instruction; +} + +function resolveDynamicContext( + context: DynamicContext, + { sources }: ResolverOptions +): Program.Context { + if (typeof context !== "function") { + return context; + } + + const findSourceRange = ( + query: string, + options: FindSourceRangeOptions = {} + ) => { + const source = "source" in options && options.source + ? sources.find(source => source.id === options.source?.id) + : sources[0]; + + if (!source) { + return; + } + + const afterQuery = options.after || ""; + + const afterQueryOffset = source.contents.indexOf(afterQuery); + if (afterQueryOffset === -1) { + throw new Error( + `Unexpected could not find string ${options.after} as prior occurrence to ${query}` + ); + } + + + const startOffset = afterQueryOffset + afterQuery.length; + + const offset = source.contents.indexOf(query, startOffset); + if (offset === -1) { + throw new Error(`Unexpected could not find string ${query}`); + } + + const length = query.length; + + return { + source: { id: source.id }, + range: { + offset, + length + } + }; + }; + + return context({ findSourceRange }); +} diff --git a/packages/web/src/theme/ProgramExample/index.ts b/packages/web/src/theme/ProgramExample/index.ts new file mode 100644 index 000000000..a7901da1a --- /dev/null +++ b/packages/web/src/theme/ProgramExample/index.ts @@ -0,0 +1,7 @@ +export * from "./ProgramExampleContext"; +export * from "./SourceContents"; +export * from "./Opcodes"; +export * from "./HighlightedInstruction"; + +export * from "./Viewer"; + diff --git a/packages/web/src/theme/ProgramExample/offsets.ts b/packages/web/src/theme/ProgramExample/offsets.ts new file mode 100644 index 000000000..bb112d61d --- /dev/null +++ b/packages/web/src/theme/ProgramExample/offsets.ts @@ -0,0 +1,59 @@ +import { Data, Program } from "@ethdebug/format"; + +// define base generic instruction since other parts of this module +// allow dynamic contexts and such +interface OffsetComputableInstruction { + operation: Program.Instruction.Operation; +} + +type OffsetComputedInstruction = + & I + & { offset: Data.Value; }; + +export function computeOffsets( + instructions: I[] +): OffsetComputedInstruction[] { + const initialResults: { + nextOffset: number; + results: OffsetComputedInstruction[]; + } = { + nextOffset: 0, + results: [] + }; + + const { + results + } = instructions.reduce( + ({ nextOffset, results }, instruction) => { + const result = { + offset: nextOffset, + ...instruction + }; + + const operationSize = ( + 1 /* for opcode */ + + Math.ceil( + (instruction.operation.arguments || []) + .map( + value => typeof value === "number" + ? value.toString(16) + : value.slice(2) + ) + .join("") + .length / 2 + ) + ); + + return { + nextOffset: nextOffset + operationSize, + results: [ + ...results, + result + ] + }; + }, + initialResults + ); + + return results; +} diff --git a/packages/web/src/theme/ShikiCodeBlock/ShikiCodeBlock.tsx b/packages/web/src/theme/ShikiCodeBlock/ShikiCodeBlock.tsx new file mode 100644 index 000000000..ef68a2cb6 --- /dev/null +++ b/packages/web/src/theme/ShikiCodeBlock/ShikiCodeBlock.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { + type Highlighter, + type HighlightOptions, + useHighlighter +} from "./useHighlighter"; + +export interface Props extends HighlightOptions { + code: string; +} + +export function ShikiCodeBlock({ + code, + ...highlightOptions +}: Props): JSX.Element { + const highlighter = useHighlighter(); + + if (!highlighter) { + return <>Loading...; + } + + const html = highlighter.highlight(code, highlightOptions); + + return
; +} diff --git a/packages/web/src/theme/ShikiCodeBlock/index.ts b/packages/web/src/theme/ShikiCodeBlock/index.ts new file mode 100644 index 000000000..987f6d64f --- /dev/null +++ b/packages/web/src/theme/ShikiCodeBlock/index.ts @@ -0,0 +1,6 @@ +export * from "./useHighlighter"; +export * from "./ShikiCodeBlock"; + +import { ShikiCodeBlock } from "./ShikiCodeBlock"; + +export default ShikiCodeBlock; diff --git a/packages/web/src/theme/ShikiCodeBlock/useHighlighter.ts b/packages/web/src/theme/ShikiCodeBlock/useHighlighter.ts new file mode 100644 index 000000000..c81735a70 --- /dev/null +++ b/packages/web/src/theme/ShikiCodeBlock/useHighlighter.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; + +import * as Shiki from "shiki/core"; +import { createOnigurumaEngine } from "shiki/engine/oniguruma"; + +export interface Highlighter { + highlight(text: string, options: HighlightOptions): string; +} + +export interface HighlightOptions { + language?: string; + decorations?: Shiki.DecorationItem[]; +} + +export function useHighlighter() { + const [highlighter, setHighlighter] = useState(); + + useEffect(() => { + createHighlighter().then(setHighlighter) + }, [setHighlighter]); + + return highlighter; +} + +async function createHighlighter(): Promise { + const shiki = await Shiki.createHighlighterCore({ + themes: [ + import("@shikijs/themes/github-light"), + ], + langs: [ + import("@shikijs/langs/solidity"), + import("@shikijs/langs/javascript"), + ], + engine: createOnigurumaEngine(import("shiki/wasm")) + }); + + const themeName = "github-light"; + + return { + highlight(text, { language, decorations }) { + return shiki.codeToHtml(text, { + lang: language || "text", + theme: themeName, + decorations + }) + } + }; +} diff --git a/yarn.lock b/yarn.lock index 7373c649c..f1a6cd1de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3506,6 +3506,62 @@ "@noble/hashes" "~1.4.0" "@scure/base" "~1.1.6" +"@shikijs/core@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-2.2.0.tgz#3cdc4a4463dde8c4d3bb0a42e39376d47ac8b2ce" + integrity sha512-U+vpKdsQDWuX3fPTCkSc8XPX9dCaS+r+qEP1XhnU30yxRFo2OxHJmY2H5rO1q+v0zB5R2vobsxEFt5uPf31CGQ== + dependencies: + "@shikijs/engine-javascript" "2.2.0" + "@shikijs/engine-oniguruma" "2.2.0" + "@shikijs/types" "2.2.0" + "@shikijs/vscode-textmate" "^10.0.1" + "@types/hast" "^3.0.4" + hast-util-to-html "^9.0.4" + +"@shikijs/engine-javascript@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-2.2.0.tgz#7427a0e91c0c233111ca66076b9197872378b3c5" + integrity sha512-96SpZ4V3UVMtpSPR5QpmU395CNrQiRPszXK62m8gKR2HMA0653ruce7omS5eX6EyAyFSYHvBWtTuspiIsHpu4A== + dependencies: + "@shikijs/types" "2.2.0" + "@shikijs/vscode-textmate" "^10.0.1" + oniguruma-to-es "^2.3.0" + +"@shikijs/engine-oniguruma@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-2.2.0.tgz#9745f322571752ca5ab4f8393df24057f6f36e21" + integrity sha512-wowCKwkvPFFMXFkiKK/a2vs5uTCc0W9+O9Xcu/oqFP6VoDFe14T8u/D+Rl4dCJJSOyeynP9mxNPJ82T5JHTNCw== + dependencies: + "@shikijs/types" "2.2.0" + "@shikijs/vscode-textmate" "^10.0.1" + +"@shikijs/langs@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-2.2.0.tgz#fbd3a25a8bef754a83034d3d62b66252b96b8c74" + integrity sha512-RSWLH3bnoyG6O1kZ2msh5jOkKKp8eENwyT30n62vUtXfp5cxkF/bpWPpO+p4+GAPhL2foBWR2kOerwkKG0HXlQ== + dependencies: + "@shikijs/types" "2.2.0" + +"@shikijs/themes@2.2.0", "@shikijs/themes@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-2.2.0.tgz#0792df404b413836c4805611382f10a7bd08c843" + integrity sha512-8Us9ZF2mV9kuh+4ySJ9MzrUDIpc2RIkRfKBZclkliW1z9a0PlGU2U7fCkItZZHpR5e4/ft5BzuO+GDqombC6Aw== + dependencies: + "@shikijs/types" "2.2.0" + +"@shikijs/types@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-2.2.0.tgz#3953175a58e3ac0b25dae508fc4a85e5b5e3cc30" + integrity sha512-wkZZKs80NtW5Jp/7ONI1j7EdXSatX2BKMS7I01wliDa09gJKHkZyVqlEMRka/mjT5Qk9WgAyitoCKgGgbsP/9g== + dependencies: + "@shikijs/vscode-textmate" "^10.0.1" + "@types/hast" "^3.0.4" + +"@shikijs/vscode-textmate@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.1.tgz#d06d45b67ac5e9b0088e3f67ebd3f25c6c3d711a" + integrity sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg== + "@sideway/address@^4.1.3": version "4.1.4" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz" @@ -4007,6 +4063,13 @@ dependencies: "@types/unist" "*" +"@types/hast@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz" @@ -6519,6 +6582,11 @@ emittery@^0.13.1: resolved "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-regex-xs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724" + integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" @@ -7682,6 +7750,23 @@ hast-util-to-estree@^3.0.0: unist-util-position "^5.0.0" zwitch "^2.0.0" +hast-util-to-html@^9.0.4: + version "9.0.4" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.4.tgz#d689c118c875aab1def692c58603e34335a0f5c5" + integrity sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + hast-util-to-jsx-runtime@^2.0.0: version "2.3.0" resolved "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz" @@ -10903,6 +10988,15 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +oniguruma-to-es@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz#35ea9104649b7c05f3963c6b3b474d964625028b" + integrity sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g== + dependencies: + emoji-regex-xs "^1.0.0" + regex "^5.1.1" + regex-recursion "^5.1.1" + open@^8.0.9, open@^8.4.0: version "8.4.2" resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz" @@ -12199,6 +12293,26 @@ regenerator-transform@^0.15.2: dependencies: "@babel/runtime" "^7.8.4" +regex-recursion@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-5.1.1.tgz#5a73772d18adbf00f57ad097bf54171b39d78f8b" + integrity sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w== + dependencies: + regex "^5.1.1" + regex-utilities "^2.3.0" + +regex-utilities@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280" + integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng== + +regex@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/regex/-/regex-5.1.1.tgz#cf798903f24d6fe6e531050a36686e082b29bd03" + integrity sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw== + dependencies: + regex-utilities "^2.3.0" + regexpu-core@^5.3.1: version "5.3.2" resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz" @@ -12734,6 +12848,20 @@ shelljs@^0.8.5: interpret "^1.0.0" rechoir "^0.6.2" +shiki@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-2.2.0.tgz#3dab8ea41bb11a129acb70e858102df72ac73fb1" + integrity sha512-3uoZBmc+zpd2JOEeTvKP/vK5UVDDe8YiigkT9flq+MV5Z1MKFiUXfbLIvHfqcJ+V90StDiP1ckN97z1WlhC6cQ== + dependencies: + "@shikijs/core" "2.2.0" + "@shikijs/engine-javascript" "2.2.0" + "@shikijs/engine-oniguruma" "2.2.0" + "@shikijs/langs" "2.2.0" + "@shikijs/themes" "2.2.0" + "@shikijs/types" "2.2.0" + "@shikijs/vscode-textmate" "^10.0.1" + "@types/hast" "^3.0.4" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz" @@ -14369,7 +14497,7 @@ yocto-queue@^1.0.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zwitch@^2.0.0: +zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==