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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,19 @@ Both attributes are required:

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 reading the type of the props from a single file that has no relative imports, e.g. bundled type declarations (`*.d.ts`) of a library. Such a file contains no component, so `name` must be the type of the props, and the props are presented without default values.
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:

```html
<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:

Expand Down Expand Up @@ -197,6 +209,7 @@ window.docoffConfig = {
mergeLiteralUnions: true,
mergeObjectIntersections: true,
resolvePackages: {},
resolveRelativeImports: undefined,
},
};
```
Expand Down Expand Up @@ -248,7 +261,7 @@ When the site is not deployed at the root of the domain, URLs starting with a sl

* the CSS file of live previews defined by the `--docoff-preview-css` custom property,
* the `src` attribute of the `docoff-react-props` element,
* the URLs in its `resolvePackages` option.
* the URLs in its `resolvePackages` and `resolveRelativeImports` options.

Define `window.docoffConfig` **before** the Docoff bundle is loaded:

Expand Down
4 changes: 3 additions & 1 deletion src/DocoffReactProps/_helpers/getPropsTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export const getPropsTable = async (src, name) => {

try {
const packageUrls = getPackageUrls(document.baseURI, basePath, config.resolvePackages);
const props = await parseProps(await response.text(), url, name, packageUrls);
const relativeImportsUrl = config.resolveRelativeImports
&& resolveUrl(document.baseURI, basePath, config.resolveRelativeImports);
const props = await parseProps(await response.text(), url, name, packageUrls, relativeImportsUrl);
const properties = getTypeProperties(props.types.map((tsType) => evaluateTSType(tsType, config)));

return properties.length > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { parseDefinitions } from './parseDefinitions';
* @param {string} url The absolute URL of the file
* @param {string} name The name of the component
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @param {Map} modules The files that have already been downloaded by their URL, filled in with the imported files
* @returns {Promise<Object|null>} The component as described by `react-docgen`, `null` when the file has no
* component of that name
*/
export const findComponent = async (source, url, name, resolvePackages, modules) => {
const definitions = await parseDefinitions(source, url, resolvePackages, modules);
export const findComponent = async (source, url, name, resolvePackages, relativeImportUrls, modules) => {
const definitions = await parseDefinitions(source, url, resolvePackages, relativeImportUrls, modules);

return definitions.find((definition) => definition.displayName === name) ?? null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ const TYPE_NAME = 'DocoffReactPropsTypeProps';
* @param {string} url The absolute URL of the file
* @param {string} name The name of the type exported from the file, or `default`
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @param {Map} modules The files that have already been downloaded by their URL, including the file itself
* @returns {Promise<Object>} The made-up component as described by `react-docgen`
*/
export const findType = async (url, name, resolvePackages, modules) => {
export const findType = async (url, name, resolvePackages, relativeImportUrls, modules) => {
const typeComponentSource = `
import type { ${name} as ${TYPE_NAME} } from './${url.split('/').pop()}';

Expand All @@ -33,6 +35,7 @@ export const findType = async (url, name, resolvePackages, modules) => {
typeComponentSource,
new URL(TYPE_COMPONENT_FILE_NAME, url).href,
resolvePackages,
relativeImportUrls,
downloadedModules,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { RESOLVE_EXTENSIONS } from '../../../constants';
* @param {string} source What is imported, e.g. `./Button.types` or `@scope/package/src/components/Button`
* @param {string} filename The URL of the file with the import
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @returns {string|null} The URL of the import, typically without file extension. `null` when the import
* is not to be resolved.
*/
export const getModuleUrl = (source, filename, resolvePackages) => {
export const getModuleUrl = (source, filename, resolvePackages, relativeImportUrls = {}) => {
if (source.startsWith('.')) {
return new URL(source, filename).href;
// A file contains everything the relative imports point to, so it is used regardless of the imported path
return relativeImportUrls[filename] ?? new URL(source, filename).href;
}

const packageName = Object.keys(resolvePackages)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ describe('functionality', () => {
expect(getModuleUrl('../../types', FILENAME, {})).toEqual('https://example.com/types');
});

it('looks up relative import in the file given for the file with the import', () => {
expect(getModuleUrl('../../types', FILENAME, {}, { [FILENAME]: 'https://example.com/library.d.ts' }))
.toEqual('https://example.com/library.d.ts');
});

it('resolves relative import of another file against it', () => {
expect(getModuleUrl('./Button', 'https://example.com/components/Button/index.ts', {}, {
[FILENAME]: 'https://example.com/library.d.ts',
})).toEqual('https://example.com/components/Button/Button');
});

it('does not resolve import from package that is not listed', () => {
expect(getModuleUrl('react', FILENAME, { '@scope/package': 'https://cdn.test/package' })).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ import { setUniqueTypeName } from './_helpers/setUniqueTypeName';
* @param {Map} modules The downloaded files by URL of the import, `null` if the file could not be downloaded
* @param {Set} missingModuleUrls The URLs of the imports that have not been downloaded yet, filled in by the importer
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @returns {Function} The importer
*/
export const createUrlImporter = (modules, missingModuleUrls, resolvePackages) => {
export const createUrlImporter = (modules, missingModuleUrls, resolvePackages, relativeImportUrls) => {
let importedTypesCount = 0;

const resolveImportedValue = (path, name, file, seen = new Set()) => {
const source = path.node.source?.value;
const { filename } = file.opts;

const moduleUrl = source && filename ? getModuleUrl(source, filename, resolvePackages) : null;
// When the file to look up relative imports in cannot be downloaded, the imported files are downloaded instead
const availableRelativeImportUrls = modules.get(relativeImportUrls[filename]) === null ? {} : relativeImportUrls;
const moduleUrl = source && filename
? getModuleUrl(source, filename, resolvePackages, availableRelativeImportUrls)
: null;
if (moduleUrl === null) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ import { restoreTypeNames } from './_helpers/restoreTypeNames';
* @param {string} url The absolute URL of the source file
* @param {Object} options The options of `react-docgen`
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @param {Map} modules The files that have already been downloaded by their URL
* @returns {Promise<Object[]>} The components found in the source code as described by `react-docgen`
*/
export const parseWithImports = async (source, url, options, resolvePackages, modules) => {
export const parseWithImports = async (source, url, options, resolvePackages, relativeImportUrls, modules) => {
const missingModuleUrls = new Set();
const optionsWithImporter = {
...options,
filename: url,
importer: createUrlImporter(modules, missingModuleUrls, resolvePackages),
importer: createUrlImporter(modules, missingModuleUrls, resolvePackages, relativeImportUrls),
};

const parse = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { propsTypeHandler } from './_helpers/propsTypeHandler';
* @param {string} source The source code
* @param {string} url The absolute URL of the source file
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {Object} relativeImportUrls The URLs of the files to look up relative imports in, by the URLs of the files
* with the imports
* @param {Map} modules The files that have already been downloaded by their URL
* @returns {Promise<Object[]>} The components as described by `react-docgen`, each with `propsTypes`
*/
export const parseDefinitions = async (source, url, resolvePackages, modules = new Map()) => {
export const parseDefinitions = async (source, url, resolvePackages, relativeImportUrls, modules = new Map()) => {
const options = {
babelOptions: {
// The source code is parsed on its own, Babel config files must never be looked up
Expand All @@ -28,7 +30,7 @@ export const parseDefinitions = async (source, url, resolvePackages, modules = n
};

try {
return await parseWithImports(source, url, options, resolvePackages, modules);
return await parseWithImports(source, url, options, resolvePackages, relativeImportUrls, modules);
} catch (error) {
// A file with no component, e.g. a file with types only, is not an error
if (error.code === docgen.ERROR_CODES.MISSING_DEFINITION) {
Expand Down
10 changes: 7 additions & 3 deletions src/DocoffReactProps/_helpers/parseProps/parseProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { normalizeType } from './_helpers/normalizeType';
* @param {string} url The absolute URL of the file
* @param {string} name The name of the component or of the type
* @param {Object} resolvePackages The URLs of the packages to resolve by the names of the packages
* @param {string|null} resolveRelativeImports The URL of the file to look up the relative imports of the file in
* @returns {Promise<{ defaultValues: Object, types: Object[] }>} The default values of the props by their names,
* and the types that define the props as described by `react-docgen`
*/
export const parseProps = async (source, url, name, resolvePackages = {}) => {
export const parseProps = async (source, url, name, resolvePackages = {}, resolveRelativeImports = null) => {
// Only the imports of the file itself are looked up there. The made-up component that presents a type exported
// from the file must import the file itself.
const relativeImportUrls = resolveRelativeImports ? { [url]: resolveRelativeImports } : {};
// Files are downloaded once, even when both a component and a type are looked for
const modules = new Map([[
url,
Expand All @@ -22,8 +26,8 @@ export const parseProps = async (source, url, name, resolvePackages = {}) => {
url,
},
]]);
const definition = await findComponent(source, url, name, resolvePackages, modules)
?? await findType(url, name, resolvePackages, modules);
const definition = await findComponent(source, url, name, resolvePackages, relativeImportUrls, modules)
?? await findType(url, name, resolvePackages, relativeImportUrls, modules);

return {
defaultValues: getDefaultValues(definition),
Expand Down
72 changes: 70 additions & 2 deletions src/DocoffReactProps/_helpers/parseProps/parseProps.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ const files = {
<h1 id={id}>{level}{icon?.name}</h1>
);
`,
'http://localhost/components/Switch/Switch.tsx': `
import React from 'react';
import type { ToggleProps } from '../Toggle';

export const Switch = ({ disabled = false, label }: ToggleProps) => <label>{label}</label>;
`,
'http://localhost/components/Switch/Switch.types.ts': `
import type { ToggleLabelPosition } from '../Toggle';

export type SwitchProps = {
/**
* Side of the label.
*/
labelPosition?: ToggleLabelPosition;
};
`,
'http://localhost/components/Toggle/Toggle.types.ts': `
import type { ToggleProps as LibraryToggleProps } from '@library/ui/src/components/Toggle';

Expand Down Expand Up @@ -309,8 +325,8 @@ const FORM_LAYOUT_TYPES_URL = 'http://localhost/components/FormLayout/FormLayout
const TOGGLE_TYPES_URL = 'http://localhost/components/Toggle/Toggle.types.ts';

// Presents the props the way they are presented in the table
const getProps = async (url, name, resolvePackages) => {
const props = await parseProps(files[url], url, name, resolvePackages);
const getProps = async (url, name, resolvePackages, resolveRelativeImports) => {
const props = await parseProps(files[url], url, name, resolvePackages, resolveRelativeImports);
const properties = getTypeProperties(props.types.map((tsType) => evaluateTSType(tsType)));

return Object.fromEntries(properties.map((property) => [
Expand Down Expand Up @@ -488,6 +504,58 @@ describe('functionality', () => {
expect(global.fetch).not.toHaveBeenCalled();
});

it('looks up relative imports of the component in the given file instead of downloading them', async () => {
expect(await getProps(
'http://localhost/components/Switch/Switch.tsx',
'Switch',
{},
'https://cdn.test/library.d.ts',
)).toEqual({
disabled: {
defaultValue: 'false',
description: 'If `true`, the input will be disabled.',
required: false,
type: 'boolean',
},
label: {
defaultValue: undefined,
description: 'Label of the input.',
required: true,
type: 'ReactNode',
},
labelPosition: {
defaultValue: undefined,
description: 'Placement of the label.',
required: false,
type: '\'before\' | \'after\'',
},
});
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual(['https://cdn.test/library.d.ts']);
});

it('downloads relative imports when the given file cannot be downloaded', async () => {
expect(Object.keys(await getProps(BUTTON_URL, 'Button', {}, 'https://cdn.test/missing.d.ts')))
.toEqual(['color', 'icon', 'label', 'size']);
expect(getFetchedUrls()).toContain(BUTTON_TYPES_URL);
});

it('looks up relative imports of the file with the type in the given file', async () => {
expect(await getProps(
'http://localhost/components/Switch/Switch.types.ts',
'SwitchProps',
{},
'https://cdn.test/library.d.ts',
)).toEqual({
labelPosition: {
defaultValue: undefined,
description: 'Side of the label.',
required: false,
type: '\'before\' | \'after\'',
},
});
expect(global.fetch.mock.calls.map(([url]) => url)).toEqual(['https://cdn.test/library.d.ts']);
});

it('resolves types that `react-docgen` does not recognize', async () => {
const props = await getProps(FORM_LAYOUT_TYPES_URL, 'FormLayoutProps');

Expand Down
Loading