From 13c78a116da5c5f481d211177603362c398ebfcc Mon Sep 17 00:00:00 2001 From: dav-is Date: Thu, 24 Sep 2026 16:29:12 -0400 Subject: [PATCH 1/6] Fix bugs encountered when integrating with @fairgarden/design --- .../demo-init/index.ts | 9 + .../demo-init/page.tsx | 9 + .../demo-loading/index.ts | 8 + .../demo-loading/page.tsx | 9 + .../demo-plain/index.ts | 8 + .../demo-plain/page.tsx | 9 + .../inline-count/Counter.tsx | 12 ++ .../output-count/Counter.tsx | 15 ++ .../demos/demo-variants-transform/test.ts | 55 ++++++ docs/app/lib/hooks/use-code/page.mdx | 8 +- docs/app/lib/hooks/use-code/types.md | 8 + docs/app/lib/hooks/use-demo/types.md | 74 ++++---- .../CodeHighlighter/CodeHighlighterClient.tsx | 35 +++- .../CodeHighlighterContext.tsx | 8 + .../docs/src/CodeHighlighter/user.spec.tsx | 65 +++++++ .../parseImportsAndComments.test.ts | 173 ++++++++++++++++++ .../loaderUtils/parseImportsAndComments.ts | 70 ++++--- .../docs/src/useCode/SourceEditingEngine.ts | 5 +- .../docs/src/useCode/TransformEngine.test.ts | 102 +++++++++++ packages/docs/src/useCode/TransformEngine.ts | 12 +- packages/docs/src/useCode/useCode.ts | 26 ++- .../docs/src/useCode/useCopyFunctionality.ts | 16 +- packages/docs/src/useCode/useSourceEditing.ts | 11 +- .../src/useCode/useTransformManagement.ts | 16 +- packages/docs/src/useCode/user.spec.ts | 169 ++++++++++++++++- 25 files changed, 842 insertions(+), 90 deletions(-) create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts new file mode 100644 index 0000000..36548ba --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/index.ts @@ -0,0 +1,9 @@ +import { Counter as InlineCount } from '../inline-count/Counter'; +import { Counter as OutputCount } from '../output-count/Counter'; +import { createDemoWithVariants } from '../../createDemo'; + +export const DemoCounterInit = createDemoWithVariants( + import.meta.url, + { InlineCount, OutputCount }, + { highlightAfter: 'init' }, +); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx new file mode 100644 index 0000000..4661a38 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-init/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterInit } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts new file mode 100644 index 0000000..5d5d365 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/index.ts @@ -0,0 +1,8 @@ +import { Counter as InlineCount } from '../inline-count/Counter'; +import { Counter as OutputCount } from '../output-count/Counter'; +import { createDemoWithVariants } from '../../demo-fallback/createDemo'; + +export const DemoCounterLoading = createDemoWithVariants(import.meta.url, { + InlineCount, + OutputCount, +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx new file mode 100644 index 0000000..cfee15e --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-loading/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterLoading } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts new file mode 100644 index 0000000..2844860 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/index.ts @@ -0,0 +1,8 @@ +import { Counter as InlineCount } from '../inline-count/Counter'; +import { Counter as OutputCount } from '../output-count/Counter'; +import { createDemoWithVariants } from '../../createDemo'; + +export const DemoCounterPlain = createDemoWithVariants(import.meta.url, { + InlineCount, + OutputCount, +}); diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx new file mode 100644 index 0000000..2ff06a5 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/demo-plain/page.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { DemoCounterPlain } from '.'; + +export default function Page() { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx new file mode 100644 index 0000000..c145841 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/inline-count/Counter.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +interface CounterProps { + count?: number; +} + +export function Counter({ count = 0 }: CounterProps) { + return ( + // @focus + + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx new file mode 100644 index 0000000..5a19507 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/output-count/Counter.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +interface CounterProps { + count?: number; +} + +export function Counter({ count = 0 }: CounterProps) { + return ( + // @focus-start +

+ Clicked {count} times +

+ // @focus-end + ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts new file mode 100644 index 0000000..131d4fd --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-transform/test.ts @@ -0,0 +1,55 @@ +import path from 'node:path'; +import { test, expect } from '@playwright/test'; + +// The standalone demo route, derived from this file's location under `app`. +const route = path + .dirname(import.meta.filename) + .split('/app') + .pop()!; + +// Both variants name their file `Counter.tsx`, and each production payload is +// compressed with its own file's text as the DEFLATE dictionary. Switching +// variants with the JS transform applied must decode the incoming variant with +// ITS dictionary, not the same-named file's from the initial variant. +const setups = [ + { name: 'demo-plain', description: 'without a ContentLoading' }, + { name: 'demo-init', description: "with highlightAfter: 'init' and no ContentLoading" }, + { name: 'demo-loading', description: 'with a ContentLoading' }, +]; + +for (const { name, description } of setups) { + test(`keeps the JS transform when switching between same-named variant files ${description}`, async ({ + page, + }) => { + const pageErrors: Error[] = []; + const consoleErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(`${route}/${name}`); + const code = page.locator('pre').first(); + + await expect(code).toContainText('interface CounterProps', { timeout: 15000 }); + + await page.getByRole('button', { name: 'JS' }).click(); + await expect(page.getByText('Counter.jsx')).toBeVisible({ timeout: 15000 }); + await expect(code).not.toContainText('interface CounterProps'); + + await page.getByText('InlineCount', { exact: true }).click(); + await page.getByRole('option', { name: 'OutputCount' }).click(); + + // The incoming variant renders, still in JavaScript. + await expect(code).toContainText('', { timeout: 15000 }); + await expect(code).not.toContainText('interface CounterProps'); + await expect(page.getByText('Counter.jsx')).toBeVisible(); + + expect( + consoleErrors.filter((text) => /dictionary mismatch|Transform failed/i.test(text)), + ).toEqual([]); + expect(pageErrors, 'the demo should switch variants without uncaught errors').toEqual([]); + }); +} diff --git a/docs/app/lib/hooks/use-code/page.mdx b/docs/app/lib/hooks/use-code/page.mdx index 57cebf6..41b3f41 100644 --- a/docs/app/lib/hooks/use-code/page.mdx +++ b/docs/app/lib/hooks/use-code/page.mdx @@ -216,8 +216,12 @@ function CopyControls(props) { Both are wired through [`useCopier`](../use-copier/page.mdx); pass the `copy` option (`UseCopierOpts`) to configure success-state timing and callbacks for -both. Compressed sources are decoded back to text on demand, so neither copy -forces an early decompression of the block. +both. Each also exposes its copier's `recentlySuccessful` flag, as +`copyRecentlySuccessful` and `copyMarkdownRecentlySuccessful`: `true` for the +`copy.timeout` (2000 ms by default) after a copy succeeds. They're meant for +driving a toast or other notification that confirms the copy. Compressed sources +are decoded back to text on demand, so neither copy forces an early +decompression of the block. > [!NOTE] > For lists and standalone chunks, the lower-level diff --git a/docs/app/lib/hooks/use-code/types.md b/docs/app/lib/hooks/use-code/types.md index 754ccad..f20a245 100644 --- a/docs/app/lib/hooks/use-code/types.md +++ b/docs/app/lib/hooks/use-code/types.md @@ -208,11 +208,19 @@ type UseCodeResult = { expand: () => void; setExpanded: (expanded: boolean) => void; copy: (event: React.MouseEvent) => Promise; + /** + * `true` for a moment after `copy` succeeds — for the `copy.timeout` option, + * 2000 ms by default — to drive a toast or other notification confirming the + * copy. The `recentlySuccessful` of the `useCopier` behind `copy`. + */ + copyRecentlySuccessful: boolean; /** * Copies all files in the current variant to the clipboard as a Markdown * snippet (heading + per-file fenced code blocks). */ copyMarkdown: (event: React.MouseEvent) => Promise; + /** Like `copyRecentlySuccessful`, for `copyMarkdown`. */ + copyMarkdownRecentlySuccessful: boolean; availableTransforms: string[]; selectedTransform: string | null | undefined; selectTransform: (transformName: string | null) => void; diff --git a/docs/app/lib/hooks/use-demo/types.md b/docs/app/lib/hooks/use-demo/types.md index 7c4d8bd..17090cd 100644 --- a/docs/app/lib/hooks/use-demo/types.md +++ b/docs/app/lib/hooks/use-demo/types.md @@ -218,42 +218,44 @@ type ReturnValue = void; **useDemo Return Value:** -| Property | Type | Description | -| :------------------ | :------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| component | `string \| number \| bigint \| true \| ReactElement \| Iterable \| Promise \| null` | - | -| error | `string \| null` | - | -| focusRef | `React.RefObject` | - | -| resetFocus | `(() => void)` | - | -| openStackBlitz | `(() => void)` | - | -| openCodeSandbox | `(() => void)` | - | -| name | `string \| undefined` | - | -| slug | `string \| undefined` | - | -| variants | `string[]` | - | -| selectedVariant | `string` | - | -| selectVariant | `((variant: string \| null) => void)` | - | -| files | `({ name: string; slug?: string; component: React.ReactNode })[]` | - | -| selectedFile | `React.ReactNode` | - | -| selectedFileLines | `number` | - | -| selectedFileName | `string \| undefined` | - | -| selectedFileUrl | `string \| undefined` | URL of the currently selected file, derived from the selected variant's `url`, the file's name, and its `relativeUrl` (when set). `undefined` when the variant has no `url` or the URL cannot be resolved. | -| selectedFileSlug | `string \| undefined` | Slug for the currently selected file. Always derived from the canonical (original) file name — transforms are a view preference and do not produce separate slugs. Useful for building permalinks (e.g. `#${slug}`) that survive transform changes. | -| selectFileName | `((fileName: string) => void)` | - | -| allFilesSlugs | `({ fileName: string; slug: string; variantName: string })[]` | - | -| expanded | `boolean` | - | -| expand | `(() => void)` | - | -| setExpanded | `((expanded: boolean) => void)` | - | -| copy | `((event: React.MouseEvent) => Promise)` | - | -| copyMarkdown | `((event: React.MouseEvent) => Promise)` | Copies all files in the current variant to the clipboard as a Markdown snippet (heading + per-file fenced code blocks). | -| availableTransforms | `string[]` | - | -| selectedTransform | `string \| null \| undefined` | - | -| selectTransform | `((transformName: string \| null) => void)` | - | -| pendingTransform | `string \| null \| undefined` | Target of an in-flight transform swap that is still waiting on slow peers to catch up. `undefined` when no swap is pending or shortly after one commits. Otherwise mirrors the shape of `selectedTransform`: `null` for a pending swap back to the un-transformed original, or the transform name for a pending swap to that transform. Consumers can check `pendingTransform !== undefined` to render a generic loading indicator, or read the value to render something like `` `Switching to ${pendingTransform ?? 'original'}…` ``. Only populated on the demo that originated the change — peer demos receiving the broadcast keep this `undefined` so the indicator stays anchored to the demo the user interacted with. | -| editable | `boolean` | Whether edit mode is currently on. `true` by default; starts `false` when the block opts in via `initialDisabled` — per demo through the `createDemo` `meta`, or across a factory's demos through `createDemoFactory`. While `false` the block renders read-only and the live-editing engine is not even warmed; flip it with `setEditable`. Independent of `editActivation`, which governs _when_ the engine loads once editing is on, and of `disabled`, which turns editing off permanently. | -| setEditable | `((editable: boolean) => void) \| undefined` | Turns edit mode on or off. `undefined` when the block can't be edited at all — no `CodeControllerContext` with `setCode` in scope, or editing is hard-`disabled` — so a toggle button can render only when this is defined. Toggling off keeps the reader's edits (use `reset` to discard them). | -| setSource | `((source: string, fileName?: string) => void) \| undefined` | Replace the source of the currently selected file (or `fileName` when provided) in the controlled code. Internal hooks may pass additional arguments (caret position, pre-parsed HAST) that are not part of the public contract. | -| reset | `(() => void) \| undefined` | Clears the entire controlled code state back to `undefined`, discarding user edits across **all variants and files** owned by the surrounding `CodeControllerContext` (not just the currently selected file or variant). Only available when a `CodeControllerContext` with `setCode` is in scope and editing is not disabled. | -| refresh | `(() => void) \| undefined` | Re-fetches the block's data on the client by re-running the full variant loader, then swaps in the fresh result while keeping the current highlighted output visible until the new tree lands (stale-while-revalidate). Invalidates the pre-parsed HAST cache. `undefined` (or a no-op) for a block with no `url` to re-fetch from, or with no `CodeProvider` in scope. | -| userProps | `UserProps<{}>` | - | +| Property | Type | Description | +| :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| component | `string \| number \| bigint \| true \| ReactElement \| Iterable \| Promise \| null` | - | +| error | `string \| null` | - | +| focusRef | `React.RefObject` | - | +| resetFocus | `(() => void)` | - | +| openStackBlitz | `(() => void)` | - | +| openCodeSandbox | `(() => void)` | - | +| name | `string \| undefined` | - | +| slug | `string \| undefined` | - | +| variants | `string[]` | - | +| selectedVariant | `string` | - | +| selectVariant | `((variant: string \| null) => void)` | - | +| files | `({ name: string; slug?: string; component: React.ReactNode })[]` | - | +| selectedFile | `React.ReactNode` | - | +| selectedFileLines | `number` | - | +| selectedFileName | `string \| undefined` | - | +| selectedFileUrl | `string \| undefined` | URL of the currently selected file, derived from the selected variant's `url`, the file's name, and its `relativeUrl` (when set). `undefined` when the variant has no `url` or the URL cannot be resolved. | +| selectedFileSlug | `string \| undefined` | Slug for the currently selected file. Always derived from the canonical (original) file name — transforms are a view preference and do not produce separate slugs. Useful for building permalinks (e.g. `#${slug}`) that survive transform changes. | +| selectFileName | `((fileName: string) => void)` | - | +| allFilesSlugs | `({ fileName: string; slug: string; variantName: string })[]` | - | +| expanded | `boolean` | - | +| expand | `(() => void)` | - | +| setExpanded | `((expanded: boolean) => void)` | - | +| copy | `((event: React.MouseEvent) => Promise)` | - | +| copyRecentlySuccessful | `boolean` | `true` for a moment after `copy` succeeds — for the `copy.timeout` option, 2000 ms by default — to drive a toast or other notification confirming the copy. The `recentlySuccessful` of the `useCopier` behind `copy`. | +| copyMarkdown | `((event: React.MouseEvent) => Promise)` | Copies all files in the current variant to the clipboard as a Markdown snippet (heading + per-file fenced code blocks). | +| copyMarkdownRecentlySuccessful | `boolean` | Like `copyRecentlySuccessful`, for `copyMarkdown`. | +| availableTransforms | `string[]` | - | +| selectedTransform | `string \| null \| undefined` | - | +| selectTransform | `((transformName: string \| null) => void)` | - | +| pendingTransform | `string \| null \| undefined` | Target of an in-flight transform swap that is still waiting on slow peers to catch up. `undefined` when no swap is pending or shortly after one commits. Otherwise mirrors the shape of `selectedTransform`: `null` for a pending swap back to the un-transformed original, or the transform name for a pending swap to that transform. Consumers can check `pendingTransform !== undefined` to render a generic loading indicator, or read the value to render something like `` `Switching to ${pendingTransform ?? 'original'}…` ``. Only populated on the demo that originated the change — peer demos receiving the broadcast keep this `undefined` so the indicator stays anchored to the demo the user interacted with. | +| editable | `boolean` | Whether edit mode is currently on. `true` by default; starts `false` when the block opts in via `initialDisabled` — per demo through the `createDemo` `meta`, or across a factory's demos through `createDemoFactory`. While `false` the block renders read-only and the live-editing engine is not even warmed; flip it with `setEditable`. Independent of `editActivation`, which governs _when_ the engine loads once editing is on, and of `disabled`, which turns editing off permanently. | +| setEditable | `((editable: boolean) => void) \| undefined` | Turns edit mode on or off. `undefined` when the block can't be edited at all — no `CodeControllerContext` with `setCode` in scope, or editing is hard-`disabled` — so a toggle button can render only when this is defined. Toggling off keeps the reader's edits (use `reset` to discard them). | +| setSource | `((source: string, fileName?: string) => void) \| undefined` | Replace the source of the currently selected file (or `fileName` when provided) in the controlled code. Internal hooks may pass additional arguments (caret position, pre-parsed HAST) that are not part of the public contract. | +| reset | `(() => void) \| undefined` | Clears the entire controlled code state back to `undefined`, discarding user edits across **all variants and files** owned by the surrounding `CodeControllerContext` (not just the currently selected file or variant). Only available when a `CodeControllerContext` with `setCode` is in scope and editing is not disabled. | +| refresh | `(() => void) \| undefined` | Re-fetches the block's data on the client by re-running the full variant loader, then swaps in the fresh result while keeping the current highlighted output visible until the new tree lands (stale-while-revalidate). Invalidates the pre-parsed HAST cache. `undefined` (or a no-op) for a block with no `url` to re-fetch from, or with no `CodeProvider` in scope. | +| userProps | `UserProps<{}>` | - | ## Additional Types diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx index 03227a8..ff395a0 100644 --- a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx @@ -1572,8 +1572,8 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { const codeForFallback = overlaidCode || (controlled?.code ? undefined : resolvedPropsCode || resolvedStateCode); - // Resolve the active variant's fallbacks from the two places one can cross - // the server→client boundary: the hoisted copy (from a `ContentLoading` + // Resolve each variant's fallbacks from the two places one can cross the + // server→client boundary: the hoisted copy (from a `ContentLoading` // component, which had it stripped off `Code`) and the variant's own // `fallback` field on `Code` (present without a `ContentLoading`, or scattered // back from the residual blob). For most files only one is populated. When @@ -1581,13 +1581,28 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { // scatters the *full* fallback onto `Code` — the `Code` copy must win, since // the full text is the DEFLATE dictionary `hastCompressed` needs. So merge // with the derived (`Code`) copy taking precedence. - const activeFallbacks = React.useMemo(() => { - const merged = { - ...hoistedFallbackHasts[variantName], - ...deriveFallbacksFromCode(codeForFallback, variantName), - }; - return Object.keys(merged).length > 0 ? merged : undefined; - }, [hoistedFallbackHasts, variantName, codeForFallback]); + // + // Kept per variant: `useCode` selects variants on its own, and variants often + // share file names, so a single file-name map would hand another variant's + // same-named file this variant's dictionary. + const variantFallbacks = React.useMemo(() => { + const byVariant: Record = {}; + const names = new Set([ + ...Object.keys(hoistedFallbackHasts), + ...Object.keys(codeForFallback ?? {}), + ]); + for (const name of names) { + const merged = { + ...hoistedFallbackHasts[name], + ...deriveFallbacksFromCode(codeForFallback, name), + }; + if (Object.keys(merged).length > 0) { + byVariant[name] = merged; + } + } + return byVariant; + }, [hoistedFallbackHasts, codeForFallback]); + const activeFallbacks = variantFallbacks[variantName]; const fallbackContext = React.useMemo( () => ({ @@ -1656,6 +1671,7 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { url: props.url, deferHighlight, fallbacks: activeFallbacks, + variantFallbacks, highlightReady, highlightAfter, editActivation, @@ -1676,6 +1692,7 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { props.url, deferHighlight, activeFallbacks, + variantFallbacks, highlightReady, highlightAfter, editActivation, diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx index 3b1f1b4..d8aea7a 100644 --- a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx @@ -37,6 +37,14 @@ export interface CodeHighlighterContextType { * for decompressing `hastCompressed` payloads. */ fallbacks?: Fallbacks; + /** + * The same compact fallback data for every variant, keyed by variant and + * then fileName. A `hastCompressed` payload only decodes with its own file's + * dictionary, and variants often share file names, so a consumer that + * selects variants on its own (like `useCode`) reads the entry for the + * variant it renders rather than `fallbacks`. + */ + variantFallbacks?: Record; /** * Render-side readiness gate. `true` once the highlight trigger * (`init` / `hydration` / `idle` / `visible`) has fired *and* the diff --git a/packages/docs/src/CodeHighlighter/user.spec.tsx b/packages/docs/src/CodeHighlighter/user.spec.tsx index 9fceb0b..1c516f6 100644 --- a/packages/docs/src/CodeHighlighter/user.spec.tsx +++ b/packages/docs/src/CodeHighlighter/user.spec.tsx @@ -33,6 +33,9 @@ import { CodeContext } from '../CodeProvider/CodeContext'; import { parseControlledCode } from './parseControlledCode'; import { createParseSource } from '../pipeline/parseSource'; import { preloadSourceEditingEngine } from '../useCode/useSourceEditing'; +import { preloadTransformEngine } from '../useCode/transformEngineCache'; +import { compressHast } from '../pipeline/hastUtils/hastCompression'; +import { fallbackToText } from './fallbackFormat'; import type { Code, ContentProps, ControlledCode, HastRoot, ParseSource } from './types'; let parseSource: ParseSource; @@ -256,6 +259,68 @@ describe('CodeHighlighter rendering', () => { await waitFor(() => expect(screen.getByTestId('file').textContent).toBe('app.js')); }); + it('keeps a transform applied when the user switches to a variant whose file has the same name', async () => { + // Production payloads compress each file with its own `fallback` text as the + // DEFLATE dictionary. Both variants name their file `app.ts`, so the second + // must decode with ITS dictionary, not the first variant's same-named one. + await preloadTransformEngine(); + const compressed = (text: string) => { + const fallback = [text]; + return { + source: { + hastCompressed: compressHast(JSON.stringify(highlighted(text)), fallbackToText(fallback)), + }, + fallback, + totalLines: 1, + }; + }; + const toJs = (text: string) => ({ + js: { + fileName: 'app.js', + delta: { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { children: { _t: 'a', 0: { value: [text] } } } } }, + }, + }, + }, + }); + const code = { + Plain: { + fileName: 'app.ts', + ...compressed('const answer: number = 42;'), + transforms: toJs('const answer = 42;'), + }, + Doubled: { + fileName: 'app.ts', + ...compressed('const answer: number = 21 * 2;'), + transforms: toJs('const answer = 21 * 2;'), + }, + } as unknown as Code; + + render( + + + , + ); + + act(() => { + screen.getByTestId('transform:js').click(); + }); + await waitFor(() => + expect(screen.getByTestId('code').textContent).toContain('const answer = 42;'), + ); + + act(() => { + screen.getByTestId('variant:Doubled').click(); + }); + + // The incoming variant renders with the transform still applied. + await waitFor(() => expect(screen.getByTestId('code').textContent).toContain('21 * 2')); + expect(screen.getByTestId('code').textContent).toContain('const answer = 21 * 2;'); + expect(screen.getByTestId('file').textContent).toBe('app.js'); + }); + it('re-highlights a controlled file after the user edits it', async () => { const code = { Default: { fileName: 'app.js', source: highlighted('const answer = 42;') }, diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts index 71d078c..c64ab85 100644 --- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts +++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts @@ -332,6 +332,142 @@ describe('parseImportsAndComments', () => { }); }); + describe('imports without semicolons', () => { + /** Position of a quoted module path in `code`, as the parser reports it. */ + function positionOf(code: string, quotedPath: string, fromIndex = 0) { + const start = code.indexOf(quotedPath, fromIndex); + return { start, end: start + quotedPath.length }; + } + + it('detects every import form when each statement ends at a line break', () => { + const code = `import * as React from 'react' +import Button, { type ButtonProps } from './Button' +import type { Theme } from './theme' +import { + Checkbox, + Radio as RadioInput, +} from './inputs' +import './setup' +import styles from './Demo.module.css' + +export function Demo() { + return + // @focus-end +}`; + + const result = parseImportsAndComments(code, '/src/Counter.tsx', { + removeCommentsWithPrefix: ['@highlight', '@focus'], + notableCommentsPrefix: ['@highlight', '@focus'], + }); + + // The same code and line keys as the semicolon-terminated source produces. + expect(result.code).toBe(`import * as React from 'react' +import styles from './Counter.module.css' + +export function Counter() { + const [count, setCount] = React.useState(0) + return +}`); + expect(result.comments).toEqual({ + 2: ['@highlight'], + 5: ['@focus-start', '@highlight'], + 7: ['@focus-end'], + }); + expect(Object.keys(result.relative)).toEqual(['./Counter.module.css']); + }); }); // Test cases for export-from statements @@ -3507,6 +3675,11 @@ describe('Export-from statement parsing', () => { names: [{ name: 'Component', type: 'named' }], positions: [{ start: 26, end: 39 }], }, + './Button': { + url: 'file:///src/Button', + names: [{ name: 'Button', type: 'named' }], + positions: [{ start: 63, end: 73 }], + }, }, externals: {}, }); diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts index 717e24c..e0282f9 100644 --- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts +++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts @@ -1562,6 +1562,45 @@ function parseJSImports( }; } +/** + * Finds where an import or export-from statement ends once its module path has + * been read, looking ahead from `pos` on the same line. Returns the index just + * past a `;` that ends it (block comments before the `;` stay inside the + * statement). Without a `;`, the end of the line ends the statement (automatic + * semicolon insertion), so returns the index of the line break, or of the first + * trailing comment so the comment is still scanned. Returns `-1` when something + * else follows on the line (e.g. an import attributes clause), so the caller + * keeps scanning. + */ +function findStatementEndAfterModulePath(text: string, pos: number): number { + const len = text.length; + let k = pos; + let firstCommentStart = -1; + for (;;) { + while (k < len && (text[k] === ' ' || text[k] === '\t' || text[k] === '\r')) { + k += 1; + } + if (k < len && text[k] === ';') { + return k + 1; + } + const lineEnds = k >= len || text[k] === '\n' || (text[k] === '/' && text[k + 1] === '/'); + if (lineEnds) { + return firstCommentStart === -1 ? k : firstCommentStart; + } + if (text[k] !== '/' || text[k + 1] !== '*') { + return -1; + } + if (firstCommentStart === -1) { + firstCommentStart = k; + } + const commentEnd = text.indexOf('*/', k + 2); + if (commentEnd === -1) { + return firstCommentStart; + } + k = commentEnd + 2; + } +} + /** * Detects JavaScript import and export-from statements at a given position in source code. * @param sourceText - The source text to scan @@ -1649,16 +1688,9 @@ function detectJavaScriptImport( foundFrom = true; } if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) { - let k = j; - while (k < len && /\s/.test(sourceText[k])) { - k += 1; - } - if (k >= len || sourceText[k] === ';' || sourceText[k] === '\n') { - if (sourceText[k] === ';') { - j = k + 1; - } else { - j = k; - } + const end = findStatementEndAfterModulePath(sourceText, j); + if (end !== -1) { + j = end; break; } } @@ -1812,7 +1844,9 @@ function detectJavaScriptImport( if (isStringStart(cj)) { importState = cj === '`' ? 'template' : 'string'; importQuote = cj; - if (foundFrom) { + // The module path follows `from`, or is the quoted string that opens a + // side-effect import (`import './styles.css'`). + if (foundFrom || (cj !== '`' && sourceText.slice(pos + 6, j).trim() === '')) { foundModulePath = true; } j += 1; @@ -1829,17 +1863,9 @@ function detectJavaScriptImport( } // If we found a module path and we're back to normal code, we might be done if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) { - // Look ahead for semicolon or end of statement - let k = j; - while (k < len && /\s/.test(sourceText[k])) { - k += 1; - } - if (k >= len || sourceText[k] === ';' || sourceText[k] === '\n') { - if (sourceText[k] === ';') { - j = k + 1; - } else { - j = k; - } + const end = findStatementEndAfterModulePath(sourceText, j); + if (end !== -1) { + j = end; break; } } diff --git a/packages/docs/src/useCode/SourceEditingEngine.ts b/packages/docs/src/useCode/SourceEditingEngine.ts index adc2520..cc17e75 100644 --- a/packages/docs/src/useCode/SourceEditingEngine.ts +++ b/packages/docs/src/useCode/SourceEditingEngine.ts @@ -436,8 +436,9 @@ export function toControlledCode( // The per-file `fallback` is the DEFLATE dictionary for a `hastCompressed` // source. It rides on the `VariantCode` in the no-`ContentLoading` path; on // the `ContentLoading` path the active variant's fallback is stripped off - // `Code` and lives in `context.fallbacks` (`activeFallbacks`) instead — so - // prefer that for the active variant, falling back to the variant's field. + // `Code` and is hoisted into the highlighter context instead, which `useCode` + // resolves for this variant (`activeFallbacks`) — so prefer that for the + // active variant, falling back to the variant's field. const variantFallbacks = key === activeVariantKey ? activeFallbacks : undefined; const mainFallback = (variant.fileName ? variantFallbacks?.[variant.fileName] : undefined) ?? variant.fallback; diff --git a/packages/docs/src/useCode/TransformEngine.test.ts b/packages/docs/src/useCode/TransformEngine.test.ts index a3d44a5..8577aaa 100644 --- a/packages/docs/src/useCode/TransformEngine.test.ts +++ b/packages/docs/src/useCode/TransformEngine.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; +import type { Delta } from 'jsondiffpatch'; import { createTransformedFiles, applyTransformToSource } from './TransformEngine'; import type { TransformRuntimeDeps } from './TransformEngine'; import { decodeHastSource } from '../pipeline/loadIsomorphicCodeVariant/decodeHastSource'; import { frameFallbackFromSpans } from '../pipeline/hastUtils'; +import { compressHast } from '../pipeline/hastUtils/hastCompression'; +import { fallbackToText } from '../CodeHighlighter/fallbackFormat'; +import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; import type { VariantCode } from '../CodeHighlighter/types'; // Real hast helpers the engine takes injected (no mocks, per convention 3.5). @@ -354,5 +358,103 @@ describe('TransformEngine', () => { consoleSpy.mockRestore(); }); + + describe('compressed sources', () => { + // A `hastCompressed` source decodes only with the dictionary its own + // `fallback` text built, so each file here is compressed with its own. + function compressedFile(text: string) { + const root = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'span', + properties: {}, + children: [{ type: 'text', value: text }], + }, + ], + }; + const fallback: FallbackNode[] = [text]; + return { + source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) }, + fallback, + }; + } + + function replaceText(text: string): Delta { + return { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { value: [text] } } }, + }, + }; + } + + function textOf(source: unknown) { + return (source as { children: { children: { value: string }[] }[] }).children[0].children[0] + .value; + } + + it('decodes a variant with its own dictionary when fallbacks of another variant share the file name', () => { + const first = compressedFile('const first: number = 1;'); + const second = compressedFile('const second: number = 2;'); + const secondVariant: VariantCode = { + fileName: 'Button.tsx', + ...second, + transforms: { js: { delta: replaceText('const second = 2;'), fileName: 'Button.jsx' } }, + }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // The per-file map handed in belongs to the first variant. + const result = createTransformedFiles(secondVariant, 'js', deps, { + 'Button.tsx': first.fallback, + }); + + const errors = [...errorSpy.mock.calls]; + errorSpy.mockRestore(); + expect(errors).toEqual([]); + expect(result!.files.map((file) => file.name)).toEqual(['Button.jsx']); + expect(textOf(result!.files[0].source)).toBe('const second = 2;'); + }); + + it('decodes an extra file with its own dictionary when fallbacks of another variant share the file name', () => { + const firstStyles = compressedFile('.first { color: red; }'); + const secondStyles = compressedFile('.second { color: blue; }'); + const secondVariant: VariantCode = { + fileName: 'Button.tsx', + source: 'const second = 2;', + extraFiles: { + 'styles.css': { + ...secondStyles, + transforms: { js: { delta: replaceText('.second {}') } }, + }, + }, + }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = createTransformedFiles(secondVariant, 'js', deps, { + 'styles.css': firstStyles.fallback, + }); + + const errors = [...errorSpy.mock.calls]; + errorSpy.mockRestore(); + expect(errors).toEqual([]); + expect(result!.files.map((file) => file.name)).toEqual(['Button.tsx', 'styles.css']); + expect(textOf(result!.files[1].source)).toBe('.second {}'); + }); + + it('uses the fallbacks map when the variant carries no dictionary of its own', () => { + const { source, fallback } = compressedFile('const only: number = 1;'); + const variant: VariantCode = { + fileName: 'Button.tsx', + source, + transforms: { js: { delta: replaceText('const only = 1;'), fileName: 'Button.jsx' } }, + }; + + const result = createTransformedFiles(variant, 'js', deps, { 'Button.tsx': fallback }); + + expect(textOf(result!.files[0].source)).toBe('const only = 1;'); + }); + }); }); }); diff --git a/packages/docs/src/useCode/TransformEngine.ts b/packages/docs/src/useCode/TransformEngine.ts index 2a9fbe8..9129b64 100644 --- a/packages/docs/src/useCode/TransformEngine.ts +++ b/packages/docs/src/useCode/TransformEngine.ts @@ -114,7 +114,9 @@ export function createTransformedFiles( // Per-file DEFLATE dictionaries hoisted from a `ContentLoading` component. // A file's fallback may live here (hoisted) instead of on the variant // (stripped) — applying a transform must decode `hastCompressed`, so resolve - // from both, preferring the hoisted copy. + // from both. The file's own `fallback` wins: it is the dictionary its source + // was compressed with, while this map is keyed by file name alone and so can + // hold a same-named file of another variant. fallbacks?: Fallbacks, ): TransformedFiles | undefined { // Only create transformed files when there's actually a transform selected @@ -171,8 +173,8 @@ export function createTransformedFiles( selectedTransform, deps, selectedVariant.comments, - (selectedVariant.fileName ? fallbacks?.[selectedVariant.fileName] : undefined) ?? - selectedVariant.fallback, + selectedVariant.fallback ?? + (selectedVariant.fileName ? fallbacks?.[selectedVariant.fileName] : undefined), ); const fileName = selectedVariant.fileName; @@ -226,8 +228,8 @@ export function createTransformedFiles( selectedTransform, deps, fileComments, - fallbacks?.[extraFileName] ?? - (typeof fileData === 'object' ? fileData.fallback : undefined), + (typeof fileData === 'object' ? fileData.fallback : undefined) ?? + fallbacks?.[extraFileName], ); transformedSource = result.source; transformedComments = result.comments; diff --git a/packages/docs/src/useCode/useCode.ts b/packages/docs/src/useCode/useCode.ts index de443d0..2246470 100644 --- a/packages/docs/src/useCode/useCode.ts +++ b/packages/docs/src/useCode/useCode.ts @@ -188,11 +188,19 @@ export interface UseCodeResult { expand: () => void; setExpanded: (expanded: boolean) => void; copy: (event: React.MouseEvent) => Promise; + /** + * `true` for a moment after `copy` succeeds — for the `copy.timeout` option, + * 2000 ms by default — to drive a toast or other notification confirming the + * copy. The `recentlySuccessful` of the `useCopier` behind `copy`. + */ + copyRecentlySuccessful: boolean; /** * Copies all files in the current variant to the clipboard as a Markdown * snippet (heading + per-file fenced code blocks). */ copyMarkdown: (event: React.MouseEvent) => Promise; + /** Like `copyRecentlySuccessful`, for `copyMarkdown`. */ + copyMarkdownRecentlySuccessful: boolean; availableTransforms: string[]; selectedTransform: string | null | undefined; selectTransform: (transformName: string | null) => void; @@ -479,12 +487,21 @@ export function useCode( const renderedVariantKey = variantSelection.committedVariantKey || variantSelection.selectedVariantKey; + // Per-file DEFLATE dictionaries for the rendered variant. The highlighter's + // `fallbacks` belong to its own active variant, but the variant is selected + // here, and variants often share file names — so read the rendered variant's + // entry, or a same-named file would decode with another variant's dictionary. + const fallbacks = context?.variantFallbacks + ? context.variantFallbacks[renderedVariantKey] + : context?.fallbacks; + // Sub-hook: Transform Management const transformManagement = useTransformManagement({ context, effectiveCode, selectedVariantKey: renderedVariantKey, selectedVariant: renderedVariant, + fallbacks, initialTransform, transformDelay, transformLayoutShift, @@ -498,6 +515,7 @@ export function useCode( selectedVariantKey: renderedVariantKey, effectiveCode, selectedVariant: renderedVariant, + fallbacks, disabled, }); @@ -621,7 +639,7 @@ export function useCode( saveVariantToLocalStorage: variantSelection.saveVariantToLocalStorage, hashVariant: variantSelection.hashVariant, sourceEnhancers: mergedEnhancers, - fallbacks: context?.fallbacks, + fallbacks, expanded: uiState.expanded, collapseToEmpty, expand, @@ -638,9 +656,9 @@ export function useCode( selectedFile: fileNavigation.selectedFile, selectedVariant: renderedVariant, transformedFiles: transformManagement.transformedFiles, - // Per-file dictionaries for the active variant (decodes `hastCompressed` + // Per-file dictionaries for the rendered variant (decodes `hastCompressed` // sources back to text); `selectedFileFallback` covers the single-file copy. - fallbacks: context?.fallbacks, + fallbacks, selectedFileFallback: fileNavigation.selectedFileFallback, title: userProps.name, copyOpts, @@ -667,7 +685,9 @@ export function useCode( expand, setExpanded, copy: copyFunctionality.copy, + copyRecentlySuccessful: copyFunctionality.copyRecentlySuccessful, copyMarkdown: copyFunctionality.copyMarkdown, + copyMarkdownRecentlySuccessful: copyFunctionality.copyMarkdownRecentlySuccessful, availableTransforms: transformManagement.availableTransforms, selectedTransform: transformManagement.selectedTransform, selectTransform: transformManagement.selectTransform, diff --git a/packages/docs/src/useCode/useCopyFunctionality.ts b/packages/docs/src/useCode/useCopyFunctionality.ts index 6fbf371..00d44f4 100644 --- a/packages/docs/src/useCode/useCopyFunctionality.ts +++ b/packages/docs/src/useCode/useCopyFunctionality.ts @@ -26,11 +26,15 @@ interface UseCopyFunctionalityProps { export interface UseCopyFunctionalityResult { copy: (event: React.MouseEvent) => Promise; + /** `useCopier`'s `recentlySuccessful` for `copy`. */ + copyRecentlySuccessful: boolean; /** * Copies all files in the current variant to the clipboard as a Markdown * snippet (heading + per-file fenced code blocks). */ copyMarkdown: (event: React.MouseEvent) => Promise; + /** `useCopier`'s `recentlySuccessful` for `copyMarkdown`. */ + copyMarkdownRecentlySuccessful: boolean; } export function collectVariantFiles( @@ -132,11 +136,19 @@ export function useCopyFunctionality({ return generateVariantMarkdown({ title, files }); }, [selectedVariant, transformedFiles, fallbacks, title]); - const { copy } = useCopier(sourceFileToText, copyOpts); - const { copy: copyMarkdown } = useCopier(variantToMarkdown, copyOpts); + const { copy, recentlySuccessful: copyRecentlySuccessful } = useCopier( + sourceFileToText, + copyOpts, + ); + const { copy: copyMarkdown, recentlySuccessful: copyMarkdownRecentlySuccessful } = useCopier( + variantToMarkdown, + copyOpts, + ); return { copy, + copyRecentlySuccessful, copyMarkdown, + copyMarkdownRecentlySuccessful, }; } diff --git a/packages/docs/src/useCode/useSourceEditing.ts b/packages/docs/src/useCode/useSourceEditing.ts index 0373eb9..8103877 100644 --- a/packages/docs/src/useCode/useSourceEditing.ts +++ b/packages/docs/src/useCode/useSourceEditing.ts @@ -9,6 +9,7 @@ import type { Code, ControlledCode, ControlledVariantCode, + Fallbacks, VariantCode, } from '../CodeHighlighter/types'; import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext'; @@ -59,6 +60,11 @@ interface UseSourceEditingProps { selectedVariantKey: string; effectiveCode: Code; selectedVariant: VariantCode | null; + /** + * Per-file DEFLATE dictionaries for `selectedVariant` (keyed by file name), + * used to decode its `hastCompressed` sources into editable text. + */ + fallbacks?: Fallbacks; disabled?: boolean; } @@ -87,6 +93,7 @@ export function useSourceEditing({ selectedVariantKey, effectiveCode, selectedVariant, + fallbacks, disabled, }: UseSourceEditingProps): UseSourceEditingResult { const contextSetCode = context?.setCode; @@ -137,7 +144,7 @@ export function useSourceEditing({ : engine.toControlledCode( effectiveCode, selectedVariantKey, - context?.fallbacks, + fallbacks, stringOrHastToString, ); @@ -282,7 +289,7 @@ export function useSourceEditing({ effectiveCode, selectedVariant, context?.preParsedCache, - context?.fallbacks, + fallbacks, editingEngineLoader, ], ); diff --git a/packages/docs/src/useCode/useTransformManagement.ts b/packages/docs/src/useCode/useTransformManagement.ts index c1c57b0..5a8d2ce 100644 --- a/packages/docs/src/useCode/useTransformManagement.ts +++ b/packages/docs/src/useCode/useTransformManagement.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { Code, VariantCode } from '../CodeHighlighter/types'; +import type { Code, Fallbacks, VariantCode } from '../CodeHighlighter/types'; // `decodeHastSource` and `frameFallbackFromSpans` are already part of the // always-loaded `useCode` shell (via `Pre`, `sourceLineCounts`, // `useFileNavigation`, `useSourceEnhancing`). Passing them into the lazy @@ -64,6 +64,11 @@ interface UseTransformManagementProps { effectiveCode: Code; selectedVariantKey: string; selectedVariant: VariantCode | null; + /** + * Per-file DEFLATE dictionaries for `selectedVariant` (keyed by file name), + * used to decode its `hastCompressed` sources before applying a transform. + */ + fallbacks?: Fallbacks; initialTransform?: string; /** * When set to a positive number, the *swap* of `transformedFiles` to the @@ -165,6 +170,7 @@ export function useTransformManagement({ effectiveCode, selectedVariantKey, selectedVariant, + fallbacks, initialTransform, transformDelay, transformLayoutShift, @@ -386,7 +392,7 @@ export function useTransformManagement({ transformLayoutShift, selectedFileName, expanded, - fallbacks: context?.fallbacks, + fallbacks, }); // eslint-disable-next-line react-hooks/refs layoutShiftPropsRef.current = { @@ -394,7 +400,7 @@ export function useTransformManagement({ transformLayoutShift, selectedFileName, expanded, - fallbacks: context?.fallbacks, + fallbacks, }; // Plumb classifier props through `transformHasCollapsePlaceholder` @@ -694,9 +700,9 @@ export function useTransformManagement({ selectedVariant, delayedAppliedTransform, transformRuntimeDeps, - context?.fallbacks, + fallbacks, ); - }, [precomputed, selectedVariant, delayedAppliedTransform, context?.fallbacks, transformEngine]); + }, [precomputed, selectedVariant, delayedAppliedTransform, fallbacks, transformEngine]); const result = { availableTransforms, diff --git a/packages/docs/src/useCode/user.spec.ts b/packages/docs/src/useCode/user.spec.ts index bbcd244..ef4d2b4 100644 --- a/packages/docs/src/useCode/user.spec.ts +++ b/packages/docs/src/useCode/user.spec.ts @@ -14,12 +14,17 @@ */ import * as React from 'react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { render, renderHook, act, waitFor } from '@testing-library/react'; import { useCode } from './useCode'; -import type { ContentProps, HastRoot } from '../CodeHighlighter/types'; +import type { UseCodeResult } from './useCode'; +import type { ContentProps, HastRoot, Transforms } from '../CodeHighlighter/types'; import { CodeHighlighterContext } from '../CodeHighlighter/CodeHighlighterContext'; import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext'; import { CodeControllerContext } from '../CodeControllerContext/CodeControllerContext'; +import { compressHast } from '../pipeline/hastUtils/hastCompression'; +import { fallbackToText } from '../CodeHighlighter/fallbackFormat'; +import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; +import { preloadTransformEngine } from './transformEngineCache'; describe('useCode integration tests', () => { let originalLocation: Location; @@ -1337,6 +1342,166 @@ describe('useCode integration tests', () => { }); }); + describe('compressed sources across variants', () => { + // Each file is compressed with its own `fallback` text as the dictionary, + // so it decodes only with that same text. + function compressedFile(text: string) { + const root = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'span', + properties: {}, + children: [{ type: 'text', value: text }], + }, + ], + }; + const fallback: FallbackNode[] = [text]; + return { + source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) }, + fallback, + }; + } + + function toJs(text: string): Transforms { + return { + js: { + delta: { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { value: [text] } } }, + }, + }, + fileName: 'Button.jsx', + }, + }; + } + + it('keeps the selected transform when switching to a variant whose file shares a name', async () => { + await preloadTransformEngine(); + const first = compressedFile('const first: number = 1;'); + const second = compressedFile('const second: number = 2;'); + + // A `ContentLoading` hoists each variant's dictionaries off `Code`, and the + // highlighter hands them back per variant alongside its own variant's map. + const context: CodeHighlighterContextType = { + code: { + First: { + fileName: 'Button.tsx', + source: first.source, + totalLines: 1, + transforms: toJs('const first = 1;'), + }, + Second: { + fileName: 'Button.tsx', + source: second.source, + totalLines: 1, + transforms: toJs('const second = 2;'), + }, + }, + fallbacks: { 'Button.tsx': first.fallback }, + variantFallbacks: { + First: { 'Button.tsx': first.fallback }, + Second: { 'Button.tsx': second.fallback }, + }, + }; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(CodeHighlighterContext.Provider, { value: context }, children); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // `
` observes frame visibility, which jsdom doesn't implement.
+      const { IntersectionObserver: originalIntersectionObserver } = globalThis;
+      globalThis.IntersectionObserver = class {
+        observe() {}
+
+        unobserve() {}
+
+        disconnect() {}
+      } as unknown as typeof IntersectionObserver;
+
+      try {
+        let code: UseCodeResult | undefined;
+        function Viewer() {
+          code = useCode({ slug: 'button' });
+          return React.createElement('div', { 'data-testid': 'viewer' }, code.selectedFile);
+        }
+        const { getByTestId } = render(React.createElement(Viewer), { wrapper });
+
+        act(() => {
+          code!.selectTransform('js');
+        });
+        await waitFor(() => {
+          expect(getByTestId('viewer').textContent).toBe('const first = 1;');
+        });
+
+        act(() => {
+          code!.selectVariant('Second');
+        });
+        await waitFor(() => {
+          expect(getByTestId('viewer').textContent).toContain('second');
+        });
+
+        expect(getByTestId('viewer').textContent).toBe('const second = 2;');
+        expect(code!.selectedFileName).toBe('Button.jsx');
+        const transformErrors = errorSpy.mock.calls.filter(([message]) =>
+          String(message).includes('Transform failed'),
+        );
+        expect(transformErrors).toEqual([]);
+      } finally {
+        errorSpy.mockRestore();
+        globalThis.IntersectionObserver = originalIntersectionObserver;
+      }
+    });
+  });
+
+  describe('copy feedback', () => {
+    it('reports a recent copy of the file and of the Markdown separately', async () => {
+      const writes: string[] = [];
+      Object.defineProperty(window.navigator, 'clipboard', {
+        configurable: true,
+        value: {
+          writeText: async (text: string) => {
+            writes.push(text);
+          },
+        },
+      });
+      vi.useFakeTimers();
+      try {
+        const contentProps: ContentProps<{}> = {
+          name: 'Button',
+          code: { Default: { fileName: 'Button.tsx', source: 'const button = 1;' } },
+        };
+        const { result } = renderHook(() => useCode(contentProps, { copy: { timeout: 1000 } }));
+
+        expect(result.current.copyRecentlySuccessful).toBe(false);
+        expect(result.current.copyMarkdownRecentlySuccessful).toBe(false);
+
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(writes).toEqual(['const button = 1;']);
+        expect(result.current.copyRecentlySuccessful).toBe(true);
+        expect(result.current.copyMarkdownRecentlySuccessful).toBe(false);
+
+        // The feedback clears after the `copy.timeout`.
+        await act(async () => {
+          await vi.advanceTimersByTimeAsync(1000);
+        });
+        expect(result.current.copyRecentlySuccessful).toBe(false);
+
+        await act(async () => {
+          await result.current.copyMarkdown({} as React.MouseEvent);
+        });
+        expect(writes).toHaveLength(2);
+        expect(result.current.copyMarkdownRecentlySuccessful).toBe(true);
+        expect(result.current.copyRecentlySuccessful).toBe(false);
+      } finally {
+        vi.useRealTimers();
+        delete (window.navigator as { clipboard?: Clipboard }).clipboard;
+      }
+    });
+  });
+
   describe('deferred expand during swap', () => {
     it('defers expand() until the variant swap completes', async () => {
       vi.useFakeTimers();

From 1f7aec1d45e2fc7978afcc3747f81d26735f9eb4 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Thu, 24 Sep 2026 22:07:20 -0400
Subject: [PATCH 2/6] Code review

---
 docs/app/lib/hooks/use-copier/page.mdx        |   4 +-
 .../CodeHighlighter/CodeHighlighterClient.tsx |  39 +-
 .../CodeHighlighterContext.tsx                |  18 +-
 .../codeToFallbackProps.test.ts               |  77 +++
 .../CodeHighlighter/codeToFallbackProps.ts    |  34 ++
 .../prepareInitialSource.test.tsx             | 183 +++++--
 .../CodeHighlighter/prepareInitialSource.tsx  |  10 +
 .../docs/src/CodeHighlighter/user.spec.tsx    |  58 ++-
 .../enhanceCodeEmphasis.test.ts               |   9 +-
 .../hastUtils/hastCompression.testUtils.ts    |  36 ++
 .../parseImportsAndComments.test.ts           | 241 ++++++++-
 .../loaderUtils/parseImportsAndComments.ts    | 489 +++++++++---------
 .../src/useCode/SourceEditingEngine.test.ts   |  88 ++++
 .../docs/src/useCode/SourceEditingEngine.ts   |  10 +-
 .../docs/src/useCode/TransformEngine.test.ts  |  35 +-
 packages/docs/src/useCode/TransformEngine.ts  |   3 +-
 packages/docs/src/useCode/useCode.ts          |  12 +-
 .../docs/src/useCode/useCodeUtils.test.ts     |  81 +++
 packages/docs/src/useCode/useCodeUtils.ts     |  17 +
 .../src/useCode/useCopyFunctionality.test.ts  |   2 +-
 .../docs/src/useCode/useCopyFunctionality.ts  |   6 +-
 .../src/useCode/useFileNavigation.test.ts     |  86 +++
 .../docs/src/useCode/useFileNavigation.tsx    |  81 ++-
 .../useCode/useTransformManagement.test.ts    |  44 ++
 .../src/useCode/useTransformManagement.ts     |  17 +-
 packages/docs/src/useCode/user.spec.ts        | 136 ++++-
 packages/docs/src/useCopier/index.ts          |  16 +-
 packages/docs/src/useCopier/useCopier.test.ts | 174 +++++++
 28 files changed, 1552 insertions(+), 454 deletions(-)
 create mode 100644 packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts
 create mode 100644 packages/docs/src/useCode/SourceEditingEngine.test.ts
 create mode 100644 packages/docs/src/useCopier/useCopier.test.ts

diff --git a/docs/app/lib/hooks/use-copier/page.mdx b/docs/app/lib/hooks/use-copier/page.mdx
index f0626bb..58c651e 100644
--- a/docs/app/lib/hooks/use-copier/page.mdx
+++ b/docs/app/lib/hooks/use-copier/page.mdx
@@ -116,6 +116,7 @@ The hook manages internal state for copy feedback:
 - **On success**: State becomes `true` and starts timeout countdown
 - **After timeout**: State resets to `false`
 - **On error**: State remains `false` but error callback is fired
+- **On empty content**: Nothing is written and state remains `false`; neither `onCopied` nor `onError` is fired, but `onClick` still is
 
 ## Error Handling
 
@@ -123,10 +124,11 @@ Copy operations can fail for various reasons:
 
 - **Security restrictions**: Some browsers require user interaction
 - **Permission denied**: Clipboard access may be blocked
-- **Empty content**: No content to copy (function returns undefined)
 
 The hook provides the `onError` callback to handle these cases gracefully.
 
+Empty content (an empty string, or a function that returns `undefined`) is not an error: there is nothing to copy, so the clipboard is left untouched and `recentlySuccessful` stays `false`.
+
 ---
 
 ## When to Use
diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
index ff395a0..28699c4 100644
--- a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
+++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
@@ -19,7 +19,7 @@ import { useControlledCode } from '../CodeControllerContext';
 import type { Selection } from '../CodeControllerContext';
 import {
   codeToFallbackProps,
-  deriveFallbacksFromCode,
+  resolveVariantFallbacks,
   stripFallbackHastsFromCode,
 } from './codeToFallbackProps';
 import { resolveFallbackCritical } from './resolveFallbackCritical';
@@ -1572,37 +1572,10 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) {
   const codeForFallback =
     overlaidCode || (controlled?.code ? undefined : resolvedPropsCode || resolvedStateCode);
 
-  // Resolve each variant's fallbacks from the two places one can cross the
-  // server→client boundary: the hoisted copy (from a `ContentLoading`
-  // component, which had it stripped off `Code`) and the variant's own
-  // `fallback` field on `Code` (present without a `ContentLoading`, or scattered
-  // back from the residual blob). For most files only one is populated. When
-  // both are — a `fallbackCollapsed` block hoists the *visible* window but
-  // scatters the *full* fallback onto `Code` — the `Code` copy must win, since
-  // the full text is the DEFLATE dictionary `hastCompressed` needs. So merge
-  // with the derived (`Code`) copy taking precedence.
-  //
-  // Kept per variant: `useCode` selects variants on its own, and variants often
-  // share file names, so a single file-name map would hand another variant's
-  // same-named file this variant's dictionary.
-  const variantFallbacks = React.useMemo(() => {
-    const byVariant: Record = {};
-    const names = new Set([
-      ...Object.keys(hoistedFallbackHasts),
-      ...Object.keys(codeForFallback ?? {}),
-    ]);
-    for (const name of names) {
-      const merged = {
-        ...hoistedFallbackHasts[name],
-        ...deriveFallbacksFromCode(codeForFallback, name),
-      };
-      if (Object.keys(merged).length > 0) {
-        byVariant[name] = merged;
-      }
-    }
-    return byVariant;
-  }, [hoistedFallbackHasts, codeForFallback]);
-  const activeFallbacks = variantFallbacks[variantName];
+  const variantFallbacks = React.useMemo(
+    () => resolveVariantFallbacks(hoistedFallbackHasts, codeForFallback),
+    [hoistedFallbackHasts, codeForFallback],
+  );
 
   const fallbackContext = React.useMemo(
     () => ({
@@ -1670,7 +1643,6 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) {
       availableTransforms: controlled?.code ? [] : availableTransforms,
       url: props.url,
       deferHighlight,
-      fallbacks: activeFallbacks,
       variantFallbacks,
       highlightReady,
       highlightAfter,
@@ -1691,7 +1663,6 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) {
       availableTransforms,
       props.url,
       deferHighlight,
-      activeFallbacks,
       variantFallbacks,
       highlightReady,
       highlightAfter,
diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
index d8aea7a..ed8fa7e 100644
--- a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
+++ b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
@@ -31,18 +31,12 @@ export interface CodeHighlighterContextType {
   url?: string;
   deferHighlight?: boolean;
   /**
-  /**
-   * Compact fallback data for the active variant, keyed by fileName.
-   * Used by `Pre` to both render the fallback and derive text dictionaries
-   * for decompressing `hastCompressed` payloads.
-   */
-  fallbacks?: Fallbacks;
-  /**
-   * The same compact fallback data for every variant, keyed by variant and
-   * then fileName. A `hastCompressed` payload only decodes with its own file's
-   * dictionary, and variants often share file names, so a consumer that
-   * selects variants on its own (like `useCode`) reads the entry for the
-   * variant it renders rather than `fallbacks`.
+   * Compact fallback data for every variant, keyed by variant and then
+   * fileName. Used to render the fallback and as the text dictionary for
+   * decompressing a `hastCompressed` payload. That payload only decodes with
+   * its own file's dictionary, and variants often share file names, so a
+   * consumer that selects variants on its own (like `useCode`) reads the entry
+   * for the variant it renders.
    */
   variantFallbacks?: Record;
   /**
diff --git a/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts b/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts
index 5e4bb3b..9c99670 100644
--- a/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts
+++ b/packages/docs/src/CodeHighlighter/codeToFallbackProps.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
 import {
   codeToFallbackProps,
   deriveFallbacksFromCode,
+  resolveVariantFallbacks,
   stripFallbackHastsFromCode,
 } from './codeToFallbackProps';
 import type { Code, HastRoot } from './types';
@@ -514,3 +515,79 @@ describe('deriveFallbacksFromCode', () => {
     expect(deriveFallbacksFromCode(code, 'javascript')).toEqual({ 'App.js': fbMain });
   });
 });
+
+describe('resolveVariantFallbacks', () => {
+  it('keeps the hoisted fallbacks when the code carries none', () => {
+    const fbButton = fb('button');
+    const code: Code = {
+      Default: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' } },
+    };
+
+    expect(resolveVariantFallbacks({ Default: { 'Button.tsx': fbButton } }, code)).toEqual({
+      Default: { 'Button.tsx': fbButton },
+    });
+  });
+
+  it('reads the fallbacks off the code when nothing was hoisted', () => {
+    const fbButton = fb('button');
+    const fbCheckbox = fb('checkbox');
+    const code: Code = {
+      Button: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' }, fallback: fbButton },
+      Checkbox: {
+        fileName: 'Checkbox.tsx',
+        source: { hastCompressed: 'def' },
+        fallback: fbCheckbox,
+      },
+    };
+
+    expect(resolveVariantFallbacks({}, code)).toEqual({
+      Button: { 'Button.tsx': fbButton },
+      Checkbox: { 'Checkbox.tsx': fbCheckbox },
+    });
+  });
+
+  it('prefers the code copy of a file over the hoisted one and keeps hoisted-only files', () => {
+    // A `fallbackCollapsed` block hoists only the visible window but carries the
+    // full fallback on `Code`; the full text is the dictionary the source needs.
+    const fbWindow = fb('window');
+    const fbFull = fb('full');
+    const fbStyles = fb('styles');
+    const code: Code = {
+      Default: {
+        fileName: 'Button.tsx',
+        source: { hastCompressed: 'abc' },
+        fallback: fbFull,
+        extraFiles: { 'styles.css': { source: { hastCompressed: 'def' } } },
+      },
+    };
+
+    expect(
+      resolveVariantFallbacks(
+        { Default: { 'Button.tsx': fbWindow, 'styles.css': fbStyles } },
+        code,
+      ),
+    ).toEqual({ Default: { 'Button.tsx': fbFull, 'styles.css': fbStyles } });
+  });
+
+  it('omits variants that have no fallbacks anywhere', () => {
+    const fbButton = fb('button');
+    const code: Code = {
+      Button: { fileName: 'Button.tsx', source: { hastCompressed: 'abc' }, fallback: fbButton },
+      Checkbox: { fileName: 'Checkbox.tsx', source: 'const checkbox = 1;' },
+      Radio: 'const radio = 1;',
+    };
+
+    expect(resolveVariantFallbacks({ Switch: {} }, code)).toEqual({
+      Button: { 'Button.tsx': fbButton },
+    });
+  });
+
+  it('resolves from the hoisted fallbacks alone when there is no code', () => {
+    const fbButton = fb('button');
+
+    expect(resolveVariantFallbacks({ Default: { 'Button.tsx': fbButton } }, undefined)).toEqual({
+      Default: { 'Button.tsx': fbButton },
+    });
+    expect(resolveVariantFallbacks({}, undefined)).toEqual({});
+  });
+});
diff --git a/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts b/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts
index 1a0a230..5bb3261 100644
--- a/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts
+++ b/packages/docs/src/CodeHighlighter/codeToFallbackProps.ts
@@ -289,6 +289,40 @@ export function deriveFallbacksFromCode(
   return Object.keys(fallbacks).length > 0 ? fallbacks : undefined;
 }
 
+/**
+ * Resolve each variant's fallbacks from the two places one can cross the
+ * server→client boundary: the hoisted copy (from a `ContentLoading` component,
+ * which had it stripped off `Code`) and the variant's own `fallback` field on
+ * `Code` (present without a `ContentLoading`, or scattered back from the
+ * residual blob). For most files only one is populated. When both are — a
+ * `fallbackCollapsed` block hoists the *visible* window but scatters the *full*
+ * fallback onto `Code` — the `Code` copy must win, since the full text is the
+ * DEFLATE dictionary `hastCompressed` needs. So merge with the derived (`Code`)
+ * copy taking precedence.
+ *
+ * Kept per variant: `useCode` selects variants on its own, and variants often
+ * share file names, so a single file-name map would hand another variant's
+ * same-named file this variant's dictionary. Variants with no fallback in
+ * either place are omitted.
+ */
+export function resolveVariantFallbacks(
+  hoistedFallbackHasts: Record,
+  code: Code | undefined,
+): Record {
+  const byVariant: Record = {};
+  const names = new Set([...Object.keys(hoistedFallbackHasts), ...Object.keys(code ?? {})]);
+  for (const name of names) {
+    const merged = {
+      ...hoistedFallbackHasts[name],
+      ...deriveFallbacksFromCode(code, name),
+    };
+    if (Object.keys(merged).length > 0) {
+      byVariant[name] = merged;
+    }
+  }
+  return byVariant;
+}
+
 /**
  * Strip `fallback` entries from a `Code` object and return the
  * stripped Code alongside the extracted fallbacks grouped by variant → fileName.
diff --git a/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx b/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx
index 2d376ce..abf7795 100644
--- a/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx
+++ b/packages/docs/src/CodeHighlighter/prepareInitialSource.test.tsx
@@ -28,58 +28,54 @@ import { variantHasLayoutShift } from '../useCode/sourceLineCounts';
 import { createEnhanceCodeEmphasis } from '../pipeline/enhanceCodeEmphasis';
 import { prepareInitialSource } from './prepareInitialSource';
 
+/** A root with one frame per entry of `lineTexts`, each holding a single line. */
 function framedRoot(
-  lineText: string,
+  lineTexts: string | string[],
   counts: { totalLines: number; focusedLines: number; collapsible?: boolean } = {
     totalLines: 1,
     focusedLines: 1,
     collapsible: false,
   },
 ): HastRoot {
+  const lines = typeof lineTexts === 'string' ? [lineTexts] : lineTexts;
   return {
     type: 'root',
     data: counts,
-    children: [
-      {
-        type: 'element',
-        tagName: 'span',
-        properties: { className: 'frame' },
-        data: { fallback: [{ type: 'text', value: lineText }] } as HastElement['data'],
-        children: [
-          {
-            type: 'element',
-            tagName: 'span',
-            properties: { className: 'line', dataLn: 1 },
-            children: [{ type: 'text', value: lineText }],
-          },
-        ],
-      },
-    ],
+    children: lines.map((lineText, index) => ({
+      type: 'element',
+      tagName: 'span',
+      properties: { className: 'frame' },
+      data: { fallback: [{ type: 'text', value: lineText }] } as HastElement['data'],
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: { className: 'line', dataLn: index + 1 },
+          children: [{ type: 'text', value: lineText }],
+        },
+      ],
+    })),
   };
 }
 
 /**
- * Build a `{ hastCompressed }` source the way the loader does: derive the root
- * fallback, strip the per-frame `data.fallback`, and compress with the fallback
- * text as the DEFLATE dictionary. Decoding REQUIRES the matching fallback — a
- * missing/mismatched one throws "invalid distance". Made long enough that the
- * residual blob clears `FALLBACK_COMPRESSION_MIN_BYTES` (so the bug's wire-code
- * path is exercised, not the small-residual inline path).
+ * Compress a framed root the way the loader does: derive the root fallback,
+ * strip the per-frame `data.fallback`, and compress with the fallback text as
+ * the DEFLATE dictionary. Decoding REQUIRES the matching fallback — a
+ * missing/mismatched one throws "invalid distance".
  */
-function buildCompressedVariant(
-  seed: string,
-  counts?: { totalLines: number; focusedLines: number; collapsible?: boolean },
-): {
+function compressFramedRoot(root: HastRoot): {
   source: { hastCompressed: string };
   fallback: FallbackNode[];
   fallbackCritical: { [frameIndex: number]: FallbackNode };
 } {
-  const root = framedRoot(`const ${seed} = "${'x'.repeat(200)}";`, counts);
   const fallback = buildRootFallback(root);
   // The sparse highlighted-visible companion the loader bakes alongside `fallback`.
   const fallbackCritical = buildCriticalFallback(root, getInitialVisibleFrames(root, false));
   const stripped = JSON.parse(JSON.stringify(root)) as HastRoot;
-  delete (stripped.children[0] as HastElement).data!.fallback;
+  for (const frame of stripped.children) {
+    delete (frame as HastElement).data!.fallback;
+  }
   return {
     // Fresh object per call so the decode WeakMap never bridges cases.
     source: { hastCompressed: compressHast(JSON.stringify(stripped), fallbackToText(fallback)) },
@@ -88,6 +84,19 @@ function buildCompressedVariant(
   };
 }
 
+/**
+ * Build a `{ hastCompressed }` source with its fallback (see
+ * `compressFramedRoot`). Made long enough that the residual blob clears
+ * `FALLBACK_COMPRESSION_MIN_BYTES` (so the bug's wire-code path is exercised,
+ * not the small-residual inline path).
+ */
+function buildCompressedVariant(
+  seed: string,
+  counts?: { totalLines: number; focusedLines: number; collapsible?: boolean },
+) {
+  return compressFramedRoot(framedRoot(`const ${seed} = "${'x'.repeat(200)}";`, counts));
+}
+
 function ContentLoading(): null {
   return null;
 }
@@ -246,6 +255,122 @@ describe('prepareInitialSource residual round-trip', () => {
   });
 });
 
+describe('prepareInitialSource fallbackCollapsed without a residual blob', () => {
+  const collapsedCounts = { totalLines: 2, focusedLines: 1, collapsible: true };
+  const loadingSourceOf = (fallback: React.ReactNode): FallbackNode[] | undefined =>
+    (fallback as React.ReactElement<{ source?: FallbackNode[] }>).props.source;
+
+  it('keeps the rendered file decodable on a client render (compressResidual: false)', () => {
+    // Two frames with no emphasis: the collapsed window is only the first frame,
+    // so its text is not the dictionary the whole source was compressed with.
+    const variant = compressFramedRoot(
+      framedRoot(
+        [`const button = "${'x'.repeat(200)}";`, `const checkbox = "${'y'.repeat(200)}";`],
+        collapsedCounts,
+      ),
+    );
+    const code = {
+      Default: {
+        fileName: 'Button.tsx',
+        source: variant.source,
+        fallback: variant.fallback,
+        // The stored counts the loader ships, so nothing decodes on the server
+        // (which would warm the decode cache with the full dictionary).
+        ...collapsedCounts,
+      },
+    } as unknown as Code;
+
+    const { fallback, codeForClient, residualFallbacks } = prepareInitialSource({
+      code,
+      initialVariant: 'Default',
+      initialFilename: 'Button.tsx',
+      initialSource: variant.source,
+      ContentLoading,
+      Content,
+      slug: 'slug',
+      name: 'name',
+      fallbackCollapsed: true,
+      compressResidual: false,
+    });
+
+    expect(residualFallbacks).toBeUndefined();
+    // The loading UI still paints only the collapsed window.
+    expect(fallbackToText(loadingSourceOf(fallback)!)).toBe(
+      fallbackToText(variant.fallback.slice(0, 1)),
+    );
+
+    // The client decodes with the variant's own fallback on the code, else the hoist.
+    const dictionary = fallbackOf(codeForClient, 'Default') ?? loadingSourceOf(fallback);
+    expect(() => decodeHastSource(variant.source, dictionary)).not.toThrow();
+  });
+
+  it('keeps the rendered file decodable when the residual is too small to compress', () => {
+    const variant = compressFramedRoot(
+      framedRoot(['const a = 1;', 'const b = 2;'], collapsedCounts),
+    );
+    const code = {
+      Default: {
+        fileName: 'Button.tsx',
+        source: variant.source,
+        fallback: variant.fallback,
+        // The stored counts the loader ships, so nothing decodes on the server
+        // (which would warm the decode cache with the full dictionary).
+        ...collapsedCounts,
+      },
+    } as unknown as Code;
+
+    const { fallback, codeForClient, residualFallbacks } = prepareInitialSource({
+      code,
+      initialVariant: 'Default',
+      initialFilename: 'Button.tsx',
+      initialSource: variant.source,
+      ContentLoading,
+      Content,
+      slug: 'slug',
+      name: 'name',
+      fallbackCollapsed: true,
+    });
+
+    expect(residualFallbacks).toBeUndefined();
+    const dictionary = fallbackOf(codeForClient, 'Default') ?? loadingSourceOf(fallback);
+    expect(() => decodeHastSource(variant.source, dictionary)).not.toThrow();
+  });
+
+  it('still defers the full fallback into the blob when it is worth compressing', () => {
+    const variant = compressFramedRoot(
+      framedRoot(
+        [`const button = "${'x'.repeat(200)}";`, `const checkbox = "${'y'.repeat(200)}";`],
+        collapsedCounts,
+      ),
+    );
+    const code = {
+      Default: {
+        fileName: 'Button.tsx',
+        source: variant.source,
+        fallback: variant.fallback,
+        // The stored counts the loader ships, so nothing decodes on the server
+        // (which would warm the decode cache with the full dictionary).
+        ...collapsedCounts,
+      },
+    } as unknown as Code;
+
+    const { codeForClient, residualFallbacks } = prepareInitialSource({
+      code,
+      initialVariant: 'Default',
+      initialFilename: 'Button.tsx',
+      initialSource: variant.source,
+      ContentLoading,
+      Content,
+      slug: 'slug',
+      name: 'name',
+      fallbackCollapsed: true,
+    });
+
+    expect(residualFallbacks).toBeDefined();
+    expect(fallbackOf(codeForClient, 'Default')).toBeUndefined();
+  });
+});
+
 describe('prepareInitialSource line counts', () => {
   it('reads STORED variant line counts instead of decompressing the source', () => {
     // The loader now hoists the window counts off `root.data` onto the variant
diff --git a/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx b/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx
index d2ffaaa..d13db34 100644
--- a/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx
+++ b/packages/docs/src/CodeHighlighter/prepareInitialSource.tsx
@@ -17,6 +17,7 @@ import {
   extractResidualFallbacks,
   mergeResidualFallbacks,
   residualDictionaryText,
+  scatterResidualFallbacks,
 } from './fallbackCompression';
 import { replaceUrlPrefix } from '../pipeline/loaderUtils/applyUrlPrefix';
 import { getVariantFileLineCounts } from '../useCode/sourceLineCounts';
@@ -325,6 +326,15 @@ export function prepareInitialSource(
     );
     codeForClient = residualFallbacks ? wireCode : strippedCode;
   }
+  // With no blob (a client render, or a residual too small to compress), a
+  // `fallbackCollapsed` block would leave the rendered files' full fallbacks
+  // nowhere: the hoist is only each file's collapsed window, which can't decode
+  // its `hastCompressed` source. Keep them inline on `codeForClient` instead —
+  // the same shape the client produces after decompressing the blob, and the
+  // variant's own fallback wins over the hoist on the client.
+  if (!residualFallbacks && effectiveFallbackCollapsed) {
+    codeForClient = scatterResidualFallbacks(codeForClient, allFallbackHasts);
+  }
 
   // Get the component for the selected variant
   const component = props.components?.[initialVariant];
diff --git a/packages/docs/src/CodeHighlighter/user.spec.tsx b/packages/docs/src/CodeHighlighter/user.spec.tsx
index 1c516f6..03c69ca 100644
--- a/packages/docs/src/CodeHighlighter/user.spec.tsx
+++ b/packages/docs/src/CodeHighlighter/user.spec.tsx
@@ -34,8 +34,7 @@ import { parseControlledCode } from './parseControlledCode';
 import { createParseSource } from '../pipeline/parseSource';
 import { preloadSourceEditingEngine } from '../useCode/useSourceEditing';
 import { preloadTransformEngine } from '../useCode/transformEngineCache';
-import { compressHast } from '../pipeline/hastUtils/hastCompression';
-import { fallbackToText } from './fallbackFormat';
+import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils';
 import type { Code, ContentProps, ControlledCode, HastRoot, ParseSource } from './types';
 
 let parseSource: ParseSource;
@@ -259,21 +258,55 @@ describe('CodeHighlighter rendering', () => {
     await waitFor(() => expect(screen.getByTestId('file').textContent).toBe('app.js'));
   });
 
+  it('offers only the transforms the selected variant declares', async () => {
+    // Only the first variant declares a `js` transform, so the toggle must go
+    // away when the user switches to the second one.
+    window.localStorage.clear();
+    const code = {
+      Typed: {
+        fileName: 'Button.tsx',
+        source: highlighted('const button: number = 1;'),
+        transforms: { js: { fileName: 'Button.jsx', hasDelta: true } },
+      },
+      Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') },
+    } as unknown as Code;
+
+    render(
+      
+        
+      ,
+    );
+
+    expect(screen.getByTestId('transform:js')).toBeTruthy();
+
+    act(() => {
+      screen.getByTestId('variant:Plain').click();
+    });
+    await waitFor(() =>
+      expect(screen.getByTestId('code').textContent).toContain('const button = 1;'),
+    );
+    expect(screen.queryByTestId('transform:js')).toBeNull();
+
+    act(() => {
+      screen.getByTestId('variant:Typed').click();
+    });
+    await waitFor(() =>
+      expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'),
+    );
+    expect(screen.getByTestId('transform:js')).toBeTruthy();
+  });
+
   it('keeps a transform applied when the user switches to a variant whose file has the same name', async () => {
     // Production payloads compress each file with its own `fallback` text as the
     // DEFLATE dictionary. Both variants name their file `app.ts`, so the second
     // must decode with ITS dictionary, not the first variant's same-named one.
+    // Start from no stored transform preference so the toggle is exercised.
+    window.localStorage.clear();
     await preloadTransformEngine();
-    const compressed = (text: string) => {
-      const fallback = [text];
-      return {
-        source: {
-          hastCompressed: compressHast(JSON.stringify(highlighted(text)), fallbackToText(fallback)),
-        },
-        fallback,
-        totalLines: 1,
-      };
-    };
+    const compressed = (text: string) => ({
+      ...createCompressedFile(text, highlighted(text)),
+      totalLines: 1,
+    });
     const toJs = (text: string) => ({
       js: {
         fileName: 'app.js',
@@ -304,6 +337,7 @@ describe('CodeHighlighter rendering', () => {
       ,
     );
 
+    expect(screen.getByTestId('transform').textContent).toBe('none');
     act(() => {
       screen.getByTestId('transform:js').click();
     });
diff --git a/packages/docs/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts b/packages/docs/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
index 8845d32..6f53ac3 100644
--- a/packages/docs/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
+++ b/packages/docs/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
@@ -119,7 +119,7 @@ const e = 5; // @highlight`,
         const b = 2;
         const c = 3;
         const d = 4;
-        "
+        const e = 5;"
       `,
       );
     });
@@ -488,7 +488,7 @@ const e = 5; // @highlight`,
       );
 
       expect(result).toMatchInlineSnapshot(
-        `"<Input value={a} /> <Input value={b} /> // @highlight-text "value""`,
+        `"<Input value={a} /> <Input value={b} />"`,
       );
     });
 
@@ -530,7 +530,8 @@ const e = 5; // @highlight`,
         createEnhanceCodeEmphasis({ strictHighlightText: true }),
       );
 
