From fe244c445abce0994681ca3746147476b63158f9 Mon Sep 17 00:00:00 2001 From: "Amir H. Hashemi" <87268103+amirhhashemi@users.noreply.github.com> Date: Sat, 11 Oct 2025 09:12:05 +0330 Subject: [PATCH 1/3] Add more customization options to the language switcher plugin --- docs/src/routes/guide/language-switcher.mdx | 60 ++++- package.json | 5 + .../{ts-to-js.ts => converter.ts} | 72 ++--- .../ec-plugins/language-switcher/index.ts | 254 ++++++++++++++---- .../ec-plugins/language-switcher/styles.ts | 155 +++++++++++ src/config/mdx.ts | 16 +- src/config/plugins.ts | 1 + src/config/remark-plugins/tab-group.ts | 15 +- src/default-theme/index.css | 69 ----- src/default-theme/mdx-components.tsx | 24 +- src/default-theme/variables.css | 4 - 11 files changed, 490 insertions(+), 185 deletions(-) rename src/config/ec-plugins/language-switcher/{ts-to-js.ts => converter.ts} (91%) create mode 100644 src/config/ec-plugins/language-switcher/styles.ts create mode 100644 src/config/plugins.ts diff --git a/docs/src/routes/guide/language-switcher.mdx b/docs/src/routes/guide/language-switcher.mdx index 33946cf..2808082 100644 --- a/docs/src/routes/guide/language-switcher.mdx +++ b/docs/src/routes/guide/language-switcher.mdx @@ -20,7 +20,7 @@ import { withSolidBase } from "@kobalte/solidbase/config"; export default defineConfig( withSolidBase( - /* your SolidStart config */, + /* your SolidStart config */ { markdown: { expressiveCode: { @@ -42,15 +42,30 @@ import { withSolidBase } from "@kobalte/solidbase/config"; export default defineConfig( withSolidBase( - /* your SolidStart config */, + /* your SolidStart config */ { markdown: { expressiveCode: { languageSwitcher: { showToggleButton: true, - formatter: async (jsCode, isJsx) => { - // Custom formatting - return code; + toggleButtonTsLabel: "TypeScript", + toggleButtonJsLabel: "JavaScript", + conversions: { + // Override default formatter for typescript + typescript: { + to: "javascript", + fileExtensionMap: { ".ts": ".js" }, + formatter: (code) => myFormatter(code), + }, + // Add a new conversion for Svelte + svelte: { + to: "svelte", + fileExtensionMap: { ".svelte": ".svelte" }, + converter: svelteConverter, + formatter: svelteFormatter + }, + // Disable the default "tsx" conversion + tsx: false, }, }, }, @@ -62,12 +77,12 @@ export default defineConfig( ### Supported Languages -The plugin converts these TypeScript variants to JavaScript: +By default, the plugin converts these TypeScript variants to JavaScript: - `typescript` / `ts` → `javascript` / `js` - `tsx` → `jsx` -JavaScript code blocks (`js`, `javascript`, `jsx`) are not converted. +You can extend or override these defaults using the [`conversions`](#conversions) option. ### Meta Options @@ -93,8 +108,39 @@ The plugin automatically preserves line markers. - **Default:** `true` - **Description:** Whether to show the toggle button in the code block header. +### `toggleButtonTsLabel` + +- **Type:** `string` +- **Default:** `"TS"` +- **Description:** The label for the TypeScript toggle button. + +### `toggleButtonJsLabel` + +- **Type:** `string` +- **Default:** `"JS"` +- **Description:** The label for the JavaScript toggle button. + +### `conversions` + +- **Type:** `Record` +- **Description:** A map defining language conversion rules. + The key is the source language ID. + This allows you to extend the default conversions (e.g., add Svelte) or override them (e.g., change the formatter for TypeScript). + To disable a default conversion (e.g., for "typescript"), set its value to `false`. + + A `ConversionRule` object has the following properties: + + - `to?: string`: The target language ID to use for syntax highlighting after conversion. + If not provided, the source language ID is used. + - `fileExtensionMap: Record`: A map of source file extensions to their target extensions for this language (e.g., `{ ".ts": ".js" }`). + This is used to update the filename in the code block title. + - `converter: Converter`: A converter for this language. + - `formatter?: Formatter`: An optional formatter for this language's output. + ### `formatter` - **Type:** `(jsCode: string, isJsx; boolean) => string | Promise` - **Default:** Formatting with Prettier with default options - **Description:** A function to format the generated JavaScript code. +- **Deprecated:** Use the `formatter` property within the `conversions` map instead. +This option will be removed in a future major version. diff --git a/package.json b/package.json index 7629dc5..4ec7830 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "import": "./src/client/index.tsx", "types": "./src/client/index.tsx" }, + "./client/plugins": { + "solid": "./src/client/plugins.ts", + "import": "./src/client/plugins.ts", + "types": "./src/client/plugins.ts" + }, "./server": { "solid": "./src/server.ts", "import": "./src/server.ts", diff --git a/src/config/ec-plugins/language-switcher/ts-to-js.ts b/src/config/ec-plugins/language-switcher/converter.ts similarity index 91% rename from src/config/ec-plugins/language-switcher/ts-to-js.ts rename to src/config/ec-plugins/language-switcher/converter.ts index 82626d0..19bae81 100644 --- a/src/config/ec-plugins/language-switcher/ts-to-js.ts +++ b/src/config/ec-plugins/language-switcher/converter.ts @@ -385,25 +385,44 @@ export type Marker = { }; export const MarkerTypeOrder: Array = ["mark", "del", "ins"]; -export async function tsToJs( - tsCode: string, - tsMarkers: Array, - isJsx?: boolean, - formatter?: (jsCode: string, isJsx?: boolean) => string | Promise, -): Promise<{ - jsCode: string; - markers: Array; -}> { +export type Formatter = (code: string) => string | Promise; + +/** + * A function that transforms code and line markers from a source language to a target language. + */ +export type Converter = (context: { + sourceCode: string; + sourceMarkers: Array; + sourceLanguage: string; + formatter?: Formatter; +}) => Promise<{ + targetCode: string; + targetMarkers: Array; +}>; + +export async function defaultFormatter(jsCode: string) { + return await prettier.format(jsCode, { + parser: "babel", + }); +} + +export const defaultConverter: Converter = async ({ + sourceCode, + sourceMarkers, + sourceLanguage, + formatter, +}) => { + const isJsx = ["tsx", "jsx"].includes(sourceLanguage); const fileName = isJsx ? "temp.tsx" : "temp.ts"; const ast = ts.createSourceFile( fileName, - tsCode, + sourceCode, ts.ScriptTarget.Latest, true, isJsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); - const ms = new MagicString(tsCode); + const ms = new MagicString(sourceCode); removeTripleSlashDirectives(ms, ast); @@ -418,18 +437,9 @@ export async function tsToJs( const unformattedCode = ms.toString(); - let formattedCode = unformattedCode; - try { - if (formatter) { - formattedCode = await formatter(unformattedCode, isJsx); - } else { - formattedCode = await prettier.format(unformattedCode, { - parser: "babel", - }); - } - } catch (error) { - console.error("Error during post-processing JavaScript code:", error); - } + const formattedCode = formatter + ? await formatter(unformattedCode) + : unformattedCode; const changes = diffChars(unformattedCode, formattedCode); const formatMs = new MagicString(unformattedCode); @@ -490,10 +500,10 @@ export async function tsToJs( } }); - const jsMarkers: Array = []; - for (const tsMarker of tsMarkers) { + const targetMarkers: Array = []; + for (const sourceMarker of sourceMarkers) { const jsMarkerLines = new Set(); - for (const tsLine of tsMarker.lines) { + for (const tsLine of sourceMarker.lines) { const rawLines = tsToJsMap.get(tsLine); if (rawLines) { for (const rawLine of rawLines) { @@ -506,14 +516,14 @@ export async function tsToJs( } } } - jsMarkers.push({ - ...tsMarker, + targetMarkers.push({ + ...sourceMarker, lines: [...jsMarkerLines].sort((a, b) => a - b), }); } return { - jsCode: formattedCode, - markers: jsMarkers, + targetCode: formattedCode, + targetMarkers: targetMarkers, }; -} +}; diff --git a/src/config/ec-plugins/language-switcher/index.ts b/src/config/ec-plugins/language-switcher/index.ts index c659823..86bb9b4 100644 --- a/src/config/ec-plugins/language-switcher/index.ts +++ b/src/config/ec-plugins/language-switcher/index.ts @@ -13,13 +13,18 @@ import { } from "expressive-code/hast"; import rangeParser from "parse-numeric-range"; import { + type Converter, + type Formatter, type Marker, type MarkerType, MarkerTypeOrder, - tsToJs, -} from "./ts-to-js.js"; - -const SUPPORTED_LANGS = ["ts", "typescript", "tsx"]; + defaultConverter, + defaultFormatter, +} from "./converter.js"; +import { + getLanguageSwitcherBaseStyles, + languageSwitcherStyleSettings, +} from "./styles.js"; function getMarkerType(input: string) { let normalized = input; @@ -34,19 +39,6 @@ function getMarkerType(input: string) { return MarkerTypeOrder.includes(markerType) ? markerType : undefined; } -function toJsLanguage(lang: string) { - if (lang === "typescript") { - return "javascript"; - } - if (lang === "ts") { - return "js"; - } - if (lang === "tsx") { - return "jsx"; - } - return lang; -} - function addToggleToHeader(header: Element) { visit(header, "element", (node, index, parent) => { if (node.tagName === "figure") { @@ -75,20 +67,153 @@ function addToggleToHeader(header: Element) { }); } +export interface ConversionRule { + /** + * The target language ID to use for syntax highlighting after conversion. + * If not provided, the source language ID is used. + * @example "jsx" + */ + to?: string; + /** + * A map of source file extensions to their target extensions for this language. + * @example { ".tsx": ".jsx", ".ts": ".js" } + */ + fileExtensionMap: Record<`.${string}`, `.${string}`>; + /** + * An optional, custom converter for this language. + * If not provided, the default TypeScript-to-JavaScript converter is used. + * + * **Important**: The converter is responsible for calling the `formatter` on the + * transformed code before returning the final result. This is crucial for + * ensuring that line markers are mapped correctly after formatting. + */ + converter: Converter; + /** + * An optional, custom formatter for this language's output. + * If not provided, a default Prettier-based formatter is used. + * This function is passed to the `converter`. + */ + formatter?: Formatter; +} + +const defaultConversions: Record = { + typescript: { + to: "javascript", + fileExtensionMap: { ".ts": ".js" }, + converter: defaultConverter, + formatter: defaultFormatter, + }, + ts: { + to: "js", + fileExtensionMap: { ".ts": ".js" }, + converter: defaultConverter, + formatter: defaultFormatter, + }, + tsx: { + to: "jsx", + fileExtensionMap: { ".tsx": ".jsx" }, + converter: defaultConverter, + formatter: defaultFormatter, + }, +}; + +/** + * Resolves the effective conversion rules by merging default conversions with user-provided overrides. + * + * @param userConversions - An optional record of conversion rules or `false` to disable a default rule. + * @returns The final conversion rules to be used by the plugin. + */ +export function getEffectiveConversions( + userConversions?: Record, +) { + const effectiveConversions = { ...defaultConversions }; + if (userConversions) { + for (const lang in userConversions) { + const rule = userConversions[lang]; + if (rule === false) { + delete effectiveConversions[lang]; + } else if (rule) { + effectiveConversions[lang] = { ...effectiveConversions[lang], ...rule }; + } + } + } + + return effectiveConversions; +} + +/** + * Converts file extensions in a filename based on language switcher conversion rules. + */ +export function convertFileExtension( + filename: string, + conversions: Record, +): string { + const fileExtensionMap: Record<`.${string}`, `.${string}`> = {}; + for (const [lang, rule] of Object.entries(conversions)) { + if (rule && typeof rule === "object" && rule.fileExtensionMap) { + Object.assign(fileExtensionMap, rule.fileExtensionMap); + } + } + + return filename.replace(/\.[^.]+$/, (ext) => { + const foundExt = fileExtensionMap[ext as `.${string}`]?.trim(); + return foundExt || ext; + }); +} + /** * Options for the Language switcher plugin. */ -export interface LanguageSwitcherOptions { +export interface EcPluginLanguageSwitcherOptions { /** * Whether to show the toggle button in the code block header. * @default true */ showToggleButton?: boolean; + /** + * A map defining language conversion rules. The key is the source language ID. + * + * This allows you to extend the default conversions (e.g., add Svelte) + * or override them (e.g., change the formatter for TypeScript). + * + * To disable a default conversion (e.g., for "typescript"), set its value to `false`. + * + * @example + * { + * // Override default formatter for typescript + * "typescript": { + * to: "javascript", + * fileExtensionMap: { ".ts": ".js" }, + * formatter: (code) => myFormat(code) + * }, + * // Add a new conversion for Svelte + * "svelte": { + * to: "javascript", + * fileExtensionMap: { ".svelte": ".js" }, + * converter: svelteConverter, + * }, + * // Disable the default "tsx" conversion + * "tsx": false + * } + */ + conversions?: Record; /** * A function to format the generated JavaScript code. - * Defaults to formatting with Prettier with default options. + * Defaults to formatting with Prettier. + * @deprecated Use the `formatter` property within the `conversions` map instead. + * This option will be removed in a future major version. + */ + formatter?: Formatter; + /** + * The label for the TypeScript toggle button. + * @default "TS" */ - formatter?: (code: string) => string | Promise; + toggleButtonTsLabel?: string; + /** + * The label for the JavaScript toggle button. + * @default "JS" + */ + toggleButtonJsLabel?: string; } /** @@ -103,12 +228,36 @@ export interface LanguageSwitcherOptions { * @param options - Configuration options for the plugin * @returns An Expressive Code plugin */ -export function ecPluginLanguageSwitcher(options: LanguageSwitcherOptions) { +export function ecPluginLanguageSwitcher( + options?: EcPluginLanguageSwitcherOptions, +) { + const effectiveOptions = options ?? {}; + effectiveOptions.showToggleButton ??= true; + effectiveOptions.toggleButtonTsLabel ??= "TS"; + effectiveOptions.toggleButtonJsLabel ??= "JS"; + + const conversions = getEffectiveConversions(effectiveOptions.conversions); + + // Handle deprecated formatter for backward compatibility + if (effectiveOptions.formatter) { + for (const lang in conversions) { + const rule = conversions[lang] as ConversionRule; + // Only apply if a specific formatter isn't already set + if (!rule.formatter) { + rule.formatter = effectiveOptions.formatter; + } + } + } + return definePlugin({ name: "Language switcher", + styleSettings: languageSwitcherStyleSettings, + baseStyles: (context) => + getLanguageSwitcherBaseStyles(context, effectiveOptions), hooks: { postprocessRenderedBlock: async ({ renderData, codeBlock, config }) => { - if (!SUPPORTED_LANGS.includes(codeBlock.language)) { + const rule = conversions[codeBlock.language]; + if (!rule) { return; } @@ -119,60 +268,53 @@ export function ecPluginLanguageSwitcher(options: LanguageSwitcherOptions) { } if ( - options.showToggleButton && + effectiveOptions.showToggleButton && metaOptions.getString("frame") !== "none" ) { addToggleToHeader(renderData.blockAst); } - let jsMeta = ""; + let targetMeta = ""; if (codeBlock.props.title) { - const newTitle = codeBlock.props.title.replace(/\.tsx?$/, (ext) => { - if (ext === ".tsx") { - return ".jsx"; - } - if (ext === ".ts") { - return ".js"; - } - return ext; - }); - jsMeta += ` title="${newTitle}"`; + const newTitle = convertFileExtension( + codeBlock.props.title, + conversions, + ); + targetMeta += ` title="${newTitle}"`; } - const markers: Array = []; + const sourceMarkers: Array = []; for (const { key, value, raw, kind } of metaOptions.list()) { if (kind === "range") { const type = getMarkerType(key ?? "mark"); if (type && typeof value === "string") { - markers.push({ + sourceMarkers.push({ type, lines: rangeParser(value), }); } } else if (key !== "title") { - jsMeta += ` ${raw}`; + targetMeta += ` ${raw}`; } } - const isJsx = codeBlock.language === "tsx"; - - const { jsCode, markers: jsMarkers } = await tsToJs( - codeBlock.code, - markers, - isJsx, - options.formatter, - ); + const { targetCode, targetMarkers } = await rule.converter({ + sourceCode: codeBlock.code, + sourceMarkers, + sourceLanguage: codeBlock.language, + formatter: rule.formatter, + }); - for (const { type, lines } of jsMarkers) { + for (const { type, lines } of targetMarkers) { const start = Math.min(...lines); const end = Math.max(...lines); if (start === end) { - jsMeta += ` ${type}={${start}}`; + targetMeta += ` ${type}={${start}}`; } else if (end - start + 1 === lines.length) { - jsMeta += ` ${type}={${start}-${end}}`; + targetMeta += ` ${type}={${start}-${end}}`; } else { - jsMeta += ` ${type}={${lines.join(",")}}`; + targetMeta += ` ${type}={${lines.join(",")}}`; } } @@ -181,17 +323,19 @@ export function ecPluginLanguageSwitcher(options: LanguageSwitcherOptions) { plugins: config.plugins.filter((v) => v.name !== "Language switcher"), }); - const jsBlock = new ExpressiveCodeBlock({ - code: jsCode, - language: toJsLanguage(codeBlock.language), + const targetLanguage = rule.to ?? codeBlock.language; + + const targetBlock = new ExpressiveCodeBlock({ + code: targetCode, + language: targetLanguage, locale: codeBlock.locale, - meta: jsMeta, + meta: targetMeta, }); - const renderedJsBlock = await engine.render(jsBlock); + const renderedJsBlock = await engine.render(targetBlock); if ( - options.showToggleButton && + effectiveOptions.showToggleButton && metaOptions.getString("frame") !== "none" ) { addToggleToHeader(renderedJsBlock.renderedGroupAst); diff --git a/src/config/ec-plugins/language-switcher/styles.ts b/src/config/ec-plugins/language-switcher/styles.ts new file mode 100644 index 0000000..d559e04 --- /dev/null +++ b/src/config/ec-plugins/language-switcher/styles.ts @@ -0,0 +1,155 @@ +import { + PluginStyleSettings, + type ResolverContext, +} from "@expressive-code/core"; +import type { EcPluginLanguageSwitcherOptions } from "./index.js"; + +export type LanguageSwitcherStyleSettings = { + /** + * The background color of the language switcher toggle button. + * @default "transparent" + */ + toggleButtonBackground: string; + /** + * The background color of the language switcher toggle button when hovered. + * @default + * ({ theme }) => (theme.type === "dark" ? "#27272a" : "#f6f6f7") + */ + toggleButtonBackgroundHover: string; + /** + * The foreground color (e.g., text and icons) of the language switcher toggle button. + * @default + * ({ theme }) => theme.colors["tab.activeForeground"] + */ + toggleButtonForeground: string; + /** + * The opacity of the active language label in the toggle button. + * @default "1" + */ + toggleButtonOpacityActive: string; + /** + * The opacity of the inactive language label in the toggle button. + * @default "0.4" + */ + toggleButtonOpacityInactive: string; + /** + * The border of the language switcher toggle button. + * @default "1px solid transparent" + */ + toggleButtonBorder: string; + /** + * The border radius of the language switcher toggle button. + * @default "0.2rem" + */ + toggleButtonBorderRadius: string; +}; + +declare module "@expressive-code/core" { + export interface StyleSettings { + languageSwitcher: LanguageSwitcherStyleSettings; + } +} + +export const languageSwitcherStyleSettings = new PluginStyleSettings({ + defaultValues: { + languageSwitcher: { + toggleButtonBackground: "transparent", + toggleButtonBackgroundHover: ({ theme }) => + theme.type === "dark" ? "#27272a" : "#f6f6f7", + toggleButtonForeground: ({ theme }) => + theme.colors["tab.activeForeground"], + toggleButtonOpacityActive: "1", + toggleButtonOpacityInactive: "0.4", + toggleButtonBorder: "1px solid transparent", + toggleButtonBorderRadius: "0.2rem", + }, + }, +}); + +export function getLanguageSwitcherBaseStyles( + { cssVar }: ResolverContext, + options: EcPluginLanguageSwitcherOptions, +) { + const baseStyles = ` + html[data-preferred-language="ts"] .sb-language-group { + figure:last-of-type { + display: none; + } + + pre + pre { + display: none; + } + } + + html[data-preferred-language="js"] .sb-language-group { + figure:first-of-type { + display: none; + } + + pre:has(+ pre) { + display: none; + } + } + `.trim(); + + const toggleButtonStyles = ` + .sb-ts-js-toggle, + /* Expressive code hides all non-svg elements inside the header, so we need this selector to override it. */ + .expressive-code + html .sb-ts-js-toggle { + appearance: none; + min-height: 2rem; + display: flex; + align-items: center; + gap: 0.5rem; + background: ${cssVar("languageSwitcher.toggleButtonBackground")}; + color: ${cssVar("languageSwitcher.toggleButtonForeground")}; + padding: 0 0.6rem; + margin-left: auto; + border-radius: ${cssVar("languageSwitcher.toggleButtonBorderRadius")}; + border: ${cssVar("languageSwitcher.toggleButtonBorder")}; + font-family: var(--sb-font-mono); + font-size: 0.9rem; + + &:hover { + background: ${cssVar("languageSwitcher.toggleButtonBackgroundHover")}; + } + + &::before, + &::after { + min-width: 1.5rem; + display: flex; + justify-content: center; + align-items: center; + color: inherit; + } + + &::before { + content: "${options.toggleButtonJsLabel}"; + opacity: ${cssVar("languageSwitcher.toggleButtonOpacityActive")}; + } + + &::after { + content: "${options.toggleButtonTsLabel}"; + opacity: ${cssVar("languageSwitcher.toggleButtonOpacityInactive")}; + } + + &:checked { + &::before { + opacity: ${cssVar("languageSwitcher.toggleButtonOpacityInactive")}; + } + + &::after { + opacity: ${cssVar("languageSwitcher.toggleButtonOpacityActive")}; + } + } + } + `.trim(); + + const styles = [ + baseStyles, + options.showToggleButton ? toggleButtonStyles : "", + ]; + + return styles.join("\n"); +} diff --git a/src/config/mdx.ts b/src/config/mdx.ts index 05562da..1f31026 100644 --- a/src/config/mdx.ts +++ b/src/config/mdx.ts @@ -20,7 +20,7 @@ import type { PluginOption } from "vite"; import mdx from "../vite-mdx/index.js"; import { - type LanguageSwitcherOptions, + type EcPluginLanguageSwitcherOptions, ecPluginLanguageSwitcher, } from "./ec-plugins/language-switcher/index.js"; import type { SolidBaseResolvedConfig } from "./index.js"; @@ -51,7 +51,7 @@ export interface MdxOptions { expressiveCode?: | (RehypeExpressiveCodeOptions & { twoSlash?: TwoslashOptions | true; - languageSwitcher?: LanguageSwitcherOptions | true; + languageSwitcher?: EcPluginLanguageSwitcherOptions | true; }) | false; toc?: TOCOptions | false; @@ -121,11 +121,8 @@ function getRehypePlugins(sbConfig: SolidBaseResolvedConfig) { if (sbConfig.markdown?.expressiveCode?.languageSwitcher) { const config = sbConfig.markdown.expressiveCode.languageSwitcher === true - ? ({ showToggleButton: true } satisfies LanguageSwitcherOptions) - : ({ - showToggleButton: true, - ...sbConfig.markdown.expressiveCode?.languageSwitcher, - } satisfies LanguageSwitcherOptions); + ? {} + : sbConfig.markdown.expressiveCode?.languageSwitcher; plugins.push(ecPluginLanguageSwitcher(config)); } @@ -196,7 +193,10 @@ function getRemarkPlugins(sbConfig: SolidBaseResolvedConfig) { }, ], [remarkPackageManagerTabs, sbConfig.markdown?.packageManagers ?? {}], - remarkTabGroup, + [ + remarkTabGroup, + languageSwitcherConfig === true ? {} : languageSwitcherConfig, + ], remarkDirective, remarkRelativeImports, ); diff --git a/src/config/plugins.ts b/src/config/plugins.ts new file mode 100644 index 0000000..0a22d38 --- /dev/null +++ b/src/config/plugins.ts @@ -0,0 +1 @@ +export * from "./ec-plugins/language-switcher/index.js"; diff --git a/src/config/remark-plugins/tab-group.ts b/src/config/remark-plugins/tab-group.ts index c504768..5038e0c 100644 --- a/src/config/remark-plugins/tab-group.ts +++ b/src/config/remark-plugins/tab-group.ts @@ -1,7 +1,17 @@ -import { SKIP, visit } from "unist-util-visit"; +import { visit } from "unist-util-visit"; +import { + type EcPluginLanguageSwitcherOptions, + convertFileExtension, + getEffectiveConversions, +} from "../ec-plugins/language-switcher/index.js"; -export function remarkTabGroup() { +export interface RemarkTabGroupOptions + extends EcPluginLanguageSwitcherOptions {} + +export function remarkTabGroup(options?: RemarkTabGroupOptions) { return (tree: any) => { + const conversions = getEffectiveConversions(options?.conversions); + visit(tree, (node, index, parent) => { if (node.type !== "containerDirective" || node.name !== "tab-group") return; @@ -36,6 +46,7 @@ export function remarkTabGroup() { node.attributes = { ...node.attributes, tabNames: tabNames.join("\0"), + languageSwitcherConversions: JSON.stringify(conversions), }; }); }; diff --git a/src/default-theme/index.css b/src/default-theme/index.css index 1033fb5..e2fd950 100644 --- a/src/default-theme/index.css +++ b/src/default-theme/index.css @@ -92,72 +92,3 @@ article *[id] { } } } - -.sb-ts-js-toggle, -/* Expressive code hides all non-svg elements inside the header, so we need this selector to override it. */ - .expressive-code - .sb-ts-js-toggle { - appearance: none; - display: flex; - align-items: center; - height: 2rem; - outline-offset: 0; - padding: 0 0.6rem; - font-family: var(--sb-font-mono); - border-radius: var(--sb-border-radius); - margin-left: auto; - - &:hover { - background: var(--ts-js-toggle-background-hover); - } - - &::before, - &::after { - width: 1.5rem; - display: flex; - justify-content: center; - align-items: center; - font-size: 0.9rem; - color: inherit; - } - - &::before { - content: "JS"; - } - - &::after { - content: "TS"; - border-left: none; - opacity: 0.4; - } - - &:checked { - &::before { - opacity: 0.4; - } - - &::after { - opacity: 1; - } - } -} - -html[data-preferred-language="ts"] .sb-language-group { - figure:last-of-type { - display: none; - } - - pre + pre { - display: none; - } -} - -html[data-preferred-language="js"] .sb-language-group { - figure:first-of-type { - display: none; - } - - pre:has(+ pre) { - display: none; - } -} diff --git a/src/default-theme/mdx-components.tsx b/src/default-theme/mdx-components.tsx index a271afd..8851f79 100644 --- a/src/default-theme/mdx-components.tsx +++ b/src/default-theme/mdx-components.tsx @@ -16,6 +16,10 @@ import { splitProps, } from "solid-js"; import { usePreferredLanguage } from "../client/preferred-language"; +import { + type ConversionRule, + convertFileExtension, +} from "../config/ec-plugins/language-switcher"; import styles from "./mdx-components.module.css"; export function h1(props: ComponentProps<"h1">) { @@ -112,6 +116,7 @@ export function DirectiveContainer( codeGroup?: string; tabNames?: string; withTsJsToggle?: string; + languageSwitcherConversions?: string; } & ParentProps, ) { const _children = children(() => props.children).toArray(); @@ -132,15 +137,16 @@ export function DirectiveContainer( > {tabNames?.map((title) => { - const jsTitle = title.replace(/\.tsx?$/, (ext) => { - if (ext === ".tsx") { - return ".jsx"; - } - if (ext === ".ts") { - return ".js"; - } - return ext; - }); + let conversions: Record = {}; + try { + conversions = props.languageSwitcherConversions + ? JSON.parse(props.languageSwitcherConversions) + : {}; + } catch (e) { + conversions = {}; + } + + const jsTitle = convertFileExtension(title, conversions); return ( diff --git a/src/default-theme/variables.css b/src/default-theme/variables.css index 1fe9df9..4bbcfb3 100644 --- a/src/default-theme/variables.css +++ b/src/default-theme/variables.css @@ -52,8 +52,6 @@ html, ); --bprogress-color: var(--sb-active-link-color); - - --ts-js-toggle-background-hover: #f6f6f7; } [data-theme*="dark"] { @@ -84,6 +82,4 @@ html, --sb-danger-background-color: hsl(356, 38%, 16%); --sb-danger-text-color: hsl(0, 91%, 71%); - - --ts-js-toggle-background-hover: #27272a; } From 046ad7db16a8d46dd8db440ea90a6e86191a928b Mon Sep 17 00:00:00 2001 From: "Amir H. Hashemi" <87268103+amirhhashemi@users.noreply.github.com> Date: Sat, 11 Oct 2025 09:21:23 +0330 Subject: [PATCH 2/3] Fix exports path --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 4ec7830..2bee3f2 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "import": "./src/client/index.tsx", "types": "./src/client/index.tsx" }, - "./client/plugins": { - "solid": "./src/client/plugins.ts", - "import": "./src/client/plugins.ts", - "types": "./src/client/plugins.ts" + "./config/plugins": { + "solid": "./src/config/plugins.ts", + "import": "./src/config/plugins.ts", + "types": "./src/config/plugins.ts" }, "./server": { "solid": "./src/server.ts", From 5b675755fbe009f9dc5d9bdd6e0687b0f5305de6 Mon Sep 17 00:00:00 2001 From: "Amir H. Hashemi" <87268103+amirhhashemi@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:42:51 +0330 Subject: [PATCH 3/3] Bring the global formatter option back --- docs/src/routes/guide/language-switcher.mdx | 11 ++++++----- src/config/ec-plugins/language-switcher/index.ts | 7 ++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/src/routes/guide/language-switcher.mdx b/docs/src/routes/guide/language-switcher.mdx index 2808082..8833ac1 100644 --- a/docs/src/routes/guide/language-switcher.mdx +++ b/docs/src/routes/guide/language-switcher.mdx @@ -135,12 +135,13 @@ The plugin automatically preserves line markers. - `fileExtensionMap: Record`: A map of source file extensions to their target extensions for this language (e.g., `{ ".ts": ".js" }`). This is used to update the filename in the code block title. - `converter: Converter`: A converter for this language. - - `formatter?: Formatter`: An optional formatter for this language's output. + - `formatter?: Formatter`: An optional formatter for this language's generated code. + This will override the top-level `formatter` option if provided. ### `formatter` -- **Type:** `(jsCode: string, isJsx; boolean) => string | Promise` +- **Type:** `(jsCode: string, isJsx: boolean) => string | Promise` - **Default:** Formatting with Prettier with default options -- **Description:** A function to format the generated JavaScript code. -- **Deprecated:** Use the `formatter` property within the `conversions` map instead. -This option will be removed in a future major version. +- **Description:** A function to format the generated code. + This acts as the default formatter. + It can be overridden by the `formatter` property within the `conversions` map. diff --git a/src/config/ec-plugins/language-switcher/index.ts b/src/config/ec-plugins/language-switcher/index.ts index 86bb9b4..38471e8 100644 --- a/src/config/ec-plugins/language-switcher/index.ts +++ b/src/config/ec-plugins/language-switcher/index.ts @@ -90,7 +90,7 @@ export interface ConversionRule { converter: Converter; /** * An optional, custom formatter for this language's output. - * If not provided, a default Prettier-based formatter is used. + * If not provided, the top-level `formatter` will be used. * This function is passed to the `converter`. */ formatter?: Formatter; @@ -200,8 +200,7 @@ export interface EcPluginLanguageSwitcherOptions { /** * A function to format the generated JavaScript code. * Defaults to formatting with Prettier. - * @deprecated Use the `formatter` property within the `conversions` map instead. - * This option will be removed in a future major version. + * This can be overridden by the `formatter` property in a `ConversionRule`. */ formatter?: Formatter; /** @@ -238,11 +237,9 @@ export function ecPluginLanguageSwitcher( const conversions = getEffectiveConversions(effectiveOptions.conversions); - // Handle deprecated formatter for backward compatibility if (effectiveOptions.formatter) { for (const lang in conversions) { const rule = conversions[lang] as ConversionRule; - // Only apply if a specific formatter isn't already set if (!rule.formatter) { rule.formatter = effectiveOptions.formatter; }