Skip to content

Commit

Permalink
Allow dynamically loaded example files
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed May 17, 2024
1 parent 2d2cffd commit e8a1c44
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 111 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY --from=builder /frontend-shared/build /usr/share/nginx/html
COPY ./templates/index.html /usr/share/nginx/html/index.html
COPY ./images /usr/share/nginx/html/images
COPY ./src/pattern-library/examples /usr/share/nginx/html/examples
COPY conf/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 5001
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ import { Link } from '@hypothesis/frontend-shared';
- [Development guide](docs/developing.md)
- [Release guide](docs/releases.md)
- [Adding examples](docs/examples.md)
28 changes: 28 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Adding code examples

Component library documentation frequently needs to show interactive examples, along with the code for them.

Manually writing those code snippets has some issues: they are not covered by your type checking and linting tasks, and they can accidentally get outdated.

The web-based documentation included with this library allows to create example files which are both used as regular modules that can be imported for interactive examples, but also read as plain text to generate their corresponding code snippet.

These files are type-checked, formatted and linted like any other source files, and the code snippet will always be in sync with the interactive example.

## Using example files

When you want to create a code example, add a file with the name of your choice inside `src/pattern-library/examples` directory.

You can then reference this file from the web-based pattern library, passing the `exampleFile` prop to the `<Library.Demo />` component.

```tsx
<Library.Demo exampleFile="some-example-file-name" />
```

## Considerations

There are some considerations and limitations when working with example files.

- They all need to have the `tsx` extension.
- Nested directories are not supported, so it's a good idea to add contextual prefixes to example files. For example, all files related with `SelectNext` can be prefixed with `select-next` to make sure they are all grouped together.
- Example files need to have a Preact component as their default export.
- When generating the source code snippet, import statements are stripped out, to avoid internal module references which are not relevant for the library consumers.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@hypothesis/frontend-testing": "^1.2.2",
"@rollup/plugin-babel": "^6.0.0",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-dynamic-import-vars": "^2.1.2",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-virtual": "^3.0.0",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
Expand Down
2 changes: 2 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { babel } from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import dynamicImportVars from '@rollup/plugin-dynamic-import-vars';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { string } from 'rollup-plugin-string';

