diff --git a/.changeset/pink-ants-deliver.md b/.changeset/pink-ants-deliver.md new file mode 100644 index 0000000..ba86563 --- /dev/null +++ b/.changeset/pink-ants-deliver.md @@ -0,0 +1,5 @@ +--- +"expressive-code-twoslash": patch +--- + +Add codeblock and type processing to Hover/Static annotations diff --git a/packages/twoslash/README.md b/packages/twoslash/README.md index 68d4123..1e4ae02 100644 --- a/packages/twoslash/README.md +++ b/packages/twoslash/README.md @@ -8,7 +8,6 @@ Add Twoslash support to your Expressive Code TypeScript code blocks. ### TODO - [ ] Make Annotations accessible -- [ ] Implement Annotation code processesing (Requires support from EC (Planned)) - [ ] Use EC's Markdown processing system once released. (Requires support from EC (Planned)) - [ ] Figure out how to work with TwoslashVFS and setup support for "Showing Emitted Files" diff --git a/packages/twoslash/package.json b/packages/twoslash/package.json index 3977325..8495b6f 100644 --- a/packages/twoslash/package.json +++ b/packages/twoslash/package.json @@ -31,9 +31,7 @@ "default": "./dist/index.js" }, "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build-js-module": "tsm --require=../../scripts/filter-warnings.cjs ./scripts/minify.ts", "compile": "tsup ./src/index.ts --format esm --dts --sourcemap --clean", @@ -42,6 +40,7 @@ }, "type": "module", "dependencies": { + "expressive-code": "^0.38.3", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.0.0", "mdast-util-to-hast": "^13.2.0", diff --git a/packages/twoslash/src/annotations/hover.ts b/packages/twoslash/src/annotations/hover.ts index 641ad1a..31f149b 100644 --- a/packages/twoslash/src/annotations/hover.ts +++ b/packages/twoslash/src/annotations/hover.ts @@ -4,14 +4,7 @@ import { } from "@expressive-code/core"; import { h, type Root, type Element } from "@expressive-code/core/hast"; import type { NodeHover } from "twoslash"; -import { - defaultHoverInfoProcessor, - renderMarkdown, - checkIfSingleParagraph, - filterTags, - renderMarkdownInline, -} from "../helpers"; -import { jsdocTags } from "../regex"; +import type { RenderJSDocs } from "../types"; /** * Represents a hover annotation for Twoslash. @@ -25,7 +18,8 @@ export class TwoslashHoverAnnotation extends ExpressiveCodeAnnotation { */ constructor( readonly hover: NodeHover, - readonly includeJsDoc: boolean, + readonly codeType: Element, + readonly renderedDocs: RenderJSDocs, ) { super({ inlineRange: { @@ -35,20 +29,6 @@ export class TwoslashHoverAnnotation extends ExpressiveCodeAnnotation { }); } - private getHoverInfo(text: string) { - const info = defaultHoverInfoProcessor(text); - - if (info === false) { - return []; - } - - if (typeof info === "string") { - return h("code.twoslash-popup-code", [ - h("span.twoslash-popup-code-type", info), - ]); - } - } - /** * Renders the hover annotation. * @param nodesToTransform - The nodes to be transformed with hover annotations. @@ -63,44 +43,11 @@ export class TwoslashHoverAnnotation extends ExpressiveCodeAnnotation { "div.twoslash-popup-container.not-content", [ - this.getHoverInfo(this.hover.text), - ...(this.hover.docs && this.includeJsDoc - ? [ - h("div.twoslash-popup-docs", [ - h("p", [renderMarkdown(this.hover.docs)]), - ]), - ] - : []), - ...(this.hover.tags && this.includeJsDoc - ? [ - h("div.twoslash-popup-docs.twoslash-popup-docs-tags", [ - ...this.hover.tags.map((tag) => - jsdocTags.includes(tag[0]) - ? h("p", [ - h( - "span.twoslash-popup-docs-tag-name", - `@${tag[0]}`, - ), - tag[1] - ? [ - checkIfSingleParagraph( - tag[1], - filterTags(tag[0]), - ) - ? " ― " - : " ", - h( - "span.twoslash-popup-docs-tag-value", - renderMarkdownInline(tag[1]), - ), - ] - : [], - ]) - : [], - ), - ]), - ] - : []), + h("code.twoslash-popup-code", [ + h("span.twoslash-popup-code-type", this.codeType), + ]), + this.renderedDocs.docs, + this.renderedDocs.tags, ], ), node, diff --git a/packages/twoslash/src/annotations/static.ts b/packages/twoslash/src/annotations/static.ts index 53453c2..4137fae 100644 --- a/packages/twoslash/src/annotations/static.ts +++ b/packages/twoslash/src/annotations/static.ts @@ -3,17 +3,10 @@ import { ExpressiveCodeAnnotation, type ExpressiveCodeLine, } from "@expressive-code/core"; -import { h } from "@expressive-code/core/hast"; +import { type Element, h } from "@expressive-code/core/hast"; import type { NodeQuery } from "twoslash"; -import { - defaultHoverInfoProcessor, - getTextWidthInPixels, - renderMarkdown, - checkIfSingleParagraph, - filterTags, - renderMarkdownInline, -} from "../helpers"; -import { jsdocTags } from "../regex"; +import { getTextWidthInPixels } from "../helpers"; +import type { RenderJSDocs } from "../types"; /** * Represents a static annotation for Twoslash. @@ -32,7 +25,8 @@ export class TwoslashStaticAnnotation extends ExpressiveCodeAnnotation { constructor( readonly query: NodeQuery, readonly line: ExpressiveCodeLine, - readonly includeJsDoc: boolean, + readonly codeType: Element, + readonly renderedDocs: RenderJSDocs, ) { super({ inlineRange: { @@ -42,20 +36,6 @@ export class TwoslashStaticAnnotation extends ExpressiveCodeAnnotation { }); } - private getHoverInfo(text: string) { - const info = defaultHoverInfoProcessor(text); - - if (info === false) { - return []; - } - - if (typeof info === "string") { - return h("code.twoslash-popup-code", [ - h("span.twoslash-popup-code-type", info), - ]); - } - } - /** * Renders the static annotation. * @param nodesToTransform - The nodes to transform with the error box annotation. @@ -74,44 +54,11 @@ export class TwoslashStaticAnnotation extends ExpressiveCodeAnnotation { }, [ h("div.twoslash-static-container.not-content", [ - this.getHoverInfo(this.query.text), - ...(this.query.docs && this.includeJsDoc - ? [ - h("div.twoslash-popup-docs", [ - h("p", [renderMarkdown(this.query.docs)]), - ]), - ] - : []), - ...(this.query.tags && this.includeJsDoc - ? [ - h("div.twoslash-popup-docs.twoslash-popup-docs-tags", [ - ...this.query.tags.map((tag) => - jsdocTags.includes(tag[0]) - ? h("p", [ - h( - "span.twoslash-popup-docs-tag-name", - `@${tag[0]}`, - ), - tag[1] - ? [ - checkIfSingleParagraph( - tag[1], - filterTags(tag[0]), - ) - ? " ― " - : " ", - h( - "span.twoslash-popup-docs-tag-value", - renderMarkdownInline(tag[1]), - ), - ] - : [], - ]) - : [], - ), - ]), - ] - : []), + h("code.twoslash-popup-code", [ + h("span.twoslash-popup-code-type", this.codeType), + ]), + this.renderedDocs.docs, + this.renderedDocs.tags, ]), ], ), diff --git a/packages/twoslash/src/helpers.ts b/packages/twoslash/src/helpers.ts index a8cb17d..ac15738 100644 --- a/packages/twoslash/src/helpers.ts +++ b/packages/twoslash/src/helpers.ts @@ -1,16 +1,22 @@ -import type { ExpressiveCodeBlock } from "@expressive-code/core"; -import type { Element, ElementContent } from "@expressive-code/core/hast"; +import type { + ExpressiveCodeBlock, + ResolvedExpressiveCodeEngineConfig, +} from "@expressive-code/core"; +import { h, type Element } from "@expressive-code/core/hast"; import { fromMarkdown } from "mdast-util-from-markdown"; import { gfmFromMarkdown } from "mdast-util-gfm"; import { toHast } from "mdast-util-to-hast"; import type { NodeCompletion, NodeError, + NodeHover, + NodeQuery, TwoslashNode, TwoslashOptions, } from "twoslash"; import { completionIcons } from "./icons/completionIcons"; import { + jsdocTags, reFunctionCleanup, reImportStatement, reInterfaceOrNamespace, @@ -21,63 +27,109 @@ import { reTypeCleanup, twoslashDefaultTags, } from "./regex"; -import type { CompletionIcon, CompletionItem, TwoslashTag } from "./types"; +import type { + CompletionIcon, + CompletionItem, + RenderJSDocs, + TwoslashTag, +} from "./types"; +import type { ExpressiveCode, ExpressiveCodeConfig } from "expressive-code"; /** - * Converts a markdown string into an array of ElementContent objects. + * Renders markdown content with code blocks using ExpressiveCode. * - * This function processes the input markdown string, replacing JSDoc links with their - * corresponding text, and then parses the markdown into an MDAST (Markdown Abstract Syntax Tree). - * The MDAST is then transformed into a HAST (Hypertext Abstract Syntax Tree) and the children - * of the resulting HAST element are returned. + * This function processes the given markdown string, converts it to an MDAST (Markdown Abstract Syntax Tree), + * and then transforms it into HAST (Hypertext Abstract Syntax Tree) with custom handlers for code blocks. + * It then uses ExpressiveCode to render the code blocks with syntax highlighting and other features. * - * @param md - The markdown string to be converted. - * @returns An array of ElementContent objects representing the parsed markdown. + * @param md - The markdown string to be processed. + * @param ec - An instance of ExpressiveCode used to render the code blocks. + * @returns A promise that resolves to an array of HAST nodes representing the processed markdown content. */ -export function renderMarkdown(md: string): ElementContent[] { +export async function renderMarkdownWithCodeBlocks( + md: string, + ec: ExpressiveCode, +) { const mdast = fromMarkdown( md.replace(reJsDocLink, "$1"), // replace jsdoc links { mdastExtensions: [gfmFromMarkdown()] }, ); - return ( - toHast(mdast, { - handlers: { - // Replace this section with EC processing once it's available - // code: (state, node) => { - // const lang = node.lang || ''; - // if (lang) { - // return { - // type: 'element', - // tagName: 'code', - // properties: {}, - // children: this.codeToHast(node.value, { - // ...this.options, - // transformers: [], - // lang, - // structure: node.value.trim().includes('\n') ? 'classic' : 'inline', - // }).children, - // } as Element; - // } - // return defaultHandlers.code(state, node); - // }, + const nodes = toHast(mdast, { + handlers: { + code: (state, node) => { + const lang = node.lang || ""; + if (lang) { + return { + type: "element", + tagName: "code", + properties: { + class: "expressive-code", + "data-lang": lang, + }, + children: [ + { + type: "text", + value: node.value, + }, + ], + } as Element; + } + return { + type: "element", + tagName: "code", + properties: { + class: "expressive-code", + }, + children: [ + { + type: "text", + value: node.value, + }, + ], + } as Element; }, - }) as Element - ).children; + }, + }) as Element; + + const codeBlocks = nodes.children + ? nodes.children.filter( + (node) => node.type === "element" && node.tagName === "code", + ) + : []; + + for (const codeBlock of codeBlocks) { + if (codeBlock.type === "element") { + codeBlock.children = + codeBlock.type === "element" && codeBlock.children[0].type === "text" + ? ( + await ec.render({ + code: codeBlock.children[0].value, + language: + (codeBlock.properties["data-lang"] as string) || "plaintext", + }) + ).renderedGroupAst.children + : []; + } + } + + return nodes.children; } /** - * Renders the given markdown string as an array of ElementContent. - * If the rendered markdown consists of a single paragraph element, - * it returns the children of that paragraph instead. + * Renders the given markdown string inline using the ExpressiveCode instance. * - * @param md - The markdown string to render. - * @returns An array of ElementContent representing the rendered markdown. + * This function processes the markdown string and returns the rendered children. + * If the rendered children contain a single paragraph element, it returns the children of that paragraph. + * Otherwise, it returns the entire rendered children array. + * + * @param md - The markdown string to be rendered. + * @param ec - The ExpressiveCode instance used for rendering. + * @returns A promise that resolves to the rendered children. */ -export function renderMarkdownInline(md: string): ElementContent[] { - const betterMD = md; +export async function renderMDInline(md: string, ec: ExpressiveCode) { + const children = await renderMarkdownWithCodeBlocks(md, ec); - const children = renderMarkdown(betterMD); if ( children.length === 1 && children[0].type === "element" && @@ -87,6 +139,64 @@ export function renderMarkdownInline(md: string): ElementContent[] { return children; } +/** + * Renders JSDoc comments for a given hover node. + * + * @param hover - The hover node containing documentation and tags. + * @param includeJsDoc - A boolean indicating whether to include JSDoc comments. + * @param ec - The ExpressiveCode instance used for rendering. + * @returns A promise that resolves to an object containing rendered documentation and tags. + */ +export async function renderJSDocs( + hover: NodeHover | NodeQuery, + includeJsDoc: boolean, + ec: ExpressiveCode, +): Promise { + if (!includeJsDoc) return { docs: [], tags: [] }; + return { + docs: hover.docs + ? h("div.twoslash-popup-docs", [ + h( + "p", + hover.docs + ? await renderMarkdownWithCodeBlocks(hover.docs, ec) + : [], + ), + ]) + : [], + tags: hover.tags + ? h("div.twoslash-popup-docs.twoslash-popup-docs-tags", [ + ...(await Promise.all( + hover.tags + ? hover.tags.map(async (tag) => + jsdocTags.includes(tag[0]) + ? h("p", [ + h("span.twoslash-popup-docs-tag-name", `@${tag[0]}`), + tag[1] + ? [ + (await checkIfSingleParagraph( + tag[1], + filterTags(tag[0]), + ec, + )) + ? " ― " + : " ", + h( + "span.twoslash-popup-docs-tag-value", + await renderMDInline(tag[1], ec), + ), + ] + : [], + ]) + : [], + ) + : [], + )), + ]) + : [], + }; +} + /** * Checks if the given markdown string consists of a single paragraph element. * @@ -94,11 +204,12 @@ export function renderMarkdownInline(md: string): ElementContent[] { * @param filterTags - A boolean indicating whether to filter tags. * @returns A boolean indicating if the markdown string is a single paragraph element. */ -export function checkIfSingleParagraph( +export async function checkIfSingleParagraph( md: string, filterTags: boolean, -): boolean { - const children = renderMarkdownInline(md); + ec: ExpressiveCode, +): Promise { + const children = await renderMDInline(md, ec); if (filterTags) { return !( children.length === 1 && @@ -412,3 +523,47 @@ export function compareNodes( // If no checks failed, the nodes are considered equal return true; } + +export const ecConfig = ( + config: ResolvedExpressiveCodeEngineConfig, +): ExpressiveCodeConfig => { + return { + cascadeLayer: config.cascadeLayer, + customizeTheme: config.customizeTheme, + defaultLocale: config.defaultLocale, + defaultProps: config.defaultProps, + logger: config.logger, + minSyntaxHighlightingColorContrast: + config.minSyntaxHighlightingColorContrast, + styleOverrides: config.styleOverrides, + themeCssRoot: config.themeCssRoot, + themeCssSelector: config.themeCssSelector, + themes: config.themes, + useDarkModeMediaQuery: config.useDarkModeMediaQuery, + useStyleReset: config.useStyleReset, + useThemedScrollbars: config.useThemedScrollbars, + useThemedSelectionColors: config.useThemedSelectionColors, + frames: { + showCopyToClipboardButton: false, + extractFileNameFromCode: false, + }, + }; +}; + +export async function renderType(text: string, ec: ExpressiveCode) { + const info = defaultHoverInfoProcessor(text); + if (typeof info === "string") { + const { renderedGroupAst } = await ec.render({ + code: info, + language: "ts", + meta: "", + }); + return renderedGroupAst; + } + const { renderedGroupAst } = await ec.render({ + code: text, + language: "ts", + meta: "", + }); + return renderedGroupAst; +} diff --git a/packages/twoslash/src/index.ts b/packages/twoslash/src/index.ts index 6236047..c2c51a2 100644 --- a/packages/twoslash/src/index.ts +++ b/packages/twoslash/src/index.ts @@ -6,6 +6,9 @@ import { processCompletion, splitCodeToLines, compareNodes, + ecConfig, + renderType, + renderJSDocs, } from "./helpers"; import floatingUiCore from "./module-code/floating-ui-core.min"; import floatingUiDom from "./module-code/floating-ui-dom.min"; @@ -23,6 +26,7 @@ import { TwoslashHoverAnnotation, TwoslashStaticAnnotation, } from "./annotations"; +import { ExpressiveCode } from "expressive-code"; export type { PluginTwoslashOptions, TwoSlashStyleSettings }; @@ -92,11 +96,14 @@ export default function ecTwoSlash( styleSettings: twoSlashStyleSettings, baseStyles: (context) => getTwoSlashBaseStyles(context), hooks: { - preprocessCode({ codeBlock }) { + async preprocessCode({ codeBlock, config }) { if (shouldTransform(codeBlock)) { // Create a new instance of the TwoslashIncludesManager const includes = new TwoslashIncludesManager(includesMap); + // Create a new instance of the Expressive Code Engine for use in the plugin + const ecEngine = new ExpressiveCode(ecConfig(config)); + // Apply the includes to the code block const codeWithIncludes = includes.applyInclude(codeBlock.code); @@ -157,7 +164,12 @@ export default function ecTwoSlash( if (line) { line.addAnnotation( - new TwoslashStaticAnnotation(node, line, includeJsDoc), + new TwoslashStaticAnnotation( + node, + line, + await renderType(node.text, ecEngine), + await renderJSDocs(node, includeJsDoc, ecEngine), + ), ); } } @@ -196,7 +208,11 @@ export default function ecTwoSlash( if (line) { line.addAnnotation( - new TwoslashHoverAnnotation(node, includeJsDoc), + new TwoslashHoverAnnotation( + node, + await renderType(node.text, ecEngine), + await renderJSDocs(node, includeJsDoc, ecEngine), + ), ); } } diff --git a/packages/twoslash/src/styles.ts b/packages/twoslash/src/styles.ts index c1daa82..f9267ef 100644 --- a/packages/twoslash/src/styles.ts +++ b/packages/twoslash/src/styles.ts @@ -282,6 +282,33 @@ export function getTwoSlashBaseStyles({ cssVar }: ResolverContext): string { white-space: pre-wrap; } + .twoslash-popup-code, + .twoslash-popup-code span { + white-space: preserve !important; + } + + .twoslash-popup-code-type { + color: ${cssVar("twoSlash.titleColor")} !important; + font-family: ${cssVar("codeFontFamily")}; + font-weight: 600; + } + + .twoslash-popup-code-type .frame pre { + display: contents !important; + } + + .twoslash-popup-code-type .frame .ec-line .code { + padding-inline-start: 0 !important; + } + + .twoslash-popup-code-type .frame pre > code { + padding: 0 !important; + } + + .twoslash-popup-code-type .frame .header::before { + border: none !important; + } + .twoslash-popup-code::-webkit-scrollbar, .twoslash-popup-code::-webkit-scrollbar-track, .twoslash-popup-docs::-webkit-scrollbar, @@ -335,14 +362,9 @@ export function getTwoSlashBaseStyles({ cssVar }: ResolverContext): string { font-weight: 500; } - .twoslash-popup-code, - .twoslash-popup-code span { - white-space: preserve !important; - } - .twoslash-popup-docs pre { width: 100%; - background-color: ${cssVar("twoSlash.background")} !important; + background-color: var(--ec-frm-edBg) !important; padding: .15rem; border-radius: 4px !important; position: relative !important; @@ -352,12 +374,6 @@ export function getTwoSlashBaseStyles({ cssVar }: ResolverContext): string { border: 2px solid ${cssVar("twoSlash.borderColor")} !important; } - .twoslash-popup-code-type { - color: ${cssVar("twoSlash.titleColor")} !important; - font-family: ${cssVar("codeFontFamily")}; - font-weight: 600; - } - .twoslash-popup-docs.twoslash-popup-docs-tags { font-size: 14px !important; margin: 0 !important; @@ -381,7 +397,7 @@ export function getTwoSlashBaseStyles({ cssVar }: ResolverContext): string { .twoslash-popup-docs code { margin: 0 !important; - background-color: transparent !important; + background-color: var(--ec-frm-edBg) !important; line-height: normal !important; } diff --git a/packages/twoslash/src/types.ts b/packages/twoslash/src/types.ts index 15723f7..44a40f6 100644 --- a/packages/twoslash/src/types.ts +++ b/packages/twoslash/src/types.ts @@ -135,3 +135,8 @@ export type CompletionIcons = { export type CompletionIcon = keyof typeof completionIcons; export type TwoslashTag = "annotate" | "log" | "warn" | "error"; + +export type RenderJSDocs = { + docs: Element | never[]; + tags: Element | never[]; +}; diff --git a/playground/astro.config.mts b/playground/astro.config.mts index 85931eb..5b74517 100644 --- a/playground/astro.config.mts +++ b/playground/astro.config.mts @@ -8,7 +8,7 @@ export default defineConfig({ starlight({ title: "Starlight", expressiveCode: { - themes: ['github-dark-dimmed'], + // themes: ['github-dark-dimmed'], plugins: [ectwoslash()], }, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed46b7..5e14d94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: '@expressive-code/core': specifier: ^0.38.3 version: 0.38.3 + expressive-code: + specifier: ^0.38.3 + version: 0.38.3 mdast-util-from-markdown: specifier: ^2.0.2 version: 2.0.2