From b3bcd1860759c05f68b61a823af4abf5ca242bb6 Mon Sep 17 00:00:00 2001 From: dav-is Date: Sat, 26 Sep 2026 13:35:52 -0400 Subject: [PATCH] Fix highlighting of inline multi-file code under 'init' --- .../demo-init-inline-files/FilesContent.tsx | 34 ++++ .../demo-init-inline-files/InlineFiles.tsx | 47 ++++++ .../demos/demo-init-inline-files/code.ts | 51 ++++++ .../demos/demo-init-inline-files/page.tsx | 6 + .../demos/demo-init-inline-files/test.ts | 111 +++++++++++++ ...CodeHighlighterClient.stringFiles.test.tsx | 153 ++++++++++++++++++ .../CodeHighlighter/CodeSourceLoader.test.tsx | 116 +++++++++++++ .../loadIsomorphicCodeVariant.test.ts | 139 +++++++++++++++- .../loadIsomorphicCodeVariant.ts | 105 ++++++------ .../parseSource/ensureGrammars.test.ts | 28 ++++ .../src/pipeline/parseSource/grammarCache.ts | 30 ++-- .../src/pipeline/parseSource/parseSource.ts | 32 +++- 12 files changed, 780 insertions(+), 72 deletions(-) create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/FilesContent.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/InlineFiles.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/code.ts create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/page.tsx create mode 100644 docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/test.ts create mode 100644 packages/docs/src/CodeHighlighter/CodeHighlighterClient.stringFiles.test.tsx create mode 100644 packages/docs/src/CodeHighlighter/CodeSourceLoader.test.tsx diff --git a/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/FilesContent.tsx b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/FilesContent.tsx new file mode 100644 index 0000000..c326378 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/FilesContent.tsx @@ -0,0 +1,34 @@ +'use client'; + +import * as React from 'react'; +import type { ContentProps } from '@fairgarden/docs/CodeHighlighter/types'; +import { useCode } from '@fairgarden/docs/useCode'; +import { CodeSource } from '../CodeSource'; + +/** + * Renders every file of the selected variant at once, each under its name, so + * all of them are in the initial HTML. The JS button toggles the TS → JS + * transform for every file. + */ +export function FilesContent(props: ContentProps) { + // @focus-start @padding 1 + const code = useCode(props); + const isJsSelected = code.selectedTransform === 'js'; + + return ( +
+ {code.availableTransforms.includes('js') ? ( + + ) : null} + {code.files.map((file) => ( +
+

{file.name}

+ {file.component} +
+ ))} +
+ ); + // @focus-end +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/InlineFiles.tsx b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/InlineFiles.tsx new file mode 100644 index 0000000..107207f --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/InlineFiles.tsx @@ -0,0 +1,47 @@ +import 'server-only'; + +import * as React from 'react'; +import { CodeHighlighter } from '@fairgarden/docs/CodeHighlighter'; +import { createParseSource } from '@fairgarden/docs/pipeline/parseSource'; +import { TypescriptToJavascriptTransformer } from '@fairgarden/docs/pipeline/transformTypescriptToJavascript'; + +import { CodeContent } from '../CodeContent'; +import { FilesContent } from './FilesContent'; +import { code } from './code'; + +const sourceParser = createParseSource(); +const sourceTransformers = [TypescriptToJavascriptTransformer]; + +/** + * Inline multi-file `code` with no `url`, highlighted on init: once with file + * tabs, and once with every file shown at once plus a TS → JS transform. + */ +export function InlineFiles() { + return ( + + {/* @focus-start */} +
+ +
+
+ +
+ {/* @focus-end */} +
+ ); +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/code.ts b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/code.ts new file mode 100644 index 0000000..4899790 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/code.ts @@ -0,0 +1,51 @@ +import type { Code } from '@fairgarden/docs/CodeHighlighter/types'; + +const checkbox = `import * as React from 'react'; +import { useChecked } from './useChecked'; +import styles from './checkbox.module.css'; + +interface CheckboxProps { + label: string; +} + +export function Checkbox({ label }: CheckboxProps) { + const [checked, toggle] = useChecked(false); + return ( + + ); +}`; + +const checkboxStyles = `.root { + display: inline-flex; + gap: 8px; + align-items: center; +}`; + +const useChecked = `import * as React from 'react'; + +export function useChecked(initial: boolean): [boolean, () => void] { + const [checked, setChecked] = React.useState(initial); + const toggle = React.useCallback(() => setChecked((value) => !value), []); + return [checked, toggle]; +}`; + +const index = `export * from './Checkbox';`; + +/** + * One variant with four inline files and no `url`: every source is a plain + * string the highlighter has to parse itself. + */ +export const code: Code = { + Default: { + fileName: 'Checkbox.tsx', + source: checkbox, + extraFiles: { + 'checkbox.module.css': { source: checkboxStyles }, + 'useChecked.ts': { source: useChecked }, + 'index.ts': { source: index }, + }, + }, +}; diff --git a/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/page.tsx b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/page.tsx new file mode 100644 index 0000000..33e74d3 --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { InlineFiles } from './InlineFiles'; + +export default function Page() { + return ; +} diff --git a/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/test.ts b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/test.ts new file mode 100644 index 0000000..8b7119a --- /dev/null +++ b/docs/app/lib/components/code-highlighter/demos/demo-init-inline-files/test.ts @@ -0,0 +1,111 @@ +import path from 'node:path'; +import { test, expect } from '@playwright/test'; +import type { Locator, Page } 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()!; + +// Inline multi-file `code` with no `url`, highlighted on init. Nothing tells the +// server where the files live, yet every one of them must be highlighted in the +// initial HTML and stay highlighted once the page hydrates. +const fileNames = ['Checkbox.tsx', 'checkbox.module.css', 'useChecked.ts', 'index.ts']; +const highlighted = '[class*="pl-"]'; + +/** Collects uncaught errors and console errors from the page. */ +function collectErrors(page: Page) { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + // The static export serves no Vercel analytics scripts. + if (message.type() === 'error' && !message.location().url.includes('/_vercel/')) { + errors.push(message.text()); + } + }); + return errors; +} + +/** + * Scrolls a file into view and expects its code highlighted: frames outside the + * viewport are only highlighted once they scroll into view. + */ +async function expectHighlightedInView(file: Locator) { + await file.scrollIntoViewIfNeeded(); + await expect(file.locator(highlighted).first()).toBeVisible(); +} + +/** Selects a file tab, retrying until the page has hydrated and the tab responds. */ +async function selectTab(page: Page, fileName: string) { + const tab = page.getByTestId('tabs').getByRole('tab', { name: fileName }); + await expect(async () => { + await tab.click(); + await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 1000 }); + }).toPass({ timeout: 15000 }); +} + +test('server-renders every file of inline code with no url highlighted', async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.goto(route); + + const files = page.getByTestId('files'); + await Promise.all( + fileNames.map((fileName) => + expect( + files.locator(`section[data-file="${fileName}"] ${highlighted}`).first(), + ).toBeAttached(), + ), + ); + // The tabs render their selected file, the main one. + await expect(page.getByTestId('tabs').locator(`pre ${highlighted}`).first()).toBeAttached(); + + await context.close(); +}); + +test('keeps every file of inline code with no url highlighted after hydration', async ({ + page, +}) => { + const errors = collectErrors(page); + await page.goto(route); + + // One file at a time: each step selects a tab or scrolls the page. + /* eslint-disable no-await-in-loop */ + const tabsCode = page.getByTestId('tabs').locator('pre'); + for (const fileName of fileNames) { + await selectTab(page, fileName); + await expect(tabsCode.locator(highlighted).first()).toBeVisible(); + } + + const files = page.getByTestId('files'); + for (const fileName of fileNames) { + await expectHighlightedInView(files.locator(`section[data-file="${fileName}"]`)); + } + /* eslint-enable no-await-in-loop */ + + expect(errors).toEqual([]); +}); + +test('switches every TypeScript file of inline code with no url to JavaScript', async ({ + page, +}) => { + const errors = collectErrors(page); + await page.goto(route); + // Wait for hydration with a click that is safe to repeat. + await selectTab(page, 'index.ts'); + + const files = page.getByTestId('files'); + await files.getByRole('button', { name: 'JS' }).click(); + + const component = files.locator('section[data-file="Checkbox.jsx"]'); + const hook = files.locator('section[data-file="useChecked.js"]'); + await expect(component).toBeAttached({ timeout: 15000 }); + await expect(hook).toBeAttached(); + await expect(component).not.toContainText('interface CheckboxProps'); + await expect(hook).not.toContainText(': boolean'); + await expectHighlightedInView(component); + await expectHighlightedInView(hook); + + expect(errors).toEqual([]); +}); diff --git a/packages/docs/src/CodeHighlighter/CodeHighlighterClient.stringFiles.test.tsx b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.stringFiles.test.tsx new file mode 100644 index 0000000..b719f57 --- /dev/null +++ b/packages/docs/src/CodeHighlighter/CodeHighlighterClient.stringFiles.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + * + * A file whose source reaches the client as a plain string (never parsed on the + * server) is parsed and highlighted by `CodeHighlighterClient` once it hydrates. + * Under `CodeProviderLazy` grammars load on demand, so the parse waits until the + * grammars for the block's files can actually be used. + */ +import * as React from 'react'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { CodeHighlighterClient } from './CodeHighlighterClient'; +import { CodeContext } from '../CodeProvider/CodeContext'; +import { useCode } from '../useCode'; +import { createParseSource, resetStarryNight } from '../pipeline/parseSource/parseSource'; +import { ensureGrammars } from '../pipeline/parseSource/grammarCache'; +import { parseCode } from '../pipeline/loadIsomorphicCodeVariant/parseCode'; +import type { Code, ContentProps, HastRoot, ParseSource } from './types'; + +// Where `parseSource` keeps the shared Starry Night instance. +const STARRY_NIGHT_KEY = '__docs_infra_starry_night_instance__'; + +beforeAll(() => { + // `
` observes frame visibility; jsdom has neither observer.
+  class NoopObserver {
+    observe() {}
+
+    unobserve() {}
+
+    disconnect() {}
+
+    takeRecords(): IntersectionObserverEntry[] {
+      return [];
+    }
+  }
+  globalThis.IntersectionObserver = NoopObserver as unknown as typeof IntersectionObserver;
+  globalThis.ResizeObserver = NoopObserver as unknown as typeof ResizeObserver;
+});
+
+beforeEach(() => {
+  resetStarryNight();
+});
+
+/** Renders every file of the selected variant, each under its name. */
+function FilesContent(props: ContentProps) {
+  const code = useCode(props);
+  return (
+    
+ {code.files.map((file) => ( +
+ {file.component} +
+ ))} +
+ ); +} + +/** The main file as the server parsed it: one highlighted line. */ +const parsedMainFile: HastRoot = { + type: 'root', + data: { totalLines: 1, focusedLines: 1 }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: 'frame' }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: 'line', dataLn: 1 }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: ['pl-k'] }, + children: [{ type: 'text', value: 'export' }], + }, + { type: 'text', value: ' const Button = () => null;' }, + ], + }, + ], + }, + ], +}; + +/** The main file parsed, its extra files still plain strings. */ +const code: Code = { + Default: { + fileName: 'Button.tsx', + source: parsedMainFile, + extraFiles: { + 'button.css': { source: '.button {\n color: red;\n}' }, + 'useToggle.ts': { source: 'export const useToggle = (value: boolean) => !value;' }, + }, + }, +}; + +function renderBlock(parseSource: ParseSource) { + return render( + + + + + , + ); +} + +const isHighlighted = (fileName: string) => + screen.getByTestId(fileName).querySelector('[class*="pl-"]') !== null; + +describe('CodeHighlighterClient string files', () => { + it('highlights extra files that are still strings after hydration', async () => { + renderBlock(await createParseSource()); + + await waitFor(() => { + expect(isHighlighted('button.css')).toBe(true); + expect(isHighlighted('useToggle.ts')).toBe(true); + }); + expect(isHighlighted('Button.tsx')).toBe(true); + }); + + it('highlights them when their grammars are still being registered as the block mounts', async () => { + // Grammars load on demand, as under `CodeProviderLazy`. + const parseSource = await createParseSource([]); + + // Another block on the page is registering the same grammars when this one + // mounts: the engine already lists them, but can't highlight with them yet. + const engine = (globalThis as Record)[STARRY_NIGHT_KEY] as { + register: (grammars: unknown[]) => Promise; + }; + const register = engine.register; + let mounted = false; + engine.register = async (grammars) => { + const registering = register(grammars); + if (!mounted) { + mounted = true; + renderBlock(parseSource); + } + await registering; + }; + + await ensureGrammars(['source.tsx', 'source.css', 'source.ts']); + + expect(mounted).toBe(true); + await waitFor(() => { + expect(isHighlighted('button.css')).toBe(true); + expect(isHighlighted('useToggle.ts')).toBe(true); + }); + }); +}); diff --git a/packages/docs/src/CodeHighlighter/CodeSourceLoader.test.tsx b/packages/docs/src/CodeHighlighter/CodeSourceLoader.test.tsx new file mode 100644 index 0000000..06d6fbd --- /dev/null +++ b/packages/docs/src/CodeHighlighter/CodeSourceLoader.test.tsx @@ -0,0 +1,116 @@ +/** + * `CodeSourceLoader` is the server loader `CodeHighlighter` routes to when its code + * still has to be highlighted: it loads and parses every variant, then renders + * `CodeHighlighterClient`. These tests render its output with `renderToString`, as + * the server render does: there is no `CodeProvider`, so the client cannot parse + * anything itself and shows only what the loader already highlighted. + */ +import * as React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; +import { describe, it, expect, beforeAll } from 'vitest'; +import CodeSourceLoader from './CodeSourceLoader'; +import type { CodeHighlighterChunkContentProps } from './CodeHighlighterChunk'; +import { useCode } from '../useCode'; +import { createParseSource } from '../pipeline/parseSource'; +import { hasAllVariants } from '../pipeline/loadIsomorphicCodeVariant/hasAllCodeVariants'; +import type { Code, ContentProps, ParseSource } from './types'; + +let parseSource: ParseSource; + +beforeAll(async () => { + parseSource = await createParseSource(); +}); + +/** Renders every file of the selected variant, each under its name. */ +function FilesContent(props: ContentProps) { + const code = useCode(props); + return ( +
+ {code.files.map((file) => ( +
+ {file.component} +
+ ))} +
+ ); +} + +/** One variant whose files are all inline strings, with no `url`. */ +const inlineCode: Code = { + Default: { + fileName: 'Button.tsx', + source: 'export const Button = () =>