-      expect(result).toContain('');
+      // The element itself becomes the mark, keeping its classes.
+      expect(result).toContain('value');
       expect(result).not.toContain('data-hl-part');
     });
 
@@ -623,7 +624,7 @@ const another = 99; // @highlight`,
           const y = 2;
           return x + y;
         }
-        "
+        const another = 99;"
       `);
     });
 
diff --git a/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts b/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts
new file mode 100644
index 0000000..242a32d
--- /dev/null
+++ b/packages/docs/src/pipeline/hastUtils/hastCompression.testUtils.ts
@@ -0,0 +1,36 @@
+/**
+ * Test fixtures for `hastCompression`. Kept in a sibling file so the
+ * production module's public surface stays free of helpers that only tests
+ * should reach.
+ */
+import type { Root as HastRoot } from 'hast';
+import { compressHast } from './hastCompress';
+import { fallbackToText } from './fallbackFormat';
+import type { FallbackNode } from './fallbackFormat';
+
+/**
+ * Builds a `hastCompressed` file the way production payloads are compressed:
+ * the DEFLATE dictionary is the file's own `fallback` text, so the source
+ * decodes only with that same `fallback`. `root` is the HAST that gets
+ * compressed and defaults to a single `` holding `text`.
+ */
+export function createCompressedFile(
+  text: string,
+  root: HastRoot = {
+    type: 'root',
+    children: [
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: {},
+        children: [{ type: 'text', value: text }],
+      },
+    ],
+  },
+): { source: { hastCompressed: string }; fallback: FallbackNode[] } {
+  const fallback: FallbackNode[] = [text];
+  return {
+    source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) },
+    fallback,
+  };
+}
diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
index c64ab85..9c5e500 100644
--- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
+++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
@@ -418,10 +418,10 @@ export function Counter() {
         const { relative, externals } = parseImportsAndComments(code, '/src/Counter.tsx');
         return {
           relative: Object.fromEntries(
-            Object.entries(relative).map(([key, { names: n }]) => [key, n]),
+            Object.entries(relative).map(([key, { names: importNames }]) => [key, importNames]),
           ),
           externals: Object.fromEntries(
-            Object.entries(externals).map(([key, { names: n }]) => [key, n]),
+            Object.entries(externals).map(([key, { names: importNames }]) => [key, importNames]),
           ),
         };
       };
@@ -453,19 +453,6 @@ import { Chart } from './Chart'
       expect(Object.keys(result.relative)).toEqual(['./data.json', './Chart']);
       expect(result.relative['./Chart'].names).toEqual([{ name: 'Chart', type: 'named' }]);
     });
-
-    it('leaves a statement unchanged when a block comment comes before its semicolon', () => {
-      const code = `import { Button } from './Button' /* the trigger */;
-import { Dialog } from './Dialog';
-`;
-      const result = parseImportsAndComments(code, '/src/Demo.tsx', {
-        removeCommentsWithPrefix: ['the trigger'],
-      });
-
-      expect(Object.keys(result.relative)).toEqual(['./Button', './Dialog']);
-      // The comment sits inside the semicolon-terminated statement, so it stays.
-      expect(result.code).toBeUndefined();
-    });
   });
 
   describe('http(s) URL support', () => {
@@ -1090,6 +1077,58 @@ export default function CheckboxBasic() {
         },
       });
     });
+
+    it('reads the imports after a comment with an apostrophe inside import braces', () => {
+      const code = `import {
+  Button, // don't wrap
+  Checkbox,
+} from './Button';
+import { Dialog } from './Dialog';
+`;
+      const result = parseImportsAndComments(code, '/src/demo.ts');
+
+      expect(Object.keys(result.relative)).toEqual(['./Button', './Dialog']);
+      expect(result.relative['./Button'].names).toEqual([
+        { name: 'Button', type: 'named' },
+        { name: 'Checkbox', type: 'named' },
+      ]);
+    });
+
+    it('does not read import.meta as an import', () => {
+      const code = `import { createDemo } from './createDemo';
+import { Button } from './Button';
+
+export const DemoButton = createDemo(import.meta.url, Button, {
+  description: "Reads rows from './rows.json'", // @highlight
+});
+`;
+      const result = parseImportsAndComments(code, '/src/index.ts', {
+        removeCommentsWithPrefix: ['@highlight'],
+      });
+
+      expect(Object.keys(result.relative)).toEqual(['./createDemo', './Button']);
+      expect(result.comments).toEqual({ 5: ['@highlight'] });
+    });
+
+    it('reads an import after a string with an escaped quote', () => {
+      const code = `const label = 'it\\'s';
+import { Button } from './Button';
+`;
+      const result = parseImportsAndComments(code, '/src/demo.ts');
+
+      expect(Object.keys(result.relative)).toEqual(['./Button']);
+    });
+
+    it('reads an MDX import after inline code that ends in a backslash', () => {
+      // Markdown code spans have no escapes, so the backslash doesn't escape the backtick.
+      const code = `Type \`\\\` to escape a character.
+
+import { Button } from './Button';
+`;
+      const result = parseImportsAndComments(code, '/src/demo.mdx');
+
+      expect(Object.keys(result.relative)).toEqual(['./Button']);
+    });
   });
 
   // Test cases for CSS @import parsing
