This library provides a collection of custom HTML elements that allow easy React component library documentation.
The aim is:
- to be documentation stack independent
- to have very little dependencies
- to not force a specific version of
React
These components allow to create a live editable TSX/JSX component demo in browser.
Design decisions:
- The documentation elements are
<textarea>based custom element. This is needed so as that special HTML characters (<,>etc.) are not parsed by the browser and are accessible to JS as text. - All rendering code is run from within a
<script>tag inserted into the document. This is needed so that theReactversion is not hard-coded in this library and can be loaded by the user in the desired version. - The preview code is transpiled in browser by Sucrase See Limitations.
- Components preview are completely isolated inside a shadowDom from the page CSS styles.
The preview code is transpiled by Sucrase which only strips TypeScript types and transforms JSX. This means:
- The syntax is not transpiled to older ECMAScript versions, so the browser must natively support all the syntax used in the preview code.
- TypeScript types are not checked, they are only stripped.
namespacedeclarations are silently removed, so accessing their members fails at runtime.
See index.html for a basic working example.
In short, you need to:
- Include dependencies from CDN:
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script> <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
- Include code from this package:
<script src="/generated/bundle.js"></script> <link type="text/css" rel="stylesheet" href="/main.css" />
- Optionally define
docoff-react-baseelements with code that is to be common for alldocoff-react-previewelements on the given page.⚠ All<textarea is="docoff-react-base"> const MyHello = ({name}) => <strong>Hello, {name}</strong>; </textarea>
docoff-react-baseelements must be defined before anydocoff-react-previewelement. - Define
docoff-react-previewelement with code that renders the JSX:The content always needs to be a single React element. To achieve that we either wrap the content in<textarea is="docoff-react-preview"> <MyHello name="Igor" /> </textarea>
React.Fragment:or we explicitly create the React element (useful for hooks):<textarea is="docoff-react-preview"> <> <MyHello name="Igor" /> <MyHello name="Uwe" /> </> </textarea>
React.createElement(() => { const [isActive, setIsActive] = React.useState(false); return ( <MyButton label={isActive ? 'On' : 'Off'} onClick={() => setIsActive(!isActive)} /> ); });
This element renders a table of props of a React component written in TypeScript.
Design decisions:
- Everything happens in browser to eliminate the need for a build pipe. The props are read directly from the TypeScript source files by react-docgen, the types are neither checked nor compiled.
- It is opinionated as there was no way to make it useful without it. The types are evaluated, so that the reader sees what a type consists of instead of how it is written. See Type Evaluation.
See index.html for a basic working example.
- Include package dependencies:
<script src="/generated/bundle.js"></script> <link type="text/css" rel="stylesheet" href="main.css" />
- Use the
<docoff-react-props>element:<docoff-react-props src="/components/Button/Button.tsx" name="Button"></docoff-react-props>
Both attributes are required:
srcis the URL of a TypeScript file (*.tsor*.tsx).nameis the name of what the table is rendered for. It is either:- a component defined in the file. Its props are read from the type of its props, their default values from the default values of the parameters of the component.
- a type exported from the file, e.g.
<docoff-react-props src="/components/Button/Button.types.ts" name="ButtonProps">, ordefaultfor the type exported by default. Its properties are presented as props, without default values.
The type of the props can be defined in a separate file. Types imported using relative imports are resolved, the imported files are downloaded from URL relative to the file that imports them. When the import does not specify file extension, .ts and .tsx files are tried, first as a file, then as an index file in a directory.
The requests for the tried files that do not exist fail. They can be avoided by the resolveRelativeImports option, the URL of a single file that contains all the types, e.g. bundled type declarations (*.d.ts) of a library. The relative imports of the file in the src attribute are then not downloaded, the imported types are looked up by their names among the exports of that file instead, regardless of the path they are imported from. When the file cannot be downloaded, e.g. during development before it is built, the relative imports are downloaded as usual. The default values of the props are still read from the component:
<script>
window.docoffConfig = {
reactProps: {
resolveRelativeImports: '/library.d.ts',
},
};
</script>The type of the props can also be read from such a file directly. It contains no component, so name must be the type of the props, and the props are presented without default values.
Types imported from packages are not resolved, only their name is presented, unless the package is listed in the resolvePackages option. The option defines where the types of the package are placed, which is either:
- a folder. The path that follows the name of the package in the import is appended to its URL, e.g.
@scope/package/src/components/Buttonis downloaded fromhttps://example.com/package/src/components/Button, the same way relative imports are. - a file (
*.ts,*.tsx,*.d.ts), e.g. the bundled type declarations of the package. The types are looked up by their names among the exports of the file, regardless of the path they are imported from.
<script>
window.docoffConfig = {
reactProps: {
resolvePackages: {
'@scope/package': 'https://example.com/package',
'@scope/another-package': 'https://example.com/another-package.d.ts',
},
},
};
</script>Beware, that:
- All the imported files must be available for download just as the file in the
srcattribute is. - Interfaces are resolved the same way as types are, but an interface declared more than once is not merged.
Types are evaluated before they are presented. Types that cannot be evaluated are presented as they are written, with the types they consist of evaluated, e.g. Promise<Direction> is presented as Promise<'asc' | 'desc'>.
| What is evaluated | Example | Presented as |
|---|---|---|
Types listed in evaluateTypes |
Partial<Record<'top' | 'bottom', number>> |
object with optional top and bottom properties |
Functions (expandFunctionSignatures) |
(direction: Direction) => void |
(direction: 'asc' | 'desc') => void |
Unions consisting only of unions of literals (mergeLiteralUnions) |
ActionColor | FeedbackColor |
single union of all the values |
Intersections of objects (mergeObjectIntersections) |
Position & Size |
single object with properties of both |
Besides the utility types, evaluateTypes lists keyof for the keyof operator, e.g. keyof Props, and indexedAccess for indexed access types, e.g. Props['size']. An optional property read by indexed access type can be undefined, the same way as in TypeScript.
Functions whose parameters cannot be matched to their types, e.g. generic functions, are presented as they are written. Unions that contain other types than literals are kept as they are, so that the values stay grouped. When more objects of an intersection define the same property, the property is of the intersection of the types of its definitions, e.g. ReactNode & string, and it is required when any of its definitions is required. The same types are presented only once.
The props themselves are the properties of the evaluated type of the props, so the type can be composed, e.g. Omit<LibraryButtonProps, 'label'> & { label: string }. The parts of the type that cannot be resolved, e.g. HTML attributes defined by React, are left out.
The element is configured by the reactProps option of the window.docoffConfig object. When it is not defined, the following default configuration is used. For the basePath option, see Base Path.
window.docoffConfig = {
basePath: undefined,
reactProps: {
evaluateTypes: {
Awaited: true,
Capitalize: true,
Exclude: true,
Extract: true,
Lowercase: true,
NoInfer: true,
NonNullable: true,
Omit: true,
Partial: true,
Pick: true,
PropsWithChildren: true,
Readonly: true,
ReadonlyArray: true,
Record: true,
Required: true,
Uncapitalize: true,
Uppercase: true,
indexedAccess: true,
keyof: true,
},
expandFunctionSignatures: true,
mergeLiteralUnions: true,
mergeObjectIntersections: true,
resolvePackages: {},
resolveRelativeImports: undefined,
},
};To change the configuration, define window.docoffConfig before the Docoff bundle is loaded. Only the options that differ from the default configuration need to be defined:
<script>
window.docoffConfig = {
reactProps: {
evaluateTypes: {
// Do not evaluate the type, only its type arguments
Omit: false,
// Evaluate the type using a custom function
Partial: (tsType) => tsType.elements[0],
// Evaluate a type that is not evaluated by default
ReactNode: () => ({ name: 'node' }),
},
mergeLiteralUnions: false,
},
};
</script>The key in evaluateTypes is the name of the type, the value is one of:
trueto evaluate the type the default way. It only has an effect for the types of the default configuration.falseto present the type as it is written, with its type arguments evaluated, e.g.Omit<{ top: number; bottom?: number }, 'bottom'>.- A function to evaluate the type the custom way. It gets the type as described by react-docgen, with its type arguments (
elements) already evaluated, and it returns the type to present in the same format. When it returns nothing, the type is presented the same way as withfalse.
When a type cannot be evaluated, or the way it is written is of no use to the reader, the type to present can be defined by the @docoffOverrideType tag. The tag can be used in the comment of a type alias, so that it applies to all props of that type, or in the comment of a prop:
/**
* Either one of the predefined values or any valid CSS width.
*
* @docoffOverrideType PredefinedWidth | string
*/
export type Width = PredefinedWidth | (string & NonNullable<unknown>);The tag is followed by a type that is evaluated instead of the original one, so it can refer to other types available in the file. What is not a valid type, e.g. @docoffOverrideType any HTML element, is presented as it is written. The tag is not shown in the description of the prop.
When the site is not deployed at the root of the domain, URLs starting with a slash do not point to the site. The basePath option defines the path the site is deployed at. The following URLs are resolved against it when they start with a slash:
- the CSS file of live previews defined by the
--docoff-preview-csscustom property, - the
srcattribute of thedocoff-react-propselement, - the URLs in its
resolvePackagesandresolveRelativeImportsoptions.
Define window.docoffConfig before the Docoff bundle is loaded:
<script>
window.docoffConfig = {
basePath: '/docs/',
};
</script>Run locally: npm start
Build: npm run build
Test: npm test
The project ships a Dockerized development environment with the devcontainer,
node and playwright services.
Set up (once, on the host): bash ./setup.sh
Run locally: docker compose up — the dev server is served on
COMPOSE_START_PORT (8080 by default).
Open shell (access to npm etc.): docker compose exec devcontainer bash
See docs/dev-environment.md for the full setup and configuration reference, and docs/ai-integration.md for the bundled AI coding assistants and the Chrome-host MCP bridge.