Expand Down Expand Up @@ -27,6 +28,7 @@ function bundleConfig(name, entryFile) {
exclude: 'node_modules/**',
extensions: ['.js', '.ts', '.tsx'],
}),
dynamicImportVars(),
nodeResolve({ extensions: ['.js', '.ts', '.tsx'] }),
commonjs({ include: 'node_modules/**' }),
string({
Expand Down
4 changes: 4 additions & 0 deletions scripts/serve-pattern-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export function servePatternLibrary(port = 4001) {
app.use('/scripts', express.static(path.join(dirname, '../build/scripts')));
app.use('/styles', express.static(path.join(dirname, '../build/styles')));
app.use('/images', express.static(path.join(dirname, '../images')));
app.use(
'/examples',
express.static(path.join(dirname, '../src/pattern-library/examples')),
);

// For any other path, serve the index.html file to allow client-side routing
app.get('/:path?', (req, res) => {
Expand Down
221 changes: 175 additions & 46 deletions src/pattern-library/components/Library.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import classnames from 'classnames';
import { toChildArray, createContext } from 'preact';
import type { ComponentChildren, JSX } from 'preact';
import { useMemo, useState, useContext } from 'preact/hooks';
import { useState, useContext, useEffect, useCallback } from 'preact/hooks';
import { Link as RouteLink } from 'wouter-preact';

import {
Expand Down Expand Up @@ -165,8 +165,28 @@ function Example({ children, title, ...htmlAttributes }: LibraryExampleProps) {
);
}

function SimpleError({ message }: { message: string }) {
return (
<div className="w-full text-red-600 p-2 border rounded border-red-600">
{message}
</div>
);
}

export type LibraryDemoProps = {
children: ComponentChildren;
children?: ComponentChildren;

/**
* Example file to read and use as content (to be rendered with syntax
* highlighting).
* It should be relative to the `pattern-library/examples` dir, and include no
* file extension: `exampleFile="some-example"`.
*
* The file needs to have a default export, which will be used to render the
* interactive example
*/
exampleFile?: string;

/** Extra CSS classes for the demo content's immediate parent container */
classes?: string | string[];
/** Inline styles to apply to the demo container */
Expand All @@ -179,32 +199,94 @@ export type LibraryDemoProps = {
withSource?: boolean;
};

/**
* Fetches provided example file and returns its contents as text, excluding
* the import statements.
* An error is thrown if the file cannot be fetched for any reason.
*/
async function fetchCodeExample(
exampleFile: string,
signal: AbortSignal,
): Promise<string> {
const res = await fetch(`/examples/${exampleFile}.tsx`, { signal });
if (res.status >= 400) {
throw new Error(`Failed loading ${exampleFile} example file`);
}

const text = await res.text();

// Remove import statements and trim trailing empty lines
return text.replace(/^import .*;\n/gm, '').replace(/^\s*\n*/, '');
}

function useDemoChildrenAndSource(
props: Pick<LibraryDemoProps, 'children' | 'exampleFile'>,
) {
const [source, setSource] = useState<ComponentChildren>();
const [children, setChildren] = useState<ComponentChildren>();

useEffect(() => {
if (props.exampleFile) {
import(`../examples/${props.exampleFile}.tsx`)
.then(({ default: App }) => setChildren(<App />))
.catch(() =>
setChildren(
<SimpleError
message={`Failed loading ../examples/${props.exampleFile}.tsx module`}
/>,
),
);

const controller = new AbortController();
fetchCodeExample(props.exampleFile, controller.signal)
.then(code => setSource(<Code content={code} />))
.catch(e => setSource(<SimpleError message={e.message} />));

return () => controller.abort();
}

setChildren(props.children);
setSource(
<div className="border w-full rounded-md bg-slate-7 text-color-text-inverted p-4">
<ul>
{toChildArray(props.children).map((child, idx) => {
return (
<li key={idx}>
<code>
<pre
className="font-pre whitespace-pre-wrap break-words text-sm"
dangerouslySetInnerHTML={{ __html: jsxToHTML(child) }}
/>
</code>
</li>
);
})}
</ul>
</div>,
);

return () => {};
}, [props.exampleFile, props.children]);

return { source, children };
}

/**
* Render a "Demo", with optional source. This will render the children as
* provided in a tabbed container. If `withSource` is `true`, the JSX source
* of the children will be provided in a separate "Source" tab from the
* rendered Demo content.
*/
function Demo({
children,
classes,
withSource = false,
style = {},
title,
...rest
}: LibraryDemoProps) {
const [visibleTab, setVisibleTab] = useState('demo');
const source = toChildArray(children).map((child, idx) => {
return (
<li key={idx}>
<code>
<pre
className="font-pre whitespace-pre-wrap break-words text-sm"
dangerouslySetInnerHTML={{ __html: jsxToHTML(child) }}
/>
</code>
</li>
);
});
const { children, source } = useDemoChildrenAndSource(rest);

return (
<div className="my-8 p-2 space-y-1">
<div className="flex items-center px-2">
Expand Down Expand Up @@ -255,11 +337,7 @@ function Demo({
</div>
</div>
)}
{visibleTab === 'source' && (
<div className="border w-full rounded-md bg-slate-7 text-color-text-inverted p-4">
<ul>{source}</ul>
</div>
)}
{visibleTab === 'source' && source}
</div>
</div>
);
Expand Down Expand Up @@ -332,48 +410,99 @@ function ChangelogItem({ status, children }: LibraryChangelogItemProps) {
);
}

type CodeContentProps =
| {
/** Code content (to be rendered with syntax highlighting) */
content: ComponentChildren;
}
| {
/**
* Example file to read and use as content (to be rendered with syntax
* highlighting)
*/
exampleFile: string;
};

export type LibraryCodeProps = {
/** Code content (to be rendered with syntax highlighting) */
content: ComponentChildren;
/** Controls relative code font size */
size?: 'sm' | 'md';
/** Caption (e.g. filename, description) of code block */
title?: ComponentChildren;
};
} & CodeContentProps;

function isCodeWithContent(
props: CodeContentProps,
): props is { content: ComponentChildren } {
return 'content' in props;
}

/**
* Dynamically resolves the content based on provided props.
* An error is optionally returned in case loading the content failed.
*/
function useCodeContent(
props: CodeContentProps,
): [string | undefined, Error | undefined] {
const [codeMarkup, setCodeMarkup] = useState<string>();
const [error, setError] = useState<Error>();
const setJSXContent = useCallback(
(content: ComponentChildren) => setCodeMarkup(jsxToHTML(content)),
[],
);

useEffect(() => {
if (isCodeWithContent(props)) {
setJSXContent(props.content);
return () => {};
}

const controller = new AbortController();
fetchCodeExample(`/examples/${props.exampleFile}.tsx`, controller.signal)
.then(setJSXContent)
.catch(setError);

return () => controller.abort();
}, [props, setJSXContent]);

return [codeMarkup, error];
}

/**
* Render provided `content` as a "code block" example.
*
* Long code content will scroll if <Code /> is rendered inside a parent
* element with constrained dimensions.
*/
function Code({ content, size, title }: LibraryCodeProps) {
const codeMarkup = useMemo(() => jsxToHTML(content), [content]);
function Code({ size, title, ...rest }: LibraryCodeProps) {
const [codeMarkup, error] = useCodeContent(rest);

return (
<figure className="space-y-2 min-h-0 h-full">
<ScrollContainer borderless>
<div
className={classnames(
'unstyled-text bg-slate-7 text-color-text-inverted p-4 rounded-md min-h-0 h-full',
{ 'text-sm': size === 'sm' },
{codeMarkup && (
<ScrollContainer borderless>
<div
className={classnames(
'unstyled-text bg-slate-7 text-color-text-inverted p-4 rounded-md min-h-0 h-full',
{ 'text-sm': size === 'sm' },
)}
>
<Scroll variant="flat">
<code className="text-color-text-inverted">
<pre
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: codeMarkup }}
/>
</code>
</Scroll>
</div>
{title && (
<figcaption className="flex justify-end">
<span className="italic">{title}</span>
</figcaption>
)}
>
<Scroll variant="flat">
<code className="text-color-text-inverted">
<pre
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: codeMarkup }}
/>
</code>
</Scroll>
</div>
{title && (
<figcaption className="flex justify-end">
<span className="italic">{title}</span>
</figcaption>
)}
</ScrollContainer>
</ScrollContainer>
)}
{error && <SimpleError message={error.message} />}
</figure>
);
}
Expand Down
Loading

0 comments on commit e8a1c44

Please sign in to comment.