@@ -2490,6 +2529,24 @@ export default function CheckboxBasic() {
         },
       });
     });
+
+    it('ignores the word import in prose, so code blocks after it stay code blocks', () => {
+      const code = `import { Button } from './Button';
+
+Each import's \`url\` is resolved. Then import 'setup' before anything else.
+
+\`\`\`ts
+import { Checkbox } from './Checkbox';
+import styles from './styles.module.css';
+\`\`\`
+
+Pass the 'flat' mode.
+`;
+      const result = parseImportsAndComments(code, '/src/demo.mdx');
+
+      expect(Object.keys(result.relative)).toEqual(['./Button']);
+      expect(result.externals).toEqual({});
+    });
   });
 });
 
@@ -2797,6 +2854,110 @@ import { Button } from '@scope/design';`;
       },
     });
   });
+
+  it('keeps a line that has a stripped block comment followed by a stripped line comment', () => {
+    const code = `import { Button } from './Button' /* @highlight */ // eslint-disable-line
+import { Checkbox } from './Checkbox'
+const count = 1; /* @highlight */ // eslint-disable-line
+const total = 2;
+`;
+
+    const result = parseImportsAndComments(code, '/src/test.tsx', {
+      removeCommentsWithPrefix: ['@highlight', 'eslint-disable'],
+      notableCommentsPrefix: ['@highlight'],
+    });
+
+    expect(result.code).toBe(`import { Button } from './Button'
+import { Checkbox } from './Checkbox'
+const count = 1;
+const total = 2;
+`);
+    expect(result.comments).toEqual({ 1: ['@highlight'], 3: ['@highlight'] });
+    // Import positions point into the stripped code.
+    const checkboxStart = result.code!.indexOf("'./Checkbox'");
+    expect(result.relative['./Checkbox'].positions).toEqual([
+      { start: checkboxStart, end: checkboxStart + "'./Checkbox'".length },
+    ]);
+  });
+
+  it('keeps the last line when it ends in a comment and the file has no trailing newline', () => {
+    const stripped = parseImportsAndComments(
+      `import * as React from 'react'
+import { Button } from './Button' // @highlight`,
+      '/src/test.tsx',
+      { removeCommentsWithPrefix: ['@highlight'] },
+    );
+    const kept = parseImportsAndComments(
+      `const count = 1; // @highlight
+const total = 2; // the sum`,
+      '/src/test.tsx',
+      { removeCommentsWithPrefix: ['@highlight'] },
+    );
+
+    expect(stripped.code).toBe(`import * as React from 'react'
+import { Button } from './Button'`);
+    expect(stripped.comments).toEqual({ 2: ['@highlight'] });
+    expect(kept.code).toBe(`const count = 1;
+const total = 2; // the sum`);
+  });
+
+  it('strips a comment written directly after the module path', () => {
+    const code = `import { Button } from './Button'// @highlight
+const count = 1
+`;
+
+    const result = parseImportsAndComments(code, '/src/test.tsx', {
+      removeCommentsWithPrefix: ['@highlight'],
+    });
+
+    expect(result.code).toBe(`import { Button } from './Button'
+const count = 1
+`);
+    expect(result.comments).toEqual({ 1: ['@highlight'] });
+  });
+
+  it('strips a matching block comment between the module path and the semicolon', () => {
+    const code = `import { Button } from './Button' /* @highlight */;
+import { Dialog } from './Dialog';
+`;
+
+    const result = parseImportsAndComments(code, '/src/test.tsx', {
+      removeCommentsWithPrefix: ['@highlight'],
+    });
+
+    expect(result.code).toBe(`import { Button } from './Button';
+import { Dialog } from './Dialog';
+`);
+    expect(result.comments).toEqual({ 1: ['@highlight'] });
+    expect(Object.keys(result.relative)).toEqual(['./Button', './Dialog']);
+  });
+
+  it('keeps CRLF line endings on lines whose trailing comment is stripped', () => {
+    const code = `import { Button } from './Button' // @highlight\r\nconst count = 1; // @highlight\r\nconst total = 2;\r\n`;
+
+    const result = parseImportsAndComments(code, '/src/test.tsx', {
+      removeCommentsWithPrefix: ['@highlight'],
+    });
+
+    expect(result.code).toBe(
+      `import { Button } from './Button'\r\nconst count = 1;\r\nconst total = 2;\r\n`,
+    );
+  });
+
+  it('strips a comment that follows a string with an escaped quote', () => {
+    const code = `const label = 'it\\'s'; // @highlight
+const count = 1; // @highlight
+`;
+
+    const result = parseImportsAndComments(code, '/src/test.tsx', {
+      removeCommentsWithPrefix: ['@highlight'],
+    });
+
+    expect(result.code).toBe(`const label = 'it\\'s';
+const count = 1;
+`);
+    expect(result.comments).toEqual({ 1: ['@highlight'], 2: ['@highlight'] });
+  });
 });
 
 describe('parseImportsAndComments CSS with comment stripping', () => {
@@ -3588,6 +3749,56 @@ describe('Export-from statement parsing', () => {
     });
   });
 
