Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<object>) {
// @focus-start @padding 1
const code = useCode(props);
const isJsSelected = code.selectedTransform === 'js';

return (
<div>
{code.availableTransforms.includes('js') ? (
<button type="button" onClick={() => code.selectTransform(isJsSelected ? null : 'js')}>
{isJsSelected ? 'TS' : 'JS'}
</button>
) : null}
{code.files.map((file) => (
<section key={file.name} data-file={file.name}>
<h3>{file.name}</h3>
<CodeSource>{file.component}</CodeSource>
</section>
))}
</div>
);
// @focus-end
}
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
{/* @focus-start */}
<div data-testid="tabs">
<CodeHighlighter
code={code}
name="Checkbox"
slug="checkbox-tabs"
Content={CodeContent}
sourceParser={sourceParser}
highlightAfter="init"
/>
</div>
<div data-testid="files">
<CodeHighlighter
code={code}
name="Checkbox"
slug="checkbox-files"
Content={FilesContent}
sourceParser={sourceParser}
sourceTransformers={sourceTransformers}
highlightAfter="init"
/>
</div>
{/* @focus-end */}
</React.Fragment>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<label className={styles.root}>
<input type="checkbox" checked={checked} onChange={toggle} />
{label}
</label>
);
}`;

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<boolean>(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 },
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import { InlineFiles } from './InlineFiles';

export default function Page() {
return <InlineFiles />;
}
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading
Loading