+  it('ignores a `from` in the body of an exported declaration', () => {
+    const withoutSemicolons = `import * as React from 'react'
+
+export function Button() {
+  // Adapted from "a design guide"
+  const label = 'from the theme'
+  return 

Loaded from './data.json'

+} + +export { Checkbox } from './Checkbox' +`; + const withSemicolons = `import * as React from 'react'; + +export function Button() { + // Adapted from "a design guide" + const label = 'from the theme'; + return

Loaded from './data.json'

; +} + +export { Checkbox } from './Checkbox'; +`; + + for (const code of [withoutSemicolons, withSemicolons]) { + const result = parseImportsAndComments(code, '/src/Button.tsx'); + expect(Object.keys(result.relative)).toEqual(['./Checkbox']); + expect(Object.keys(result.externals)).toEqual(['react']); + } + }); + + it('should parse namespace and type-only star re-exports', () => { + const code = `export * as Icons from './icons' +export type * from './types' +`; + const result = parseImportsAndComments(code, '/src/index.ts'); + + expect(result.relative).toEqual({ + './icons': { + url: 'file:///src/icons', + names: [{ name: 'Icons', type: 'namespace' }], + positions: [{ start: 23, end: 32 }], + }, + './types': { + url: 'file:///src/types', + names: [], + includeTypeDefs: true, + positions: [{ start: 52, end: 61 }], + }, + }); + }); + it('should parse mixed import and export-from statements', async () => { const code = ` import React from 'react'; diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts index e0282f9..df623bb 100644 --- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts +++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts @@ -171,6 +171,17 @@ function countBackticks(sourceText: string, startPos: number): number { return count; } +/** + * Whether only spaces or tabs come before `pos` on its line. + */ +function isAtLineStart(text: string, pos: number): boolean { + let cursor = pos; + while (cursor > 0 && (text[cursor - 1] === ' ' || text[cursor - 1] === '\t')) { + cursor -= 1; + } + return cursor === 0 || text[cursor - 1] === '\n'; +} + /** * Generic function to scan source code character-by-character, finding import statements * while correctly handling strings, comments, and template literals. Optionally processes @@ -244,9 +255,19 @@ function scanForImports( // Comment stripping variables let commentStart = 0; let commentStartOutputLine = 0; - let lineStartPos = 0; let preCommentContent = ''; + // Takes back what the current output line already holds, so a comment's + // handler can re-add it (trimmed, or not at all for a comment-only line). It's + // read from the output rather than the source: a comment stripped earlier on + // the same line is already gone from the output. + const takeBackOutputLine = () => { + const outputLineStart = result.lastIndexOf('\n') + 1; + preCommentContent = result.slice(outputLineStart); + result = result.slice(0, outputLineStart); + processedPos -= preCommentContent.length; + }; + while (i < len) { const ch = sourceCode[i]; const next = sourceCode[i + 1]; @@ -259,7 +280,6 @@ function scanForImports( processedPos += 1; } outputLine += 1; - lineStartPos = i + 1; i += 1; continue; } @@ -284,11 +304,7 @@ function scanForImports( if (shouldProcessComments) { commentStart = i; commentStartOutputLine = outputLine; - // Remove content that was already added to result for this line - const contentSinceLineStart = sourceCode.slice(lineStartPos, commentStart); - result = result.slice(0, result.length - contentSinceLineStart.length); - processedPos -= contentSinceLineStart.length; - preCommentContent = contentSinceLineStart; + takeBackOutputLine(); } state = 'singleline-comment'; i += 2; @@ -299,11 +315,7 @@ function scanForImports( if (shouldProcessComments) { commentStart = i; commentStartOutputLine = outputLine; - // Remove content that was already added to result for this line - const contentSinceLineStart = sourceCode.slice(lineStartPos, commentStart); - result = result.slice(0, result.length - contentSinceLineStart.length); - processedPos -= contentSinceLineStart.length; - preCommentContent = contentSinceLineStart; + takeBackOutputLine(); } state = 'multiline-comment'; i += 2; @@ -342,8 +354,14 @@ function scanForImports( return (positionMapping.get(closest) || 0) + offset; }; - // Use the provided import detector on the original source code - const detection = importDetector(sourceCode, i, positionMapper); + // Use the provided import detector on the original source code. In MDX, an + // `import` or `export` is ESM only at the start of a line; elsewhere it's + // prose (e.g. "each import's path"), which must not swallow the text after + // it — and with it the start of a code block. + const detection = + isMdxFile && !isAtLineStart(sourceCode, i) + ? { found: false, nextPos: i } + : importDetector(sourceCode, i, positionMapper); if (detection.found) { if (detection.statement) { statements.push(detection.statement); @@ -357,7 +375,6 @@ function scanForImports( for (let j = 0; j < importText.length; j += 1) { if (importText[j] === '\n') { outputLine += 1; - lineStartPos = i + j + 1; } } } @@ -405,10 +422,12 @@ function scanForImports( // Don't add the pre-comment content or newline for comment-only lines // Skip the newline entirely } else { - // Comment is inline, keep the pre-comment content (with trailing whitespace trimmed) and newline + // Comment is inline, keep the pre-comment content (with trailing whitespace trimmed) and newline. + // A CRLF line's `\r` is the comment's last character, so restore it with the newline. + const lineBreak = sourceCode[i - 1] === '\r' ? '\r\n' : '\n'; result += preCommentContent.trimEnd(); - result += '\n'; - processedPos += preCommentContent.trimEnd().length + 1; + result += lineBreak; + processedPos += preCommentContent.trimEnd().length + lineBreak.length; outputLine += 1; } } else { @@ -420,7 +439,6 @@ function scanForImports( outputLine += 1; } preCommentContent = ''; - lineStartPos = i + 1; } state = 'code'; } @@ -487,9 +505,6 @@ function scanForImports( if (i < len && sourceCode[i] === '\n') { // Skip the newline entirely - advance to the character after it i += 1; - lineStartPos = i; - } else { - lineStartPos = i; } state = 'code'; preCommentContent = ''; @@ -537,9 +552,12 @@ function scanForImports( if (state === 'string') { if (ch === '\n') { outputLine += 1; - lineStartPos = i + 1; } - if (ch === '\\\\') { + // An escaped character (e.g. `\'`) never ends the string. + if (ch === '\\') { + if (next === '\n') { + outputLine += 1; + } if (shouldProcessComments) { result += sourceCode.slice(i, i + 2); processedPos += 2; @@ -561,7 +579,6 @@ function scanForImports( if (state === 'template') { if (ch === '\n') { outputLine += 1; - lineStartPos = i + 1; } if (ch === '`') { state = 'code'; @@ -573,7 +590,12 @@ function scanForImports( i += 1; continue; } - if (ch === '\\\\') { + // An escaped character (such as an escaped backtick) never ends a template + // literal. MDX inline code has no escapes, so a backslash there is literal. + if (ch === '\\' && !isMdxFile) { + if (next === '\n') { + outputLine += 1; + } if (shouldProcessComments) { result += sourceCode.slice(i, i + 2); processedPos += 2; @@ -591,7 +613,6 @@ function scanForImports( if (state === 'codeblock') { if (ch === '\n') { outputLine += 1; - lineStartPos = i + 1; } // Look for closing backticks that match or exceed the opening count if (ch === '`') { @@ -640,11 +661,15 @@ function scanForImports( comments[commentLine].push(...stripCommentMarkers(commentText)); } + // As in the loop, keep the code before the comment on its line (trimmed when the + // comment is stripped), which was taken back from the output when the comment began. if (shouldStrip) { anyCommentStripped = true; + result += preCommentContent.trimEnd(); + processedPos += preCommentContent.trimEnd().length; } else { - result += commentText; - processedPos += commentText.length; + result += preCommentContent + commentText; + processedPos += preCommentContent.length + commentText.length; } } @@ -1562,45 +1587,203 @@ function parseJSImports( }; } +/** + * Whether `word` stands alone at `pos` in `text`, with no identifier character + * directly before or after it. + */ +function isKeywordAt(text: string, pos: number, word: string): boolean { + return ( + text.startsWith(word, pos) && + (pos === 0 || !isIdentifierChar(text[pos - 1])) && + !isIdentifierChar(text[pos + word.length] || '') + ); +} + +/** + * Skips a string, template literal or comment that starts at `pos`, returning + * the index just past it (a line comment stops at its line break, so the caller + * still sees the break). Returns `pos` unchanged when none starts there. + */ +function skipStringOrComment(text: string, pos: number): number { + const ch = text[pos]; + if (ch === '/' && text[pos + 1] === '/') { + const lineEnd = text.indexOf('\n', pos + 2); + return lineEnd === -1 ? text.length : lineEnd; + } + if (ch === '/' && text[pos + 1] === '*') { + const commentEnd = text.indexOf('*/', pos + 2); + return commentEnd === -1 ? text.length : commentEnd + 2; + } + if (isStringStart(ch)) { + let cursor = pos + 1; + while (cursor < text.length && text[cursor] !== ch) { + cursor += text[cursor] === '\\' ? 2 : 1; + } + return Math.min(cursor + 1, text.length); + } + return pos; +} + /** * Finds where an import or export-from statement ends once its module path has * been read, looking ahead from `pos` on the same line. Returns the index just - * past a `;` that ends it (block comments before the `;` stay inside the - * statement). Without a `;`, the end of the line ends the statement (automatic - * semicolon insertion), so returns the index of the line break, or of the first - * trailing comment so the comment is still scanned. Returns `-1` when something - * else follows on the line (e.g. an import attributes clause), so the caller - * keeps scanning. + * past a `;` that ends it. Without a `;`, the end of the line ends the statement + * (automatic semicolon insertion), so returns the index of the line break. When + * comments come before either, returns the index of the first comment instead, + * so they're still scanned for stripping and collection. Returns `-1` when + * something else follows on the line (e.g. an import attributes clause), so the + * caller keeps scanning. */ function findStatementEndAfterModulePath(text: string, pos: number): number { const len = text.length; - let k = pos; + let cursor = pos; let firstCommentStart = -1; for (;;) { - while (k < len && (text[k] === ' ' || text[k] === '\t' || text[k] === '\r')) { - k += 1; + while ( + cursor < len && + (text[cursor] === ' ' || text[cursor] === '\t' || text[cursor] === '\r') + ) { + cursor += 1; } - if (k < len && text[k] === ';') { - return k + 1; + if (cursor < len && text[cursor] === ';') { + return firstCommentStart === -1 ? cursor + 1 : firstCommentStart; } - const lineEnds = k >= len || text[k] === '\n' || (text[k] === '/' && text[k + 1] === '/'); + const lineEnds = + cursor >= len || text[cursor] === '\n' || (text[cursor] === '/' && text[cursor + 1] === '/'); if (lineEnds) { - return firstCommentStart === -1 ? k : firstCommentStart; + return firstCommentStart === -1 ? cursor : firstCommentStart; } - if (text[k] !== '/' || text[k + 1] !== '*') { + if (text[cursor] !== '/' || text[cursor + 1] !== '*') { return -1; } if (firstCommentStart === -1) { - firstCommentStart = k; + firstCommentStart = cursor; } - const commentEnd = text.indexOf('*/', k + 2); + const commentEnd = text.indexOf('*/', cursor + 2); if (commentEnd === -1) { return firstCommentStart; } - k = commentEnd + 2; + cursor = commentEnd + 2; } } +/** + * Finds the end of a static import or export-from statement whose keyword ends + * at `start`. Strings, template literals and comments are skipped whole, so a + * quote, brace, `;` or `from` inside one never counts. The statement ends at a + * `;`; without one, at the end of the line holding its module path (see + * `findStatementEndAfterModulePath`), or — for a statement with no `from` or + * module path, such as TypeScript's `import x = require('./x')` — at its line + * break. + */ +function findModuleStatementEnd(text: string, start: number): number { + const len = text.length; + let cursor = start; + let braceDepth = 0; + let foundFrom = false; + let foundModulePath = false; + let sawToken = false; + + while (cursor < len) { + const ch = text[cursor]; + if (ch === ';') { + return cursor + 1; + } + if (ch === '\n' && !foundFrom && !foundModulePath && braceDepth === 0) { + // Keep going only when the next line continues the statement. + const nextToken = skipWhitespace(text, cursor + 1); + if ( + nextToken < len && + !isKeywordAt(text, nextToken, 'from') && + !isStringStart(text[nextToken]) + ) { + return cursor + 1; + } + } + + const skipped = skipStringOrComment(text, cursor); + if (skipped !== cursor) { + if (isStringStart(ch)) { + // The module path is the quoted string after `from`, or the one that + // opens a side-effect import (`import './styles.css'`). + const isModulePath = + ch !== '`' && braceDepth === 0 && !foundModulePath && (foundFrom || !sawToken); + sawToken = true; + if (isModulePath) { + foundModulePath = true; + const end = findStatementEndAfterModulePath(text, skipped); + if (end !== -1) { + return end; + } + } + } + cursor = skipped; + continue; + } + + if (ch === '{') { + braceDepth += 1; + } else if (ch === '}') { + braceDepth -= 1; + } else if (braceDepth === 0 && isKeywordAt(text, cursor, 'from')) { + foundFrom = true; + } + if (!isWhitespace(ch)) { + sawToken = true; + } + // Past the module path, only an attributes clause (`with { … }`) may follow + // before the statement ends. + if (foundModulePath && braceDepth === 0) { + const end = findStatementEndAfterModulePath(text, cursor); + if (end !== -1) { + return end; + } + } + cursor += 1; + } + + return cursor; +} + +/** + * Whether the `export` keyword ending at `pos` begins an export-from statement: + * `export * from '…'`, `export * as name from '…'` or `export { … } from '…'`, + * each optionally as `export type`. Only a `from` clause right after the `*` or + * the braces counts, so a `from` later in an exported declaration — in a + * function body, a string, JSX text or a comment — doesn't make one. + */ +function isExportFromStatement(text: string, pos: number): boolean { + let cursor = skipWhitespaceAndComments(text, pos); + if (isKeywordAt(text, cursor, 'type')) { + cursor = skipWhitespaceAndComments(text, cursor + 4); + } + if (text[cursor] === '*') { + cursor = skipWhitespaceAndComments(text, cursor + 1); + if (isKeywordAt(text, cursor, 'as')) { + cursor = skipWhitespaceAndComments(text, cursor + 2); + // The namespace name is an identifier or a string (`export * as 'name' from`). + cursor = isStringStart(text[cursor]) + ? skipStringOrComment(text, cursor) + : readIdentifier(text, cursor).nextPos; + cursor = skipWhitespaceAndComments(text, cursor); + } + } else if (text[cursor] === '{') { + cursor += 1; + while (cursor < text.length && text[cursor] !== '}') { + const skipped = skipStringOrComment(text, cursor); + cursor = skipped === cursor ? cursor + 1 : skipped; + } + cursor = skipWhitespaceAndComments(text, cursor + 1); + } else { + return false; + } + if (!isKeywordAt(text, cursor, 'from')) { + return false; + } + const modulePathStart = skipWhitespaceAndComments(text, cursor + 4); + return text[modulePathStart] === "'" || text[modulePathStart] === '"'; +} + /** * Detects JavaScript import and export-from statements at a given position in source code. * @param sourceText - The source text to scan @@ -1615,136 +1798,40 @@ function detectJavaScriptImport( ) { const ch = sourceText[pos]; - // Look for 'export' keyword followed by 'from' (export ... from '...') + // An `export … from '…'` re-export. Other exports declare local bindings and + // aren't import statements. if ( ch === 'e' && - sourceText.slice(pos, pos + 6) === 'export' && - (pos === 0 || /[^a-zA-Z0-9_$]/.test(sourceText[pos - 1])) && - /[^a-zA-Z0-9_$]/.test(sourceText[pos + 6] || '') + isKeywordAt(sourceText, pos, 'export') && + isExportFromStatement(sourceText, pos + 6) ) { - // Check if this export statement has a 'from' clause - const exportStart = pos; - const len = sourceText.length; - let j = pos + 6; - - // Skip whitespace and look ahead for 'from' keyword - let hasFrom = false; - let tempPos = j; - let tempBraceDepth = 0; - - while (tempPos < len) { - const tempCh = sourceText[tempPos]; - if (tempCh === '{') { - tempBraceDepth += 1; - } else if (tempCh === '}') { - tempBraceDepth -= 1; - } else if ( - sourceText.slice(tempPos, tempPos + 4) === 'from' && - /\s/.test(sourceText[tempPos + 4] || '') - ) { - hasFrom = true; - break; - } else if (tempCh === ';' || (tempCh === '\n' && tempBraceDepth === 0)) { - break; - } - tempPos += 1; - } - - if (!hasFrom) { - // This is not an export-from statement, skip it - return { found: false, nextPos: pos }; - } - - // Now scan to find the end of the export-from statement - let exportState: 'code' | 'string' | 'template' = 'code'; - let exportQuote: string | null = null; - let braceDepth = 0; - let foundFrom = false; - let foundModulePath = false; - - while (j < len) { - const cj = sourceText[j]; - if (exportState === 'code') { - if (cj === ';') { - j += 1; - break; - } - if (isStringStart(cj)) { - exportState = cj === '`' ? 'template' : 'string'; - exportQuote = cj; - if (foundFrom) { - foundModulePath = true; - } - j += 1; - continue; - } - if (cj === '{') { - braceDepth += 1; - } - if (cj === '}') { - braceDepth -= 1; - } - if (sourceText.slice(j, j + 4) === 'from' && /\s/.test(sourceText[j + 4] || '')) { - foundFrom = true; - } - if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) { - const end = findStatementEndAfterModulePath(sourceText, j); - if (end !== -1) { - j = end; - break; - } - } - } else if (exportState === 'string') { - if (cj === '\\') { - j += 2; - continue; - } - if (cj === exportQuote) { - exportState = 'code'; - exportQuote = null; - } - j += 1; - continue; - } else if (exportState === 'template') { - if (cj === '`') { - exportState = 'code'; - exportQuote = null; - } else if (cj === '\\') { - j += 2; - continue; - } - j += 1; - continue; - } - j += 1; - } - - const exportText = sourceText.slice(exportStart, j); + const end = findModuleStatementEnd(sourceText, pos + 6); return { found: true, - nextPos: j, - statement: { start: exportStart, end: j, text: exportText }, + nextPos: end, + statement: { start: pos, end, text: sourceText.slice(pos, end) }, }; } // Look for 'import' keyword (not part of an identifier, and not preceded by `@` // or `.` — the latter would be a member call like `obj.import('./x')`, not a - // dynamic import). + // dynamic import). `import.meta` is an expression, not an import either. if ( ch === 'i' && sourceText.slice(pos, pos + 6) === 'import' && (pos === 0 || /[^a-zA-Z0-9_$@.]/.test(sourceText[pos - 1])) && - /[^a-zA-Z0-9_$]/.test(sourceText[pos + 6] || '') + /[^a-zA-Z0-9_$]/.test(sourceText[pos + 6] || '') && + sourceText[skipWhitespace(sourceText, pos + 6)] !== '.' ) { // Mark start of import statement const importStart = pos; const len = sourceText.length; // Dynamic import `import(...)`: end the statement at the matching `)`. The - // generic statement scan below ends at the next `;` (a dynamic import has no - // `from`), which would swallow a LATER `import('./b')` in the same expression - // (e.g. a one-lined tab map) and leave it undetected. Closing at `)` lets the - // scanner resume and find each one. Parens, strings, templates AND comments are + // static statement scan below runs on to the next `;` or line break (a dynamic + // import has no `from`), which would swallow a LATER `import('./b')` in the + // same expression (e.g. a one-lined tab map) and leave it undetected. Closing + // at `)` lets the scanner resume and find each one. Parens, strings, templates AND comments are // tracked so a nested paren, the quoted specifier, or a `)`/quote inside a // `/* … */` (e.g. a webpack magic comment or `/* user's tab */`) doesn't end it // early. @@ -1810,95 +1897,11 @@ function detectJavaScriptImport( }; } - // Now, scan forward to find the end of the statement (semicolon or proper end for side-effect imports) - let j = pos + 6; - let importState: 'code' | 'string' | 'template' = 'code'; - let importQuote: string | null = null; - let braceDepth = 0; - let foundFrom = false; - let foundModulePath = false; - - while (j < len) { - const cj = sourceText[j]; - if (importState === 'code') { - if (cj === ';') { - j += 1; - break; - } - // Check if we're at a bare import statement (no 'from') - if (cj === '\n' && !foundFrom && !foundModulePath && braceDepth === 0) { - // This might be a side-effect import or end of statement - // Look ahead to see if there's content that could be part of the import - let k = j + 1; - while (k < len && /\s/.test(sourceText[k])) { - k += 1; - } - if (k >= len || sourceText.slice(k, k + 4) === 'from' || isStringStart(sourceText[k])) { - // Continue, this newline is within the import - } else { - // This looks like the end of a side-effect import - j += 1; - break; - } - } - if (isStringStart(cj)) { - importState = cj === '`' ? 'template' : 'string'; - importQuote = cj; - // The module path follows `from`, or is the quoted string that opens a - // side-effect import (`import './styles.css'`). - if (foundFrom || (cj !== '`' && sourceText.slice(pos + 6, j).trim() === '')) { - foundModulePath = true; - } - j += 1; - continue; - } - if (cj === '{') { - braceDepth += 1; - } - if (cj === '}') { - braceDepth -= 1; - } - if (sourceText.slice(j, j + 4) === 'from' && /\s/.test(sourceText[j + 4] || '')) { - foundFrom = true; - } - // If we found a module path and we're back to normal code, we might be done - if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) { - const end = findStatementEndAfterModulePath(sourceText, j); - if (end !== -1) { - j = end; - break; - } - } - } else if (importState === 'string') { - if (cj === '\\') { - j += 2; - continue; - } - if (cj === importQuote) { - importState = 'code'; - importQuote = null; - } - j += 1; - continue; - } else if (importState === 'template') { - if (cj === '`') { - importState = 'code'; - importQuote = null; - } else if (cj === '\\') { - j += 2; - continue; - } - j += 1; - continue; - } - j += 1; - } - - const importText = sourceText.slice(importStart, j); + const end = findModuleStatementEnd(sourceText, pos + 6); return { found: true, - nextPos: j, - statement: { start: importStart, end: j, text: importText }, + nextPos: end, + statement: { start: importStart, end, text: sourceText.slice(importStart, end) }, }; } diff --git a/packages/docs/src/useCode/SourceEditingEngine.test.ts b/packages/docs/src/useCode/SourceEditingEngine.test.ts new file mode 100644 index 0000000..129af81 --- /dev/null +++ b/packages/docs/src/useCode/SourceEditingEngine.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { toControlledCode } from './SourceEditingEngine'; +import { stringOrHastToString } from '../pipeline/hastUtils'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; +import type { Code } from '../CodeHighlighter/types'; + +describe('toControlledCode', () => { + it('converts plain string sources and string extra files', () => { + const code: Code = { + Default: { + fileName: 'Button.tsx', + source: 'const button = 1;', + extraFiles: { 'styles.css': '.button {}' }, + }, + }; + + const controlled = toControlledCode(code, 'Default', undefined, stringOrHastToString); + + expect(controlled.Default?.source).toBe('const button = 1;'); + expect(controlled.Default?.extraFiles?.['styles.css']?.source).toBe('.button {}'); + }); + + describe('compressed sources', () => { + it('decodes the main file with its own fallback when the fallbacks map holds a same-named file of another variant', () => { + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); + const code: Code = { + Second: { fileName: 'Button.tsx', ...second }, + }; + + const controlled = toControlledCode( + code, + 'Second', + { 'Button.tsx': first.fallback }, + stringOrHastToString, + ); + + expect(controlled.Second?.source).toBe('const second: number = 2;'); + }); + + it('decodes an extra file with its own fallback when the fallbacks map holds a same-named file of another variant', () => { + const firstStyles = createCompressedFile('.first { color: red; }'); + const secondStyles = createCompressedFile('.second { color: blue; }'); + const code: Code = { + Second: { + fileName: 'Button.tsx', + source: 'const second = 2;', + extraFiles: { 'styles.css': secondStyles }, + }, + }; + + const controlled = toControlledCode( + code, + 'Second', + { 'styles.css': firstStyles.fallback }, + stringOrHastToString, + ); + + expect(controlled.Second?.extraFiles?.['styles.css']?.source).toBe( + '.second { color: blue; }', + ); + }); + + it('decodes the active variant with the fallbacks map when its own fallback was stripped', () => { + const button = createCompressedFile('const button: number = 1;'); + const styles = createCompressedFile('.button { color: red; }'); + const code: Code = { + Default: { + fileName: 'Button.tsx', + source: button.source, + extraFiles: { 'styles.css': { source: styles.source } }, + }, + }; + + const controlled = toControlledCode( + code, + 'Default', + { 'Button.tsx': button.fallback, 'styles.css': styles.fallback }, + stringOrHastToString, + ); + + expect(controlled.Default?.source).toBe('const button: number = 1;'); + expect(controlled.Default?.extraFiles?.['styles.css']?.source).toBe( + '.button { color: red; }', + ); + }); + }); +}); diff --git a/packages/docs/src/useCode/SourceEditingEngine.ts b/packages/docs/src/useCode/SourceEditingEngine.ts index cc17e75..1fa6259 100644 --- a/packages/docs/src/useCode/SourceEditingEngine.ts +++ b/packages/docs/src/useCode/SourceEditingEngine.ts @@ -437,11 +437,13 @@ export function toControlledCode( // source. It rides on the `VariantCode` in the no-`ContentLoading` path; on // the `ContentLoading` path the active variant's fallback is stripped off // `Code` and is hoisted into the highlighter context instead, which `useCode` - // resolves for this variant (`activeFallbacks`) — so prefer that for the - // active variant, falling back to the variant's field. + // resolves for this variant (`activeFallbacks`). The file's own `fallback` + // wins: it is the dictionary its source was compressed with, while the map + // is keyed by file name alone and so can hold a same-named file of another + // variant (and a `fallbackCollapsed` hoist is only the visible window). const variantFallbacks = key === activeVariantKey ? activeFallbacks : undefined; const mainFallback = - (variant.fileName ? variantFallbacks?.[variant.fileName] : undefined) ?? variant.fallback; + variant.fallback ?? (variant.fileName ? variantFallbacks?.[variant.fileName] : undefined); const source = variant.source != null ? toString(variant.source, mainFallback) : variant.source; let extraFiles: ControlledVariantExtraFiles | undefined; @@ -451,7 +453,7 @@ export function toControlledCode( if (typeof entry === 'string') { extraFiles[fileName] = { source: entry, ...analyzeSource(entry) }; } else { - const entryFallback = variantFallbacks?.[fileName] ?? entry.fallback; + const entryFallback = entry.fallback ?? variantFallbacks?.[fileName]; const extraSource = entry.source != null ? toString(entry.source, entryFallback) : null; extraFiles[fileName] = { source: extraSource, diff --git a/packages/docs/src/useCode/TransformEngine.test.ts b/packages/docs/src/useCode/TransformEngine.test.ts index 8577aaa..b1baf5a 100644 --- a/packages/docs/src/useCode/TransformEngine.test.ts +++ b/packages/docs/src/useCode/TransformEngine.test.ts @@ -4,9 +4,7 @@ import { createTransformedFiles, applyTransformToSource } from './TransformEngin import type { TransformRuntimeDeps } from './TransformEngine'; import { decodeHastSource } from '../pipeline/loadIsomorphicCodeVariant/decodeHastSource'; import { frameFallbackFromSpans } from '../pipeline/hastUtils'; -import { compressHast } from '../pipeline/hastUtils/hastCompression'; -import { fallbackToText } from '../CodeHighlighter/fallbackFormat'; -import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; import type { VariantCode } from '../CodeHighlighter/types'; // Real hast helpers the engine takes injected (no mocks, per convention 3.5). @@ -361,25 +359,8 @@ describe('TransformEngine', () => { describe('compressed sources', () => { // A `hastCompressed` source decodes only with the dictionary its own - // `fallback` text built, so each file here is compressed with its own. - function compressedFile(text: string) { - const root = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'span', - properties: {}, - children: [{ type: 'text', value: text }], - }, - ], - }; - const fallback: FallbackNode[] = [text]; - return { - source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) }, - fallback, - }; - } + // `fallback` text built, so each file here is compressed with its own + // (`createCompressedFile`). function replaceText(text: string): Delta { return { @@ -396,8 +377,8 @@ describe('TransformEngine', () => { } it('decodes a variant with its own dictionary when fallbacks of another variant share the file name', () => { - const first = compressedFile('const first: number = 1;'); - const second = compressedFile('const second: number = 2;'); + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); const secondVariant: VariantCode = { fileName: 'Button.tsx', ...second, @@ -418,8 +399,8 @@ describe('TransformEngine', () => { }); it('decodes an extra file with its own dictionary when fallbacks of another variant share the file name', () => { - const firstStyles = compressedFile('.first { color: red; }'); - const secondStyles = compressedFile('.second { color: blue; }'); + const firstStyles = createCompressedFile('.first { color: red; }'); + const secondStyles = createCompressedFile('.second { color: blue; }'); const secondVariant: VariantCode = { fileName: 'Button.tsx', source: 'const second = 2;', @@ -444,7 +425,7 @@ describe('TransformEngine', () => { }); it('uses the fallbacks map when the variant carries no dictionary of its own', () => { - const { source, fallback } = compressedFile('const only: number = 1;'); + const { source, fallback } = createCompressedFile('const only: number = 1;'); const variant: VariantCode = { fileName: 'Button.tsx', source, diff --git a/packages/docs/src/useCode/TransformEngine.ts b/packages/docs/src/useCode/TransformEngine.ts index 9129b64..55e0422 100644 --- a/packages/docs/src/useCode/TransformEngine.ts +++ b/packages/docs/src/useCode/TransformEngine.ts @@ -173,8 +173,7 @@ export function createTransformedFiles( selectedTransform, deps, selectedVariant.comments, - selectedVariant.fallback ?? - (selectedVariant.fileName ? fallbacks?.[selectedVariant.fileName] : undefined), + selectedVariant.fallback ?? fallbacks?.[selectedVariant.fileName], ); const fileName = selectedVariant.fileName; diff --git a/packages/docs/src/useCode/useCode.ts b/packages/docs/src/useCode/useCode.ts index 2246470..6307210 100644 --- a/packages/docs/src/useCode/useCode.ts +++ b/packages/docs/src/useCode/useCode.ts @@ -487,13 +487,11 @@ export function useCode( const renderedVariantKey = variantSelection.committedVariantKey || variantSelection.selectedVariantKey; - // Per-file DEFLATE dictionaries for the rendered variant. The highlighter's - // `fallbacks` belong to its own active variant, but the variant is selected - // here, and variants often share file names — so read the rendered variant's - // entry, or a same-named file would decode with another variant's dictionary. - const fallbacks = context?.variantFallbacks - ? context.variantFallbacks[renderedVariantKey] - : context?.fallbacks; + // Per-file DEFLATE dictionaries for the rendered variant. The variant is + // selected here rather than by the highlighter, and variants often share file + // names — so read the rendered variant's entry, or a same-named file would + // decode with another variant's dictionary. + const fallbacks = context?.variantFallbacks?.[renderedVariantKey]; // Sub-hook: Transform Management const transformManagement = useTransformManagement({ diff --git a/packages/docs/src/useCode/useCodeUtils.test.ts b/packages/docs/src/useCode/useCodeUtils.test.ts index 7531191..77237c5 100644 --- a/packages/docs/src/useCode/useCodeUtils.test.ts +++ b/packages/docs/src/useCode/useCodeUtils.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { getAvailableTransforms, getApplicableTransforms, + isPassThroughFile, shouldHighlightForRender, transformHasCollapsePlaceholder, } from './useCodeUtils'; @@ -144,6 +145,86 @@ describe('useCodeUtils', () => { }); }); + describe('isPassThroughFile', () => { + const buttonSource = { hastCompressed: 'button' }; + const stylesSource = { hastCompressed: 'styles' }; + const variant: VariantCode = { + fileName: 'Button.tsx', + source: buttonSource, + extraFiles: { + 'styles.css': { source: stylesSource }, + 'utils.js': 'export const utils = 1;', + }, + }; + + it('is true for a main file passed through as its original source', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.tsx', + originalName: 'Button.tsx', + source: buttonSource, + }), + ).toBe(true); + }); + + it('is true for a renamed main file whose source was left untouched', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: buttonSource, + }), + ).toBe(true); + }); + + it('is false for a main file the transform rewrote', () => { + expect( + isPassThroughFile(variant, { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: { type: 'root', children: [] }, + }), + ).toBe(false); + }); + + it('is true for an extra file passed through as its original source', () => { + expect( + isPassThroughFile(variant, { + name: 'styles.css', + originalName: 'styles.css', + source: stylesSource, + }), + ).toBe(true); + expect( + isPassThroughFile(variant, { + name: 'utils.js', + originalName: 'utils.js', + source: 'export const utils = 1;', + }), + ).toBe(true); + }); + + it('is false for an extra file the transform rewrote', () => { + expect( + isPassThroughFile(variant, { + name: 'styles.css', + originalName: 'styles.css', + source: { hastCompressed: 'styles' }, + }), + ).toBe(false); + }); + + it('is false for a file the variant does not have', () => { + expect( + isPassThroughFile(variant, { + name: 'Checkbox.tsx', + originalName: 'Checkbox.tsx', + source: buttonSource, + }), + ).toBe(false); + }); + }); + describe('userProps generation logic', () => { // Helper type for user props (name, slug, and custom properties) type UserProps = T & { diff --git a/packages/docs/src/useCode/useCodeUtils.ts b/packages/docs/src/useCode/useCodeUtils.ts index be36963..abdd6c8 100644 --- a/packages/docs/src/useCode/useCodeUtils.ts +++ b/packages/docs/src/useCode/useCodeUtils.ts @@ -23,6 +23,23 @@ export interface TransformedFiles { filenameMap: { [originalName: string]: string }; } +/** + * Whether `file` is one a transform left untouched. `createTransformedFiles` + * passes such a file through as the variant's original source object (no + * transform for the file, a rename-only transform, or a failed one), so it may + * still be `hastCompressed` and decodes with the original file's fallback. A + * file the transform rewrote is a new live tree whose text differs from the + * original's. + */ +export function isPassThroughFile(variant: VariantCode, file: TransformedFile): boolean { + if (file.originalName === variant.fileName) { + return file.source === variant.source; + } + const extraFile = variant.extraFiles?.[file.originalName]; + const originalSource = typeof extraFile === 'object' ? extraFile.source : extraFile; + return originalSource !== undefined && file.source === originalSource; +} + /** * Pure function to get available transforms from effective code data. * diff --git a/packages/docs/src/useCode/useCopyFunctionality.test.ts b/packages/docs/src/useCode/useCopyFunctionality.test.ts index 03245dc..cb4f1d0 100644 --- a/packages/docs/src/useCode/useCopyFunctionality.test.ts +++ b/packages/docs/src/useCode/useCopyFunctionality.test.ts @@ -46,7 +46,7 @@ describe('collectVariantFiles', () => { }; // The standalone `useCode`/`useDemo` path renders without a - // `CodeHighlighterClient`, so `context?.fallbacks` is undefined. The + // `CodeHighlighterClient`, so `context?.variantFallbacks` is undefined. The // render path still decodes via the variant's own `fallback`; copy must // too, instead of throwing on the dictionary-compressed payload. const files = collectVariantFiles(selectedVariant, undefined, undefined); diff --git a/packages/docs/src/useCode/useCopyFunctionality.ts b/packages/docs/src/useCode/useCopyFunctionality.ts index 00d44f4..452f767 100644 --- a/packages/docs/src/useCode/useCopyFunctionality.ts +++ b/packages/docs/src/useCode/useCopyFunctionality.ts @@ -51,9 +51,9 @@ export function collectVariantFiles( // `fallbacks` (hoisted from a `ContentLoading` component) and the variant's // own per-file `fallback` fields (kept on `Code` when not stripped — e.g. the // standalone `useCode`/`useDemo` path with no `CodeHighlighter` context, so - // `context?.fallbacks` is undefined). Without this merge, copy-as-markdown - // throws on a `hastCompressed` source whose dictionary lives on the - // `VariantCode`. The variant copy wins so the full text — the dictionary + // `context?.variantFallbacks` is undefined). Without this merge, + // copy-as-markdown throws on a `hastCompressed` source whose dictionary lives + // on the `VariantCode`. The variant copy wins so the full text — the dictionary // `hastCompressed` needs — is used when a `fallbackCollapsed` block hoisted // only the visible window. const resolvedFallbacks: Fallbacks = { ...fallbacks }; diff --git a/packages/docs/src/useCode/useFileNavigation.test.ts b/packages/docs/src/useCode/useFileNavigation.test.ts index ee8e4dd..b9d9d9d 100644 --- a/packages/docs/src/useCode/useFileNavigation.test.ts +++ b/packages/docs/src/useCode/useFileNavigation.test.ts @@ -4060,6 +4060,92 @@ describe('useFileNavigation', () => { }); }); + describe('files decode dictionaries', () => { + it('gives a file the transform passed through its own dictionary, but not a rewritten file', () => { + // `styles.css` has no transform, so it passes through as its original + // (possibly `hastCompressed`) source and needs its dictionary. The + // rewritten main file is a live tree whose text differs from the original, + // so the original fallback must not paint over it. + const buttonFallback = ['const button: number = 1;']; + const stylesFallback = ['.button {}']; + const stylesSource = { hastJson: '{"type":"root","children":[]}' }; + const selectedVariant: VariantCode = { + fileName: 'Button.tsx', + source: { hastJson: '{"type":"root","children":[]}' }, + fallback: buttonFallback, + language: 'tsx', + extraFiles: { + 'styles.css': { source: stylesSource, fallback: stylesFallback, totalLines: 1 }, + }, + }; + const transformedFiles: TransformedFiles = { + files: [ + { + name: 'Button.jsx', + originalName: 'Button.tsx', + source: { type: 'root', children: [] }, + }, + { name: 'styles.css', originalName: 'styles.css', source: stylesSource }, + ], + filenameMap: { 'Button.tsx': 'Button.jsx', 'styles.css': 'styles.css' }, + }; + + const { result } = renderHook(() => + useFileNavigationTest({ + selectedVariant, + transformedFiles, + mainSlug: 'Demo', + selectedVariantKey: 'Default', + variantKeys: ['Default'], + shouldHighlight: true, + }), + ); + + const [buttonFile, stylesFile] = result.current.files; + const buttonProps = (buttonFile.component as React.ReactElement).props; + const stylesProps = (stylesFile.component as React.ReactElement).props; + expect(buttonFile.name).toBe('Button.jsx'); + expect(buttonProps.fallback).toBeUndefined(); + expect(buttonProps.language).toBeUndefined(); + expect(stylesFile.name).toBe('styles.css'); + expect(stylesProps.fallback).toBe(stylesFallback); + expect(stylesProps.language).toBe('css'); + expect(stylesProps.fallbackLineCounts).toEqual(expect.objectContaining({ totalLines: 1 })); + }); + + it('gives a passed-through main file its dictionary from the fallbacks map', () => { + // A rename-only (or failed) transform hands the main file back as its + // original source, whose fallback was hoisted off `Code`. + const buttonFallback = ['const button = 1;']; + const buttonSource = { hastJson: '{"type":"root","children":[]}' }; + const selectedVariant: VariantCode = { + fileName: 'Button.tsx', + source: buttonSource, + language: 'tsx', + }; + const transformedFiles: TransformedFiles = { + files: [{ name: 'Button.jsx', originalName: 'Button.tsx', source: buttonSource }], + filenameMap: { 'Button.tsx': 'Button.jsx' }, + }; + + const { result } = renderHook(() => + useFileNavigationTest({ + selectedVariant, + transformedFiles, + mainSlug: 'Demo', + selectedVariantKey: 'Default', + variantKeys: ['Default'], + shouldHighlight: true, + fallbacks: { 'Button.tsx': buttonFallback }, + }), + ); + + const buttonProps = (result.current.files[0].component as React.ReactElement).props; + expect(buttonProps.fallback).toBe(buttonFallback); + expect(buttonProps.language).toBe('tsx'); + }); + }); + describe('selectedFileLines line counting', () => { it('uses the STORED variant count for a compressed pass-through file even when a transform is active', () => { // A transform is active on the variant, but the selected file is a pass-through: diff --git a/packages/docs/src/useCode/useFileNavigation.tsx b/packages/docs/src/useCode/useFileNavigation.tsx index 70d3239..b8fec46 100644 --- a/packages/docs/src/useCode/useFileNavigation.tsx +++ b/packages/docs/src/useCode/useFileNavigation.tsx @@ -12,6 +12,7 @@ import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; import { useUrlHashState } from '../useUrlHashState'; import { countLines } from '../pipeline/parseSource/addLineGutters'; import { getLanguageFromExtension } from '../pipeline/loaderUtils/getLanguageFromExtension'; +import { isPassThroughFile } from './useCodeUtils'; import type { TransformedFiles } from './useCodeUtils'; import { getVariantFileLineCounts } from './sourceLineCounts'; import type { SetSource } from './useSourceEditing'; @@ -37,6 +38,20 @@ function getLanguageFromFileName(fileName: string | undefined): string | undefin return getLanguageFromExtension(extension); } +/** + * The language a variant file renders with when untransformed: the variant's + * `language` for its main file, otherwise the extra file's own `language` or + * the one its extension implies. + */ +function getVariantFileLanguage(variant: VariantCode, fileName: string): string | undefined { + if (fileName === variant.fileName) { + return variant.language; + } + const extraFile = variant.extraFiles?.[fileName]; + const declared = typeof extraFile === 'object' ? extraFile.language : undefined; + return declared ?? getLanguageFromFileName(fileName); +} + /** * Checks if the URL hash is relevant to a specific demo * Hash format is: {mainSlug}:{variantName}:{fileName} or {mainSlug}:{fileName} @@ -841,29 +856,49 @@ export function useFileNavigation({ // If we have transformed files, use them if (transformedFiles) { - return transformedFiles.files.map((f) => ({ - name: f.name, - slug: generateFileSlug(mainSlug, f.originalName, selectedVariantKey), - component: ( -
-            {f.source}
-          
- ), - })); + return transformedFiles.files.map((file) => { + // A file the transform left untouched is still its original source — + // possibly `hastCompressed` — so it renders like the untransformed + // branch below, with its dictionary. A rewritten file is live HAST whose + // text differs, so the original `fallback` would paint stale text first. + const passedThrough = isPassThroughFile(selectedVariant, file); + return { + name: file.name, + slug: generateFileSlug(mainSlug, file.originalName, selectedVariantKey), + component: ( +
+              {file.source}
+            
+ ), + }; + }); } // Otherwise, create files from original untransformed data diff --git a/packages/docs/src/useCode/useTransformManagement.test.ts b/packages/docs/src/useCode/useTransformManagement.test.ts index 0e4ea20..f0f3196 100644 --- a/packages/docs/src/useCode/useTransformManagement.test.ts +++ b/packages/docs/src/useCode/useTransformManagement.test.ts @@ -203,6 +203,50 @@ describe('useTransformManagement', () => { // `getAvailableTransforms`, so we don't assert call counts here.) }); + it('should use context availableTransforms while its selection matches the selected variant', async () => { + const context = { + availableTransforms: ['CustomTransform'], + selection: { variant: 'Default' }, + }; + (getAvailableTransforms as any).mockReturnValue([]); + (createTransformedFiles as any).mockReturnValue({ transformed: true }); + + const { result } = renderHook(() => + useTransformManagement({ + context, + effectiveCode: mockEffectiveCode, + selectedVariantKey: 'Default', + selectedVariant: mockSelectedVariant, + }), + ); + + expect(result.current.availableTransforms).toEqual(['CustomTransform']); + }); + + it('should compute availableTransforms for the selected variant when the context lists them for another variant', async () => { + // The highlighter lists transforms for its own selected variant, while + // `useCode` selects variants on its own, so the two can disagree. + const context = { + availableTransforms: ['CustomTransform'], + selection: { variant: 'Default' }, + }; + vi.clearAllMocks(); + (getAvailableTransforms as any).mockReturnValue([]); + (createTransformedFiles as any).mockReturnValue({ transformed: true }); + + const { result } = renderHook(() => + useTransformManagement({ + context, + effectiveCode: mockEffectiveCode, + selectedVariantKey: 'Alternative', + selectedVariant: mockSelectedVariant, + }), + ); + + expect(result.current.availableTransforms).toEqual([]); + expect(getAvailableTransforms).toHaveBeenCalledWith(mockEffectiveCode, 'Alternative'); + }); + describe('localStorage persistence', () => { it('should load transform from localStorage when no initialTransform provided', async () => { // Mock localStorage diff --git a/packages/docs/src/useCode/useTransformManagement.ts b/packages/docs/src/useCode/useTransformManagement.ts index 5a8d2ce..c4ad87b 100644 --- a/packages/docs/src/useCode/useTransformManagement.ts +++ b/packages/docs/src/useCode/useTransformManagement.ts @@ -177,16 +177,25 @@ export function useTransformManagement({ selectedFileName, expanded, }: UseTransformManagementProps): UseTransformManagementResult { - // Transform state - get available transforms from context or from the effective code data + // Transform state - get available transforms from context or from the effective code data. + // The highlighter lists transforms for ITS selected variant, but `useCode` + // selects variants on its own, so the context list only applies while the two + // agree (a context without a `selection` is taken to agree). + const contextVariant = context?.selection?.variant; const availableTransforms = React.useMemo(() => { - // First try to get from context - if (context?.availableTransforms && context.availableTransforms.length > 0) { + const contextMatchesVariant = + contextVariant === undefined || contextVariant === selectedVariantKey; + if ( + contextMatchesVariant && + context?.availableTransforms && + context.availableTransforms.length > 0 + ) { return context.availableTransforms; } // Otherwise, get from the effective code data using the utility function return getAvailableTransforms(effectiveCode, selectedVariantKey); - }, [context?.availableTransforms, effectiveCode, selectedVariantKey]); + }, [context?.availableTransforms, contextVariant, effectiveCode, selectedVariantKey]); // Lazily-resolved transform engine (the `jsondiffpatch`-pulling applier). // Initialized synchronously from the module cache so a warmed block (a later diff --git a/packages/docs/src/useCode/user.spec.ts b/packages/docs/src/useCode/user.spec.ts index ef4d2b4..8dd37f8 100644 --- a/packages/docs/src/useCode/user.spec.ts +++ b/packages/docs/src/useCode/user.spec.ts @@ -21,9 +21,7 @@ import type { ContentProps, HastRoot, Transforms } from '../CodeHighlighter/type import { CodeHighlighterContext } from '../CodeHighlighter/CodeHighlighterContext'; import type { CodeHighlighterContextType } from '../CodeHighlighter/CodeHighlighterContext'; import { CodeControllerContext } from '../CodeControllerContext/CodeControllerContext'; -import { compressHast } from '../pipeline/hastUtils/hastCompression'; -import { fallbackToText } from '../CodeHighlighter/fallbackFormat'; -import type { FallbackNode } from '../CodeHighlighter/fallbackFormat'; +import { createCompressedFile } from '../pipeline/hastUtils/hastCompression.testUtils'; import { preloadTransformEngine } from './transformEngineCache'; describe('useCode integration tests', () => { @@ -1343,26 +1341,8 @@ describe('useCode integration tests', () => { }); describe('compressed sources across variants', () => { - // Each file is compressed with its own `fallback` text as the dictionary, - // so it decodes only with that same text. - function compressedFile(text: string) { - const root = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'span', - properties: {}, - children: [{ type: 'text', value: text }], - }, - ], - }; - const fallback: FallbackNode[] = [text]; - return { - source: { hastCompressed: compressHast(JSON.stringify(root), fallbackToText(fallback)) }, - fallback, - }; - } + // Each file is compressed with its own `fallback` text as the dictionary + // (`createCompressedFile`), so it decodes only with that same text. function toJs(text: string): Transforms { return { @@ -1380,8 +1360,8 @@ describe('useCode integration tests', () => { it('keeps the selected transform when switching to a variant whose file shares a name', async () => { await preloadTransformEngine(); - const first = compressedFile('const first: number = 1;'); - const second = compressedFile('const second: number = 2;'); + const first = createCompressedFile('const first: number = 1;'); + const second = createCompressedFile('const second: number = 2;'); // A `ContentLoading` hoists each variant's dictionaries off `Code`, and the // highlighter hands them back per variant alongside its own variant's map. @@ -1400,7 +1380,6 @@ describe('useCode integration tests', () => { transforms: toJs('const second = 2;'), }, }, - fallbacks: { 'Button.tsx': first.fallback }, variantFallbacks: { First: { 'Button.tsx': first.fallback }, Second: { 'Button.tsx': second.fallback }, @@ -1454,6 +1433,77 @@ describe('useCode integration tests', () => { }); }); + describe('compressed files with a transform', () => { + it('renders every file of a transformed variant, including a compressed file the transform leaves untouched', async () => { + await preloadTransformEngine(); + // Each file is compressed with its own `fallback` text as the dictionary. + // Only the main file declares the `js` transform, so `styles.css` passes + // through as its original compressed payload. The block mounts with the + // transform already selected (e.g. a stored preference), so no file has + // been rendered untransformed first. + const button = createCompressedFile('const button: number = 1;'); + const styles = createCompressedFile('.button { color: red; }'); + const contentProps: ContentProps<{}> = { + code: { + Default: { + fileName: 'Button.tsx', + ...button, + totalLines: 1, + transforms: { + js: { + delta: { + children: { + _t: 'a', + 0: { children: { _t: 'a', 0: { value: ['const button = 1;'] } } }, + }, + }, + fileName: 'Button.jsx', + }, + }, + extraFiles: { 'styles.css': { ...styles, totalLines: 1 } }, + }, + }, + }; + // `
` observes frame visibility, which jsdom doesn't implement.
+      const { IntersectionObserver: originalIntersectionObserver } = globalThis;
+      globalThis.IntersectionObserver = class {
+        observe() {}
+
+        unobserve() {}
+
+        disconnect() {}
+      } as unknown as typeof IntersectionObserver;
+
+      try {
+        let code: UseCodeResult | undefined;
+        function Viewer() {
+          code = useCode(contentProps, { initialTransform: 'js' });
+          return React.createElement(
+            'div',
+            null,
+            code.files.map((file) =>
+              React.createElement(
+                'div',
+                { key: file.name, 'data-testid': file.name },
+                file.component,
+              ),
+            ),
+          );
+        }
+        const { getByTestId } = render(React.createElement(Viewer));
+
+        await waitFor(() => {
+          expect(code!.files.map((file) => file.name)).toEqual(['Button.jsx', 'styles.css']);
+        });
+        expect(code!.selectedTransform).toBe('js');
+        expect(getByTestId('Button.jsx').textContent).toBe('const button = 1;');
+        expect(getByTestId('styles.css').textContent).toBe('.button { color: red; }');
+      } finally {
+        globalThis.IntersectionObserver = originalIntersectionObserver;
+      }
+    });
+  });
+
   describe('copy feedback', () => {
     it('reports a recent copy of the file and of the Markdown separately', async () => {
       const writes: string[] = [];
@@ -1500,6 +1550,40 @@ describe('useCode integration tests', () => {
         delete (window.navigator as { clipboard?: Clipboard }).clipboard;
       }
     });
+
+    it('does not report a Markdown copy when the variant has no file name to copy', async () => {
+      const writes: string[] = [];
+      Object.defineProperty(window.navigator, 'clipboard', {
+        configurable: true,
+        value: {
+          writeText: async (text: string) => {
+            writes.push(text);
+          },
+        },
+      });
+      try {
+        const contentProps: ContentProps<{}> = {
+          name: 'Button',
+          code: { Default: { source: 'const button = 1;' } },
+        };
+        const { result } = renderHook(() => useCode(contentProps));
+
+        await act(async () => {
+          await result.current.copyMarkdown({} as React.MouseEvent);
+        });
+        expect(writes).toEqual([]);
+        expect(result.current.copyMarkdownRecentlySuccessful).toBe(false);
+
+        // The file itself still copies.
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(writes).toEqual(['const button = 1;']);
+        expect(result.current.copyRecentlySuccessful).toBe(true);
+      } finally {
+        delete (window.navigator as { clipboard?: Clipboard }).clipboard;
+      }
+    });
   });
 
   describe('deferred expand during swap', () => {
diff --git a/packages/docs/src/useCopier/index.ts b/packages/docs/src/useCopier/index.ts
index 170ee20..012aed7 100644
--- a/packages/docs/src/useCopier/index.ts
+++ b/packages/docs/src/useCopier/index.ts
@@ -24,17 +24,19 @@ export function useCopier(contents: (() => string | undefined) | string, opts?:
 
       try {
         const content = typeof contents === 'function' ? contents() : contents;
+        // Nothing to copy: leave the clipboard untouched and report no copy, so
+        // `recentlySuccessful` only ever means that something was written.
         if (content) {
           await copyToClipboard(content);
-        }
 
-        setRecentlySuccessful(true);
-        onCopied?.();
+          setRecentlySuccessful(true);
+          onCopied?.();
 
-        copyTimeoutRef.current = setTimeout(() => {
-          clearTimeout(copyTimeoutRef.current);
-          setRecentlySuccessful(false);
-        }, timeout);
+          copyTimeoutRef.current = setTimeout(() => {
+            clearTimeout(copyTimeoutRef.current);
+            setRecentlySuccessful(false);
+          }, timeout);
+        }
       } catch (error) {
         onError?.(error);
       }
diff --git a/packages/docs/src/useCopier/useCopier.test.ts b/packages/docs/src/useCopier/useCopier.test.ts
new file mode 100644
index 0000000..3f2bcec
--- /dev/null
+++ b/packages/docs/src/useCopier/useCopier.test.ts
@@ -0,0 +1,174 @@
+/**
+ * @vitest-environment jsdom
+ */
+import type * as React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useCopier } from '.';
+
+/**
+ * Replaces `navigator.clipboard` with a recorder and returns the texts written
+ * to it plus a function that restores the original (absent) clipboard.
+ */
+function stubClipboard() {
+  const writes: string[] = [];
+  Object.defineProperty(window.navigator, 'clipboard', {
+    configurable: true,
+    value: {
+      writeText: async (text: string) => {
+        writes.push(text);
+      },
+    },
+  });
+  const restore = () => {
+    delete (window.navigator as { clipboard?: Clipboard }).clipboard;
+  };
+  return { writes, restore };
+}
+
+describe('useCopier', () => {
+  it('writes the content, reports a recent copy and clears it after the timeout', async () => {
+    const { writes, restore } = stubClipboard();
+    vi.useFakeTimers();
+    try {
+      const copied: string[] = [];
+      const { result } = renderHook(() =>
+        useCopier('Button', { timeout: 1000, onCopied: () => copied.push('copied') }),
+      );
+      expect(result.current.recentlySuccessful).toBe(false);
+
+      await act(async () => {
+        await result.current.copy({} as React.MouseEvent);
+      });
+      expect(writes).toEqual(['Button']);
+      expect(copied).toEqual(['copied']);
+      expect(result.current.recentlySuccessful).toBe(true);
+
+      await act(async () => {
+        await vi.advanceTimersByTimeAsync(1000);
+      });
+      expect(result.current.recentlySuccessful).toBe(false);
+    } finally {
+      vi.useRealTimers();
+      restore();
+    }
+  });
+
+  it('reads the content from a function at copy time', async () => {
+    const { writes, restore } = stubClipboard();
+    try {
+      let label = 'Button';
+      const { result } = renderHook(() => useCopier(() => label));
+      label = 'Checkbox';
+
+      await act(async () => {
+        await result.current.copy({} as React.MouseEvent);
+      });
+      expect(writes).toEqual(['Checkbox']);
+      expect(result.current.recentlySuccessful).toBe(true);
+    } finally {
+      restore();
+    }
+  });
+
+  describe('empty content', () => {
+    it('does not report a copy when the content function returns undefined', async () => {
+      const { writes, restore } = stubClipboard();
+      try {
+        const calls: string[] = [];
+        const { result } = renderHook(() =>
+          useCopier(() => undefined, {
+            onCopied: () => calls.push('copied'),
+            onError: () => calls.push('error'),
+            onClick: () => calls.push('click'),
+          }),
+        );
+
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(writes).toEqual([]);
+        expect(result.current.recentlySuccessful).toBe(false);
+        expect(calls).toEqual(['click']);
+      } finally {
+        restore();
+      }
+    });
+
+    it('does not report a copy for an empty string', async () => {
+      const { writes, restore } = stubClipboard();
+      try {
+        const calls: string[] = [];
+        const { result } = renderHook(() =>
+          useCopier('', {
+            onCopied: () => calls.push('copied'),
+            onError: () => calls.push('error'),
+            onClick: () => calls.push('click'),
+          }),
+        );
+
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(writes).toEqual([]);
+        expect(result.current.recentlySuccessful).toBe(false);
+        expect(calls).toEqual(['click']);
+      } finally {
+        restore();
+      }
+    });
+
+    it('clears an earlier copy feedback when the content becomes empty', async () => {
+      const { writes, restore } = stubClipboard();
+      try {
+        let label: string | undefined = 'Button';
+        const { result } = renderHook(() => useCopier(() => label));
+
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(result.current.recentlySuccessful).toBe(true);
+
+        label = undefined;
+        await act(async () => {
+          await result.current.copy({} as React.MouseEvent);
+        });
+        expect(writes).toEqual(['Button']);
+        expect(result.current.recentlySuccessful).toBe(false);
+      } finally {
+        restore();
+      }
+    });
+  });
+
+  it('reports a failed write through onError without a recent copy', async () => {
+    const failure = new Error('Permission denied');
+    Object.defineProperty(window.navigator, 'clipboard', {
+      configurable: true,
+      value: {
+        writeText: async () => {
+          throw failure;
+        },
+      },
+    });
+    try {
+      const errors: unknown[] = [];
+      const clicks: string[] = [];
+      const { result } = renderHook(() =>
+        useCopier('Button', {
+          onError: (error) => errors.push(error),
+          onClick: () => clicks.push('click'),
+        }),
+      );
+
+      await act(async () => {
+        await result.current.copy({} as React.MouseEvent);
+      });
+      expect(errors).toHaveLength(1);
+      expect(result.current.recentlySuccessful).toBe(false);
+      expect(clicks).toEqual(['click']);
+    } finally {
+      delete (window.navigator as { clipboard?: Clipboard }).clipboard;
+    }
+  });
+});

From 1381cdcf51ec53be168eff48dd4160e9b1bd9f9d Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Thu, 24 Sep 2026 23:20:45 -0400
Subject: [PATCH 3/6] More bug fixes for MDX

---
 .../CodeHighlighter/CodeHighlighterClient.tsx |   2 +
 .../CodeHighlighterContext.tsx                |  12 +
 .../docs/src/CodeHighlighter/user.spec.tsx    |  70 ++++
 .../parseImportsAndComments.test.ts           | 331 ++++++++++++++-
 .../loaderUtils/parseImportsAndComments.ts    | 386 +++++++++++++-----
 .../useCode/useTransformManagement.test.ts    |  52 ++-
 .../src/useCode/useTransformManagement.ts     |  14 +-
 7 files changed, 745 insertions(+), 122 deletions(-)

diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
index 28699c4..fbe5bf4 100644
--- a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
+++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx
@@ -1641,6 +1641,7 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) {
       // Only suppress when an external CodeController owns the code; static
       // `props.code` still needs the locally-computed list.
       availableTransforms: controlled?.code ? [] : availableTransforms,
+      availableTransformsVariant: variantName,
       url: props.url,
       deferHighlight,
       variantFallbacks,
@@ -1661,6 +1662,7 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) {
       controlled?.errors,
       controlled?.code,
       availableTransforms,
+      variantName,
       props.url,
       deferHighlight,
       variantFallbacks,
diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
index ed8fa7e..30bd787 100644
--- a/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
+++ b/packages/docs/src/CodeHighlighter/CodeHighlighterContext.tsx
@@ -27,7 +27,19 @@ export interface CodeHighlighterContextType {
    * `useDemo().error`.
    */
   errors?: Record;
+  /**
+   * Transforms with a real delta for the variant named by
+   * `availableTransformsVariant`, i.e. the variant the highlighter considers
+   * current. Empty when a `CodeControllerContext` owns the code.
+   */
   availableTransforms?: string[];
+  /**
+   * The variant `availableTransforms` was computed for. That is not always
+   * `selection.variant` (a `variant` prop wins over it) nor the variant a
+   * consumer that selects variants on its own (like `useCode`) renders, so
+   * such a consumer reuses the list only when this names its rendered variant.
+   */
+  availableTransformsVariant?: string;
   url?: string;
   deferHighlight?: boolean;
   /**
diff --git a/packages/docs/src/CodeHighlighter/user.spec.tsx b/packages/docs/src/CodeHighlighter/user.spec.tsx
index 03c69ca..a12bfe2 100644
--- a/packages/docs/src/CodeHighlighter/user.spec.tsx
+++ b/packages/docs/src/CodeHighlighter/user.spec.tsx
@@ -296,6 +296,76 @@ describe('CodeHighlighter rendering', () => {
     expect(screen.getByTestId('transform:js')).toBeTruthy();
   });
 
+  it('offers only the transforms of the variant it renders when the highlighter is given a variant', async () => {
+    // The highlighter is told `Typed` is the current variant, but the content
+    // selects and renders its own (`Plain`, the first). Only `Typed` declares a
+    // `js` transform, so no toggle may show until the user switches to it.
+    window.localStorage.clear();
+    const code = {
+      Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') },
+      Typed: {
+        fileName: 'Button.tsx',
+        source: highlighted('const button: number = 1;'),
+        transforms: { js: { fileName: 'Button.jsx', hasDelta: true } },
+      },
+    } as unknown as Code;
+
+    render(
+      
+        
+      ,
+    );
+
+    expect(screen.getByTestId('variant').textContent).toBe('Plain');
+    expect(screen.queryByTestId('transform:js')).toBeNull();
+
+    act(() => {
+      screen.getByTestId('variant:Typed').click();
+    });
+    await waitFor(() =>
+      expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'),
+    );
+    expect(screen.getByTestId('transform:js')).toBeTruthy();
+  });
+
+  it('offers only the transforms of the variant it renders when a controller selects another variant', async () => {
+    // A controller that only selects (owns no code) points the highlighter at
+    // `Typed`, while the content renders its own first variant, `Plain`.
+    window.localStorage.clear();
+    const code = {
+      Plain: { fileName: 'Button.js', source: highlighted('const button = 1;') },
+      Typed: {
+        fileName: 'Button.tsx',
+        source: highlighted('const button: number = 1;'),
+        transforms: { js: { fileName: 'Button.jsx', hasDelta: true } },
+      },
+    } as unknown as Code;
+
+    render(
+      
+        
+          
+        
+      ,
+    );
+
+    expect(screen.getByTestId('variant').textContent).toBe('Plain');
+    expect(screen.queryByTestId('transform:js')).toBeNull();
+
+    act(() => {
+      screen.getByTestId('variant:Typed').click();
+    });
+    await waitFor(() =>
+      expect(screen.getByTestId('code').textContent).toContain('const button: number = 1;'),
+    );
+    expect(screen.getByTestId('transform:js')).toBeTruthy();
+  });
+
   it('keeps a transform applied when the user switches to a variant whose file has the same name', async () => {
     // Production payloads compress each file with its own `fallback` text as the
     // DEFLATE dictionary. Both variants name their file `app.ts`, so the second
diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
index 9c5e500..aa9e3af 100644
--- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
+++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts
@@ -1,5 +1,6 @@
 import { describe, expect, it } from 'vitest';
 import { parseImportsAndComments } from './parseImportsAndComments';
+import type { ImportName } from './parseImportsAndComments';
 
 describe('parseImportsAndComments', () => {
   it('should resolve relative import paths and group by import path', async () => {
@@ -455,6 +456,199 @@ import { Chart } from './Chart'
     });
   });
 
+  describe('statements split across lines', () => {
+    // A statement can't end before its module path, so a line break inside the
+    // clause never ends it. Each case is followed by another import with an
+    // emphasis comment, which must still be read on its own.
+    const cases: Array<{
+      title: string;
+      statement: string;
+      modulePath: string;
+      names: ImportName[];
+      includeTypeDefs?: true;
+    }> = [
+      {
+        title: 'a default import with named imports on the next line',
+        statement: `import React,\n  { useState } from 'react'`,
+        modulePath: 'react',
+        names: [
+          { name: 'React', type: 'default' },
+          { name: 'useState', type: 'named' },
+        ],
+      },
+      {
+        title: 'a namespace import on the line after the keyword',
+        statement: `import\n  * as React from 'react'`,
+        modulePath: 'react',
+        names: [{ name: 'React', type: 'namespace' }],
+      },
+      {
+        title: 'a type-only import with its braces on the next line',
+        statement: `import type\n  { Props } from './types'`,
+        modulePath: './types',
+        names: [{ name: 'Props', type: 'named', isType: true }],
+        includeTypeDefs: true,
+      },
+      {
+        title: 'a default import with the comma leading the next line',
+        statement: `import Checkbox\n  , { CheckboxProps } from './Checkbox'`,
+        modulePath: './Checkbox',
+        names: [
+          { name: 'Checkbox', type: 'default' },
+          { name: 'CheckboxProps', type: 'named' },
+        ],
+      },
+      {
+        title: 'a side-effect import with its path on the next line',
+        statement: `import\n  './reset.css'`,
+        modulePath: './reset.css',
+        names: [],
+      },
+      {
+        title: 'a namespace re-export with `from` on the next line',
+        statement: `export * as Icons\n  from './icons'`,
+        modulePath: './icons',
+        names: [{ name: 'Icons', type: 'namespace' }],
+      },
+      {
+        title: 'a type-only re-export with its braces on the next line',
+        statement: `export type\n  { Props } from './types'`,
+        modulePath: './types',
+        names: [{ name: 'Props', type: 'named', isType: true }],
+        includeTypeDefs: true,
+      },
+      {
+        title: 'an import attributes clause on the same line',
+        statement: `import data from './data.json' with { type: 'json' }`,
+        modulePath: './data.json',
+        names: [{ name: 'data', type: 'default' }],
+      },
+      {
+        title: 'an import attributes clause on the next line',
+        statement: `import data from './data.json'\n  with { type: 'json' }`,
+        modulePath: './data.json',
+        names: [{ name: 'data', type: 'default' }],
+      },
+    ];
+
+    describe.each([
+      ['without semicolons', ''],
+      ['with semicolons', ';'],
+    ])('%s', (_label, semicolon) => {
+      it.each(cases.map((testCase) => [testCase.title, testCase] as const))(
+        'reads %s',
+        (_title, { statement, modulePath, names, includeTypeDefs }) => {
+          const code = `${statement}${semicolon}
+import { Button } from './Button'${semicolon} // @highlight
+
+export const count = 1${semicolon}
+`;
+          const result = parseImportsAndComments(code, '/src/Demo.tsx', {
+            removeCommentsWithPrefix: ['@highlight'],
+            notableCommentsPrefix: ['@highlight'],
+          });
+
+          const quotedPath = `'${modulePath}'`;
+          const start = code.indexOf(quotedPath);
+          const positions = [{ start, end: start + quotedPath.length }];
+          if (modulePath.startsWith('./')) {
+            expect(result.relative[modulePath]).toEqual({
+              url: `file:///src/${modulePath.slice(2)}`,
+              names,
+              ...(includeTypeDefs && { includeTypeDefs }),
+              positions,
+            });
+          } else {
+            expect(result.externals[modulePath]).toEqual({ names, positions });
+          }
+          // The next import and its comment are read after the statement.
+          expect(result.relative['./Button'].names).toEqual([{ name: 'Button', type: 'named' }]);
+          expect(result.code).toBe(code.replace(' // @highlight', ''));
+          expect(result.comments).toEqual({
+            [statement.split('\n').length + 1]: ['@highlight'],
+          });
+        },
+      );
+    });
+
+    it('reads a multiline attributes clause after a line break', () => {
+      const code = `import data from './data.json'
+  with {
+    type: 'json',
+  }
+import { Button } from './Button' // @highlight
+`;
+      const result = parseImportsAndComments(code, '/src/Demo.tsx', {
+        removeCommentsWithPrefix: ['@highlight'],
+      });
+
+      expect(Object.keys(result.relative)).toEqual(['./data.json', './Button']);
+      expect(result.comments).toEqual({ 5: ['@highlight'] });
+    });
+
+    it('does not continue into a call of a function named assert', () => {
+      const code = `import assert from 'node:assert'
+assert(import.meta.url)
+import { Button } from './Button'
+`;
+      const result = parseImportsAndComments(code, '/src/Demo.test.ts');
+
+      expect(Object.keys(result.externals)).toEqual(['node:assert']);
+      expect(Object.keys(result.relative)).toEqual(['./Button']);
+    });
+
+    it('keeps an incomplete import from swallowing the code after it', () => {
+      const code = `import { Button
+
+export function App() {
+  return  // @highlight
+}
+
+import { Checkbox } from './Checkbox'
+`;
+      const result = parseImportsAndComments(code, '/src/App.tsx', {
+        removeCommentsWithPrefix: ['@highlight'],
+      });
+
+      expect(Object.keys(result.relative)).toEqual(['./Checkbox']);
+      expect(result.comments).toEqual({ 4: ['@highlight'] });
+      expect(result.code).toBe(code.replace(' // @highlight', ''));
+    });
+
+    it('keeps an import without a module path from joining the next import', () => {
+      const code = `import Button
+import { Checkbox } from './Checkbox'
+const count = 1 // @highlight
+`;
+      const result = parseImportsAndComments(code, '/src/App.tsx', {
+        removeCommentsWithPrefix: ['@highlight'],
+      });
+
+      expect(result.relative).toEqual({
+        './Checkbox': {
+          url: 'file:///src/Checkbox',
+          names: [{ name: 'Checkbox', type: 'named' }],
+          positions: [{ start: 39, end: 51 }],
+        },
+      });
+      expect(result.comments).toEqual({ 3: ['@highlight'] });
+    });
+
+    it('does not read prose that starts with import as a statement', () => {
+      const code = `import { Button } from './Button';
+
+import the data from the server, then render it: // @highlight
+
+  );
+}
diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts
new file mode 100644
index 0000000..8d956b2
--- /dev/null
+++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/test.ts
@@ -0,0 +1,67 @@
+import path from 'node:path';
+import { test, expect } from '@playwright/test';
+
+// The standalone demo route, derived from this file's location under `app`.
+const route = path
+  .dirname(import.meta.filename)
+  .split('/app')
+  .pop()!;
+
+// The first variant has no transform (its JS version only renames the file), the
+// second is TypeScript with a JS transform. The highlighter considers the first
+// variant current, while the JS toggle belongs to the second: switching to the
+// second, then to JS, must paint each final tree at once, with no re-flow.
+test('switches into the TypeScript variant and to JS without re-flowing after either swap', async ({
+  page,
+}) => {
+  const pageErrors: Error[] = [];
+  page.on('pageerror', (error) => pageErrors.push(error));
+
+  await page.goto(`${route}/demo-plain`);
+  const code = page.locator('pre').first();
+  await expect(code).toContainText('Clicked 0 times', { timeout: 15000 });
+  await expect(page.getByRole('button', { name: 'JS' })).toHaveCount(0);
+
+  // Record what the code shows after every change to the page, from here on: its
+  // text, and the markup of the frames in its collapsed window. (Frames outside
+  // the window are highlighted lazily, once visible, so they may change later.)
+  // The observer watches the page, since a swap may replace the `pre` itself.
+  await page.evaluate(() => {
+    const snapshots: string[] = [];
+    (window as unknown as { preSnapshots: string[] }).preSnapshots = snapshots;
+    new MutationObserver(() => {
+      const pre = document.querySelector('pre');
+      const windowFrames = Array.from(pre?.querySelectorAll('.frame[data-frame-type]') ?? []);
+      const snapshot = `${pre?.textContent}\n${windowFrames.map((frame) => frame.outerHTML).join('')}`;
+      if (snapshot !== snapshots[snapshots.length - 1]) {
+        snapshots.push(snapshot);
+      }
+    }).observe(document.body, { childList: true, subtree: true, characterData: true });
+  });
+  const snapshots = () =>
+    page.evaluate(() => (window as unknown as { preSnapshots: string[] }).preSnapshots);
+
+  await page.getByText('PlainCount', { exact: true }).click();
+  await page.getByRole('option', { name: 'TypedCount' }).click();
+  await expect(code).toContainText('interface CounterProps', { timeout: 15000 });
+
+  await page.getByRole('button', { name: 'JS' }).click();
+  await expect(page.getByText('Counter.jsx')).toBeVisible({ timeout: 15000 });
+  await expect(code).not.toContainText('interface CounterProps');
+  await expect(code).toContainText('');
+
+  // Give a late re-render time to land, then check each swap painted its final
+  // tree: what the code shows never changed after the variant's first TypeScript
+  // render, or after its first JS render.
+  await page.waitForTimeout(1000);
+  const shown = await snapshots();
+  const firstJs = shown.findIndex(
+    (snapshot) => snapshot.includes('') && !snapshot.includes('CounterProps'),
+  );
+  expect(firstJs).toBeGreaterThanOrEqual(0);
+  expect(new Set(shown.slice(firstJs)).size).toBe(1);
+  const typed = shown.slice(0, firstJs).filter((snapshot) => snapshot.includes('CounterProps'));
+  expect(new Set(typed).size).toBe(1);
+
+  expect(pageErrors, 'the demo should swap without uncaught errors').toEqual([]);
+});
diff --git a/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx
new file mode 100644
index 0000000..5a19507
--- /dev/null
+++ b/docs/app/lib/components/code-highlighter/demos/demo-variants-mixed-transform/typed-count/Counter.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+
+interface CounterProps {
+  count?: number;
+}
+
+export function Counter({ count = 0 }: CounterProps) {
+  return (
+    // @focus-start
+    

+ Clicked {count} times +

+ // @focus-end + ); +} diff --git a/docs/app/lib/hooks/use-copier/page.mdx b/docs/app/lib/hooks/use-copier/page.mdx index 58c651e..40d22d9 100644 --- a/docs/app/lib/hooks/use-copier/page.mdx +++ b/docs/app/lib/hooks/use-copier/page.mdx @@ -105,18 +105,18 @@ import { TypesUseCopier } from './types'; 2. **Clipboard API**: Uses the `clipboard-copy` library for cross-browser compatibility 3. **Success State**: Sets `recentlySuccessful` to `true` after successful copy 4. **Timeout Management**: Automatically resets success state after configured timeout -5. **Cleanup**: Clears timeouts to prevent memory leaks +5. **Cleanup**: Clears the timeout on unmount, and a write that finishes after unmounting starts none ## State Management The hook manages internal state for copy feedback: - **Initial state**: `recentlySuccessful` is `false` -- **On copy attempt**: State resets to `false` and clears any existing timeout -- **On success**: State becomes `true` and starts timeout countdown +- **On success**: State becomes `true` and the timeout countdown (re)starts - **After timeout**: State resets to `false` -- **On error**: State remains `false` but error callback is fired -- **On empty content**: Nothing is written and state remains `false`; neither `onCopied` nor `onError` is fired, but `onClick` still is +- **Copying again**: State stays `true` while the new write is in flight, so feedback keyed on it doesn't flicker; each successful write restarts the countdown, so the state stays `true` until `timeout` after the last write to finish. Use `onCopied` to react to every copy. +- **On error**: State becomes `false` and the error callback is fired +- **On empty content**: Nothing is written and state becomes `false`; neither `onCopied` nor `onError` is fired, but `onClick` still is ## Error Handling diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx new file mode 100644 index 0000000..384adeb --- /dev/null +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.deferHighlight.test.tsx @@ -0,0 +1,255 @@ +/** + * @vitest-environment jsdom + * + * `deferHighlight` holds variant and transform swaps while the async transform + * deltas (`computeHastDeltas`) are still being computed, so a swap never paints + * a tree that re-flows once they land. `useCode` picks the rendered variant on + * its own, so the wait has to cover every variant of the block, not just the + * variant the highlighter itself considers current. + */ +import * as React from 'react'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { CodeHighlighterClient } from './CodeHighlighterClient'; +import { CodeHighlighterContext } from './CodeHighlighterContext'; +import { CodeContext } from '../CodeProvider/CodeContext'; +import type { ComputeHastDeltasLoader } from '../CodeProvider/CodeContext'; +import { useCode } from '../useCode'; +import { preloadTransformEngine } from '../useCode/transformEngineCache'; +import { createParseSource } from '../pipeline/parseSource'; +import { parseCode } from '../pipeline/loadIsomorphicCodeVariant/parseCode'; +import { computeHastDeltas } from '../pipeline/loadIsomorphicCodeVariant/computeHastDeltas'; +import { transformSource } from '../pipeline/loadIsomorphicCodeVariant/transformSource'; +import type { Code, ContentProps, ParseSource, Transforms, VariantCode } from './types'; + +const typedSource = 'const typed: number = 1;\nexport default typed;\n'; +const typedJavaScript = 'const typed = 1;\nexport default typed;\n'; + +let parseSource: ParseSource; +let typedTransforms: Transforms; + +beforeAll(async () => { + class NoopObserver { + observe() {} + + unobserve() {} + + disconnect() {} + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + globalThis.IntersectionObserver = NoopObserver as unknown as typeof IntersectionObserver; + globalThis.ResizeObserver = NoopObserver as unknown as typeof ResizeObserver; + await preloadTransformEngine(); + parseSource = await createParseSource(); + // A TS → JS transform with a real source delta, as the build produces it. + typedTransforms = (await transformSource(typedSource, 'Typed.tsx', [ + { + extensions: ['tsx'], + transformer: async () => ({ js: { source: typedJavaScript, fileName: 'Typed.jsx' } }), + }, + ]))!; +}); + +beforeEach(() => { + window.localStorage.clear(); +}); + +const plainVariant = (name = 'Plain'): VariantCode => ({ + fileName: `${name}.js`, + source: `const ${name.toLowerCase()} = 1;\n`, +}); + +const typedVariant = (name = 'Typed'): VariantCode => ({ + fileName: `${name}.tsx`, + source: typedSource.replaceAll('typed', name.toLowerCase()), + transforms: structuredClone(typedTransforms), +}); + +/** + * A `computeHastDeltasLoader` whose computations finish only when the test + * resolves them. + */ +function createHeldDeltas() { + const pending: Array<() => void> = []; + const loader: ComputeHastDeltasLoader = async () => (parsedCode, parser) => + new Promise((resolve) => { + pending.push(() => resolve(computeHastDeltas(parsedCode, parser))); + }); + const resolveAll = async () => { + await act(async () => { + pending.splice(0).forEach((finish) => finish()); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + }); + }; + return { loader, pending, resolveAll }; +} + +/** Each distinct committed state of the rendered file: its name and markup. */ +let commits: Array<{ fileName: string; markup: string }> = []; + +function Demo(props: ContentProps) { + const code = useCode(props, { variantSwapDelay: 0, transformDelay: 0 }); + const deferHighlight = React.useContext(CodeHighlighterContext)?.deferHighlight; + const preRef = React.useRef(null); + React.useLayoutEffect(() => { + const fileName = code.selectedFileName ?? ''; + const markup = preRef.current?.innerHTML ?? ''; + const last = commits[commits.length - 1]; + if (!last || last.fileName !== fileName || last.markup !== markup) { + commits.push({ fileName, markup }); + } + }); + return ( +
+ {code.selectedFileName} + {String(!!deferHighlight)} +
+        {code.selectedFile}
+      
+ {code.variants.map((variant) => ( + + ))} + +
+ ); +} + +function renderBlock(code: Code, loader: ComputeHastDeltasLoader) { + commits = []; + const variants = Object.keys(code); + return render( + + + + + , + ); +} + +/** Lets timers, frames and the coordinated swap barriers run their course. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + }); +} + +const renderedFileName = () => screen.getByTestId('file').textContent; +const isDeferred = () => screen.getByTestId('defer').textContent === 'true'; + +describe('CodeHighlighterClient deferHighlight while transform deltas are computed', () => { + it('holds a switch into a variant with transforms, and its JS toggle, until the deltas land', async () => { + const deltas = createHeldDeltas(); + renderBlock({ Plain: plainVariant(), Typed: typedVariant() }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + await settle(); + expect(renderedFileName()).toBe('Plain.js'); + expect(isDeferred()).toBe(true); + const commitsBefore = commits.length; + + // Switch to the TypeScript variant, then ask for JS, while the deltas are + // still being computed. + act(() => { + screen.getByTestId('variant:Typed').click(); + }); + await settle(); + act(() => { + screen.getByTestId('js').click(); + }); + await settle(); + + // Nothing commits until they land. + expect(renderedFileName()).toBe('Plain.js'); + expect(commits).toHaveLength(commitsBefore); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Typed.tsx')); + expect(isDeferred()).toBe(false); + + act(() => { + screen.getByTestId('js').click(); + }); + await waitFor(() => expect(renderedFileName()).toBe('Typed.jsx')); + await settle(); + expect(screen.getByTestId('code').textContent).toContain('const typed = 1;'); + + // Each swap painted its final tree: the variant in TypeScript, then in + // JavaScript, with no re-flow after either. + const typedCommits = commits + .slice(commitsBefore) + .filter(({ fileName }) => fileName !== 'Plain.js'); + expect(typedCommits.map(({ fileName }) => fileName)).toEqual(['Typed.tsx', 'Typed.jsx']); + }); + + it('holds a switch out of a variant with transforms into one without, until the deltas land', async () => { + const deltas = createHeldDeltas(); + renderBlock({ Typed: typedVariant(), Plain: plainVariant() }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(true); + + act(() => { + screen.getByTestId('variant:Plain').click(); + }); + await settle(); + expect(renderedFileName()).toBe('Typed.tsx'); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Plain.js')); + expect(isDeferred()).toBe(false); + }); + + it('holds a switch between variants that all have transforms, until the deltas land', async () => { + const deltas = createHeldDeltas(); + renderBlock({ Typed: typedVariant(), Other: typedVariant('Other') }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(true); + + act(() => { + screen.getByTestId('variant:Other').click(); + }); + await settle(); + expect(renderedFileName()).toBe('Typed.tsx'); + + await deltas.resolveAll(); + await waitFor(() => expect(renderedFileName()).toBe('Other.tsx')); + expect(isDeferred()).toBe(false); + }); + + it('never waits for the deltas when no variant has transforms', async () => { + const deltas = createHeldDeltas(); + renderBlock({ Plain: plainVariant(), Other: plainVariant('Other') }, deltas.loader); + await waitFor(() => expect(deltas.pending).toHaveLength(1)); + expect(isDeferred()).toBe(false); + + act(() => { + screen.getByTestId('variant:Other').click(); + }); + await waitFor(() => expect(renderedFileName()).toBe('Other.js')); + expect(deltas.pending).toHaveLength(1); + expect(isDeferred()).toBe(false); + + await deltas.resolveAll(); + }); +}); diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx index fbe5bf4..df5eca0 100644 --- a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.tsx @@ -29,7 +29,10 @@ import { scatterResidualFallbacks, } from './fallbackCompression'; import { mergeCodeMetadata } from '../pipeline/loadIsomorphicCodeVariant/mergeCodeMetadata'; -import { getAvailableTransforms } from '../pipeline/loadIsomorphicCodeVariant/getAvailableTransforms'; +import { + getAvailableTransforms, + hasAnyVariantTransforms, +} from '../pipeline/loadIsomorphicCodeVariant/getAvailableTransforms'; import { useSpeculativeCodePreload } from './useSpeculativeCodePreload'; import { useSpeculativeEditingPreload } from './useSpeculativeEditingPreload'; import { useSpeculativeUseCodePreload } from './useSpeculativeUseCodePreload'; @@ -690,6 +693,13 @@ function useCodeTransforms({ [parsedCode, loadedCode, variantName], ); + // Whether any variant has transforms, so has deltas worth waiting for. Not + // just `variantName`'s: `useCode` picks the rendered variant on its own. + const hasAnyTransforms = React.useMemo( + () => hasAnyVariantTransforms(parsedCode ?? loadedCode), + [parsedCode, loadedCode], + ); + // Effect to compute transformations for all variants. Only runs when the // full async pipeline is wired (`parsedCode` + worker + deltas computer); // the no-async case is derived during render below instead of being stored, @@ -774,7 +784,7 @@ function useCodeTransforms({ // until its deltas land. const waitingForTransformedCode = hasAsyncPipeline && transformedState.input !== parsedCode; - return { transformedCode, availableTransforms, waitingForTransformedCode }; + return { transformedCode, availableTransforms, hasAnyTransforms, waitingForTransformedCode }; } function useControlledCodeParsing({ @@ -1475,11 +1485,12 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { url: props.url, }); - const { transformedCode, availableTransforms, waitingForTransformedCode } = useCodeTransforms({ - parsedCode, - loadedCode: codeWithGlobals, - variantName, - }); + const { transformedCode, availableTransforms, hasAnyTransforms, waitingForTransformedCode } = + useCodeTransforms({ + parsedCode, + loadedCode: codeWithGlobals, + variantName, + }); // Combined highlight-readiness gate consumed via context (notably by // `useTransformManagement`). Stay deferred while either the sync @@ -1488,11 +1499,14 @@ export function CodeHighlighterClient(props: CodeHighlighterClientProps) { // pending causes the incoming pre to first render without the // transform deltas and then re-flow a frame or two later when the // deltas land, producing a visible jump on top of the collapse - // animation. The wait only matters for highlighters with at least one - // applicable transform; plain (variant-only) highlighters skip it so - // their stored-preference resolution doesn't pay the deltas latency. + // animation. The wait only matters for blocks with at least one + // applicable transform in ANY variant: `useCode` selects the rendered + // variant itself, so this highlighter's `variantName` may be a variant + // without transforms while the one being switched to has them. Blocks + // with no transforms anywhere skip it so their stored-preference + // resolution doesn't pay the deltas latency. const deferHighlight = - deferHighlightForParsing || (availableTransforms.length > 0 && waitingForTransformedCode); + deferHighlightForParsing || (hasAnyTransforms && waitingForTransformedCode); // The fallback↔content swap, generalized into `useCoordinatedSwap`: it owns // the force-mount-once behavior, nested-fallback suppression (via the shared diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts index b2f4b2f..e0ede36 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Code, VariantCode } from '../../CodeHighlighter/types'; -import { getAvailableTransforms } from './getAvailableTransforms'; +import { getAvailableTransforms, hasAnyVariantTransforms } from './getAvailableTransforms'; const createVariantCode = (overrides: Partial = {}): VariantCode => ({ fileName: 'test.js', @@ -151,3 +151,36 @@ describe('getAvailableTransforms', () => { expect(transforms).toEqual(['js']); }); }); + +describe('hasAnyVariantTransforms', () => { + it('is true when a variant other than the first has a transform', () => { + const code: Code = { + Plain: createVariantCode({ source: 'code' }), + Typed: createVariantCode({ + fileName: 'test.ts', + source: 'code', + transforms: { js: { hasDelta: true, fileName: 'test.js' } }, + }), + }; + + expect(hasAnyVariantTransforms(code)).toBe(true); + }); + + it('is false when no variant has a transform with a delta', () => { + const code: Code = { + Plain: createVariantCode({ source: 'code' }), + Renamed: createVariantCode({ + fileName: 'test.ts', + source: 'code', + transforms: { js: { hasDelta: false, fileName: 'test.js' } }, + }), + Loading: 'https://example.com/Loading.js', + }; + + expect(hasAnyVariantTransforms(code)).toBe(false); + }); + + it('is false without code', () => { + expect(hasAnyVariantTransforms(undefined)).toBe(false); + }); +}); diff --git a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts index f7fa11d..cfc0a9d 100644 --- a/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts +++ b/packages/docs/src/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.ts @@ -53,3 +53,14 @@ export function getAvailableTransforms( return Array.from(transforms); } + +/** + * Whether any variant of `code` has an available transform (see + * `getAvailableTransforms`). + */ +export function hasAnyVariantTransforms(code: Code | undefined): boolean { + return ( + !!code && + Object.keys(code).some((variantName) => getAvailableTransforms(code, variantName).length > 0) + ); +} diff --git a/packages/docs/src/pipeline/loaderUtils/optimization.test.ts b/packages/docs/src/pipeline/loaderUtils/optimization.test.ts index e3e43e8..e746de9 100644 --- a/packages/docs/src/pipeline/loaderUtils/optimization.test.ts +++ b/packages/docs/src/pipeline/loaderUtils/optimization.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { resolveModulePath } from './resolveModulePath'; import type { DirectoryEntry } from './resolveModulePath'; +import { parseImportsAndComments } from './parseImportsAndComments'; describe('Filesystem Optimization Tests', () => { it('should make only one directory read when includeTypeDefs is true', async () => { @@ -129,3 +130,91 @@ describe('Filesystem Optimization Tests', () => { }); }); }); + +describe('parseImportsAndComments scales linearly', () => { + // Each input once made the parser rescan earlier text for every position or + // comment, so its parse time grew with the square of its size. In one pass, + // parsing an input 16 times as large takes about 16 times as long; a rescan + // takes about 256 times as long. The limit sits between the two, so the guard + // doesn't depend on how fast the machine is, and taking the fastest of several + // runs at each size keeps one-off pauses (JIT compilation, GC) out of it. + const growth = 16; + const maxTimeGrowth = 64; + const options = { + removeCommentsWithPrefix: ['@highlight'], + notableCommentsPrefix: ['@highlight'], + }; + + /** + * Parses `build(count)` and `build(count * growth)` several times, returning how + * many times as long the larger input took, and its result. + */ + function measureTimeGrowth(build: (count: number) => string, count: number, fileName: string) { + const inputs = [build(count), build(count * growth)]; + const fastestMs = [Infinity, Infinity]; + // The larger input is parsed last in each round, so this ends as its result + let result: ReturnType | undefined; + for (let round = 0; round < 7; round += 1) { + for (let index = 0; index < inputs.length; index += 1) { + const start = performance.now(); + result = parseImportsAndComments(inputs[index], fileName, options); + fastestMs[index] = Math.min(fastestMs[index], performance.now() - start); + } + } + return { timeGrowth: fastestMs[1] / fastestMs[0], result: result! }; + } + + it('reads an MDX import after a very long line', () => { + const { timeGrowth, result } = measureTimeGrowth( + (count) => `${' '.repeat(count)}x\nimport { Button } from './Button';\n`, + 5_000, + '/src/demo.mdx', + ); + + expect(Object.keys(result.relative)).toEqual(['./Button']); + expect(timeGrowth).toBeLessThan(maxTimeGrowth); + }); + + it('strips and keeps many comments on one long line', () => { + const { timeGrowth, result } = measureTimeGrowth( + (count) => 'const count = 1; /* @highlight */ /* note */ '.repeat(count), + 300, + '/src/demo.js', + ); + + expect(result.code).toBe('const count = 1; /* note */ '.repeat(300 * growth)); + expect(timeGrowth).toBeLessThan(maxTimeGrowth); + }); + + it('maps the positions of many imports in stripped code', () => { + const count = 250; + const { timeGrowth, result } = measureTimeGrowth( + (importCount) => + Array.from( + { length: importCount }, + (_, index) => `import { Item${index} } from './item${index}'; // @highlight`, + ).join('\n'), + count, + '/src/demo.ts', + ); + + const lastIndex = count * growth - 1; + const lastPath = `'./item${lastIndex}'`; + const lastStart = result.code!.indexOf(lastPath); + expect(result.relative[`./item${lastIndex}`].positions).toEqual([ + { start: lastStart, end: lastStart + lastPath.length }, + ]); + expect(timeGrowth).toBeLessThan(maxTimeGrowth); + }); + + it('gives up on an unclosed export list without searching the rest of the file', () => { + const { timeGrowth, result } = measureTimeGrowth( + (count) => 'export { first, second\nconst count = 1;\n'.repeat(count), + 250, + '/src/demo.ts', + ); + + expect(result.relative).toEqual({}); + expect(timeGrowth).toBeLessThan(maxTimeGrowth); + }); +}); diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts index 56e444d..d5906da 100644 --- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts +++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.test.ts @@ -2938,6 +2938,34 @@ export const load = () => import('./Chart'); expect(relativePathsOf(code)).toEqual([]); }); }); + + describe('ESM start', () => { + /** The relative import paths found in an MDX document. */ + function relativePathsOf(code: string) { + return Object.keys(parseImportsAndComments(code, '/src/demo.mdx').relative); + } + + // As in MDX, a line starts ESM only when the keyword is followed by a space. + it('reads a line that starts with the keyword and a space', () => { + expect(relativePathsOf(`import './reset.css'\n\nSome text.\n`)).toEqual(['./reset.css']); + }); + + it('does not read a keyword directly followed by a brace as ESM', () => { + const code = `import{Fake}from './fake' + +export{Other}from './other' +`; + expect(relativePathsOf(code)).toEqual([]); + }); + + it('does not read a keyword followed by a line break as ESM', () => { + expect(relativePathsOf(`import\n{ Fake } from './fake'\n`)).toEqual([]); + }); + + it('does not read a keyword followed by a tab as ESM', () => { + expect(relativePathsOf(`import\t{ Fake } from './fake'\n`)).toEqual([]); + }); + }); }); }); diff --git a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts index 10a5ba8..dbf446e 100644 --- a/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts +++ b/packages/docs/src/pipeline/loaderUtils/parseImportsAndComments.ts @@ -206,14 +206,29 @@ function getCodeFenceLength(text: string, pos: number): number { return length; } +/** + * What a code span search saw of the rest of its paragraph: where it stopped, + * and where the last run of backticks of each length starts before that. + */ +interface CodeSpanSearch { + stop: number; + lastRunStarts: Map; +} + /** * Finds the end of an MDX inline code span whose opening run of `length` * backticks ends at `pos`: just past the next run of exactly `length` backticks. * A span stays within its paragraph, so a blank line or a line that opens a code * fence ends the search. Returns `-1` when there's no closing run, in which case - * the opening backticks are plain text. + * the opening backticks are plain text. Records what it saw in `search`. */ -function findCodeSpanEnd(text: string, pos: number, length: number): number { +function findCodeSpanEnd( + text: string, + pos: number, + length: number, + search: CodeSpanSearch, +): number { + search.lastRunStarts.clear(); let cursor = pos; while (cursor < text.length) { if (text[cursor] === '\n') { @@ -223,7 +238,7 @@ function findCodeSpanEnd(text: string, pos: number, length: number): number { firstChar += 1; } if (isRestOfLineBlank(text, lineStart) || getCodeFenceLength(text, firstChar) > 0) { - return -1; + break; } cursor = lineStart; } else if (text[cursor] === '`') { @@ -231,11 +246,13 @@ function findCodeSpanEnd(text: string, pos: number, length: number): number { if (runLength === length) { return cursor + runLength; } + search.lastRunStarts.set(runLength, cursor); cursor += runLength; } else { cursor += 1; } } + search.stop = cursor; return -1; } @@ -251,36 +268,37 @@ function isAtLineStart(text: string, pos: number): boolean { } /** - * Returns where the MDX ESM block that starts at `lineStart` ends, or `lineStart` - * when the line doesn't start one. As in MDX, a block starts with `import ` or - * `export ` (the keyword and then whitespace) and runs until a blank line. + * Whether an MDX ESM block starts at `lineStart`. As in MDX, the line must begin + * with `import` or `export` followed by a space. (Indentation before the keyword + * is also accepted.) */ -function findMdxEsmEnd(text: string, lineStart: number): number { +function isMdxEsmStart(text: string, lineStart: number): boolean { let cursor = lineStart; while (text[cursor] === ' ' || text[cursor] === '\t') { cursor += 1; } - const keywordEnd = cursor + 6; - const startsBlock = + return ( (text.startsWith('import', cursor) || text.startsWith('export', cursor)) && - (text[keywordEnd] === ' ' || text[keywordEnd] === '\t'); - if (!startsBlock) { + text[cursor + 6] === ' ' + ); +} + +/** + * Returns where the MDX ESM block that starts at `lineStart` ends, or `lineStart` + * when the line doesn't start one (see `isMdxEsmStart`). As in MDX, a block runs + * until a blank line. + */ +function findMdxEsmEnd(text: string, lineStart: number): number { + if (!isMdxEsmStart(text, lineStart)) { return lineStart; } - let lineEnd = text.indexOf('\n', keywordEnd); + let lineEnd = text.indexOf('\n', lineStart); while (lineEnd !== -1 && !isRestOfLineBlank(text, lineEnd + 1)) { lineEnd = text.indexOf('\n', lineEnd + 1); } return lineEnd === -1 ? text.length : lineEnd + 1; } -/** - * Whether a dynamic `import(…)` starts at `pos`. - */ -function isDynamicImportAt(text: string, pos: number): boolean { - return text.startsWith('import', pos) && text[skipWhitespace(text, pos + 6)] === '('; -} - /** * Generic function to scan source code character-by-character, finding import statements * while correctly handling strings, comments, and template literals. Optionally processes @@ -314,13 +332,56 @@ function scanForImports( const shouldProcessComments = !!(removeCommentsWithPrefix || notableCommentsPrefix); // Only map positions when actually stripping comments (code will differ from source) const shouldMapPositions = !!removeCommentsWithPrefix; - let result = shouldProcessComments ? '' : sourceCode; + // The processed output (after comment removal), kept as its finished lines plus + // its current line, which is split into everything up to its last + // non-whitespace character and the whitespace after that. A comment can then + // take back, trim or check its line in constant time, however long the line. + let finishedOutput = ''; + let lineOutput = ''; + let lineTrailingSpace = ''; + const appendOutput = (text: string) => { + const lineStart = text.lastIndexOf('\n') + 1; + if (lineStart > 0) { + finishedOutput += lineOutput + lineTrailingSpace + text.slice(0, lineStart); + lineOutput = ''; + lineTrailingSpace = ''; + } + let contentEnd = text.length; + while (contentEnd > lineStart && isWhitespace(text[contentEnd - 1])) { + contentEnd -= 1; + } + if (contentEnd > lineStart) { + lineOutput += lineTrailingSpace + text.slice(lineStart, contentEnd); + lineTrailingSpace = text.slice(contentEnd); + } else { + lineTrailingSpace += text.slice(lineStart); + } + }; + // `appendOutput` for a single character, the scanner's most common append. + const appendOutputChar = (ch: string) => { + if (ch === '\n') { + finishedOutput += lineOutput + lineTrailingSpace + ch; + lineOutput = ''; + lineTrailingSpace = ''; + } else if (isWhitespace(ch)) { + lineTrailingSpace += ch; + } else { + lineOutput += lineTrailingSpace + ch; + lineTrailingSpace = ''; + } + }; // Track whether any comment was actually stripped (not just that the option was provided) let anyCommentStripped = false; - // Position mapping from original source to processed source (after comment removal) - const positionMapping = new Map(); - let processedPos = 0; + // Where each detected statement starts in the source and in the processed + // output. A statement is copied to the output unchanged, so a position inside + // one maps by its offset from the statement's start. + const statementSourceStarts: number[] = []; + const statementOutputStarts: number[] = []; + let detectionSourceStart = 0; + let detectionOutputStart = 0; + const detectionPositionMapper = (originalPos: number): number => + shouldMapPositions ? detectionOutputStart + (originalPos - detectionSourceStart) : originalPos; // Helper to check if a comment matches notable prefix const matchesNotablePrefix = (commentText: string): boolean => { @@ -353,23 +414,37 @@ function scanForImports( // The marker and length of the fence that opened the current MDX code block let codeFenceMarker = ''; let codeFenceLength = 0; + // What the last failed code span search saw of its paragraph. A later opening + // run there that's the last run of its length has no closing run either, so it's + // known to be plain text without searching again: each paragraph is searched + // through at most once for unclosed runs. + let failedCodeSpanSearch: CodeSpanSearch = { stop: -1, lastRunStarts: new Map() }; + let codeSpanSearch: CodeSpanSearch = { stop: -1, lastRunStarts: new Map() }; // Where the current MDX ESM block ends. The block is JavaScript; the rest of // an MDX document outside code blocks is prose. let mdxEsmEnd = 0; // Comment stripping variables let commentStart = 0; let commentStartOutputLine = 0; + // The output line taken back when a comment starts: its content up to the last + // non-whitespace character, and the whitespace after that let preCommentContent = ''; + let preCommentTrailingSpace = ''; // Takes back what the current output line already holds, so a comment's // handler can re-add it (trimmed, or not at all for a comment-only line). It's // read from the output rather than the source: a comment stripped earlier on // the same line is already gone from the output. const takeBackOutputLine = () => { - const outputLineStart = result.lastIndexOf('\n') + 1; - preCommentContent = result.slice(outputLineStart); - result = result.slice(0, outputLineStart); - processedPos -= preCommentContent.length; + preCommentContent = lineOutput; + preCommentTrailingSpace = lineTrailingSpace; + lineOutput = ''; + lineTrailingSpace = ''; + }; + // Puts the taken-back line back, with or without its trailing whitespace. + const restoreOutputLine = (keepTrailingSpace: boolean) => { + lineOutput = preCommentContent; + lineTrailingSpace = keepTrailingSpace ? preCommentTrailingSpace : ''; }; while (i < len) { @@ -380,8 +455,7 @@ function scanForImports( // Track line numbers for newlines in code if (ch === '\n') { if (shouldProcessComments) { - result += ch; - processedPos += 1; + appendOutputChar(ch); } outputLine += 1; i += 1; @@ -405,14 +479,21 @@ function scanForImports( codeFenceLength = fenceLength; skipTo = i + fenceLength; } else if (backtickCount > 0) { - const spanEnd = findCodeSpanEnd(sourceCode, i + backtickCount, backtickCount); + const knownUnclosed = + i < failedCodeSpanSearch.stop && + failedCodeSpanSearch.lastRunStarts.get(backtickCount) === i; + const spanEnd = knownUnclosed + ? -1 + : findCodeSpanEnd(sourceCode, i + backtickCount, backtickCount, codeSpanSearch); + if (spanEnd === -1 && !knownUnclosed) { + [failedCodeSpanSearch, codeSpanSearch] = [codeSpanSearch, failedCodeSpanSearch]; + } skipTo = spanEnd === -1 ? i + backtickCount : spanEnd; } if (skipTo > i) { const skipped = sourceCode.slice(i, skipTo); if (shouldProcessComments) { - result += skipped; - processedPos += skipped.length; + appendOutput(skipped); } outputLine += skipped.split('\n').length - 1; i = skipTo; @@ -446,53 +527,39 @@ function scanForImports( state = ch === '`' ? 'template' : 'string'; stringQuote = ch; if (shouldProcessComments) { - result += ch; - processedPos += 1; + appendOutputChar(ch); } i += 1; continue; } - // Update position mapping for current position - if (shouldProcessComments) { - positionMapping.set(i, processedPos); + // Record where a statement found here would start, for mapping positions + // inside it to the processed output. + if (shouldMapPositions) { + detectionSourceStart = i; + detectionOutputStart = finishedOutput.length + lineOutput.length + lineTrailingSpace.length; } - // Create position mapper function - const positionMapper = (originalPos: number): number => { - if (!shouldMapPositions) { - return originalPos; // No comment stripping, positions are unchanged - } - // Find the closest mapped position - let closest = 0; - positionMapping.forEach((procPos, origPos) => { - if (origPos <= originalPos && origPos > closest) { - closest = origPos; - } - }); - const offset = originalPos - closest; - return (positionMapping.get(closest) || 0) + offset; - }; - - // Use the provided import detector on the original source code. In MDX - // prose, only a static statement at the start of a line can be one: any - // other `import` is text (e.g. "each import's path", or `import()` in a - // sentence), which must not swallow the text after it — and with it the - // start of a code block. Inside an ESM block, every import counts. - const isMdxProseText = - inMdxProse && (!isAtLineStart(sourceCode, i) || isDynamicImportAt(sourceCode, i)); - const detection = isMdxProseText + // Use the provided import detector on the original source code. MDX prose + // holds no statements: any `import` or `export` there is text (e.g. "each + // import's path", or `import()` in a sentence), which must not swallow the + // text after it — and with it the start of a code block. A line that starts + // ESM begins an ESM block, where every import counts. + const detection = inMdxProse ? { found: false, nextPos: i } - : importDetector(sourceCode, i, positionMapper); + : importDetector(sourceCode, i, detectionPositionMapper); if (detection.found) { if (detection.statement) { statements.push(detection.statement); } + if (shouldMapPositions) { + statementSourceStarts.push(detectionSourceStart); + statementOutputStarts.push(detectionOutputStart); + } // Copy the detected import to result if we're building one if (shouldProcessComments) { const importText = sourceCode.slice(i, detection.nextPos); - result += importText; - processedPos += importText.length; + appendOutput(importText); // Count newlines in multi-line imports to keep outputLine accurate for (let j = 0; j < importText.length; j += 1) { if (importText[j] === '\n') { @@ -505,8 +572,7 @@ function scanForImports( } if (shouldProcessComments) { - result += ch; - processedPos += 1; + appendOutputChar(ch); } i += 1; continue; @@ -538,7 +604,7 @@ function scanForImports( if (shouldStrip) { anyCommentStripped = true; // Check if comment is the only thing on its line (ignoring whitespace) - const isCommentOnlyLine = preCommentContent.trim() === ''; + const isCommentOnlyLine = preCommentContent === ''; if (isCommentOnlyLine) { // Don't add the pre-comment content or newline for comment-only lines @@ -547,17 +613,15 @@ function scanForImports( // Comment is inline, keep the pre-comment content (with trailing whitespace trimmed) and newline. // A CRLF line's `\r` is the comment's last character, so restore it with the newline. const lineBreak = sourceCode[i - 1] === '\r' ? '\r\n' : '\n'; - result += preCommentContent.trimEnd(); - result += lineBreak; - processedPos += preCommentContent.trimEnd().length + lineBreak.length; + restoreOutputLine(false); + appendOutput(lineBreak); outputLine += 1; } } else { // Keep the comment and newline - result += preCommentContent; - result += commentText; - result += '\n'; - processedPos += preCommentContent.length + commentText.length + 1; + restoreOutputLine(true); + appendOutput(commentText); + appendOutput('\n'); outputLine += 1; } preCommentContent = ''; @@ -593,69 +657,58 @@ function scanForImports( if (shouldStrip) { anyCommentStripped = true; - // Find the end of the comment and check what's after + // Check what follows the comment on its line: its first non-whitespace + // character, and whether the rest of the line is blank const afterCommentPos = i + 2; - let afterCommentContent = ''; - let nextNewlinePos = sourceCode.indexOf('\n', afterCommentPos); - if (nextNewlinePos === -1) { - nextNewlinePos = sourceCode.length; + let afterCommentStart = afterCommentPos; + while ( + afterCommentStart < len && + sourceCode[afterCommentStart] !== '\n' && + isWhitespace(sourceCode[afterCommentStart]) + ) { + afterCommentStart += 1; } - afterCommentContent = sourceCode.slice(afterCommentPos, nextNewlinePos); // Check for JSX comment syntax: {/* comment */} - // preCommentContent ends with '{' (ignoring whitespace) and afterCommentContent starts with '}' (ignoring whitespace) - const trimmedPreComment = preCommentContent.trimEnd(); - const trimmedAfterComment = afterCommentContent.trimStart(); + // preCommentContent ends with '{' (ignoring whitespace) and the comment is followed by '}' (ignoring whitespace) const isJsxComment = - trimmedPreComment.endsWith('{') && trimmedAfterComment.startsWith('}'); + sourceCode[afterCommentStart] === '}' && preCommentContent.endsWith('{'); // For JSX comments, check if removing the braces leaves only whitespace const preCommentWithoutBrace = isJsxComment - ? trimmedPreComment.slice(0, -1) + ? preCommentContent.slice(0, -1).trimEnd() : preCommentContent; - const afterCommentWithoutBrace = isJsxComment - ? trimmedAfterComment.slice(1) - : afterCommentContent; + const afterCommentIsBlank = isJsxComment + ? isRestOfLineBlank(sourceCode, afterCommentStart + 1) + : isRestOfLineBlank(sourceCode, afterCommentPos); - const isCommentOnlyLines = - preCommentWithoutBrace.trim() === '' && afterCommentWithoutBrace.trim() === ''; + const isCommentOnlyLines = preCommentWithoutBrace === '' && afterCommentIsBlank; if (isCommentOnlyLines) { // Skip the entire comment and everything up to and including the next newline // For JSX comments, this also skips the surrounding braces - i = nextNewlinePos; - if (i < len && sourceCode[i] === '\n') { - // Skip the newline entirely - advance to the character after it - i += 1; - } + const nextNewlinePos = sourceCode.indexOf('\n', afterCommentPos); + i = nextNewlinePos === -1 ? len : nextNewlinePos + 1; state = 'code'; preCommentContent = ''; continue; } else if (isJsxComment) { // JSX comment is inline with other code - strip the braces too // e.g., `