Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Components: Set up README auto-generator #66035

Merged
merged 20 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
72 changes: 72 additions & 0 deletions bin/api-docs/gen-components-docs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* External dependencies
*/
const docgen = require( 'react-docgen-typescript' );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using the same docgen as the one we use in Storybook, to keep the props data as consistent as possible between Storybook and README.

const glob = require( 'glob' );
const fs = require( 'fs' );
const path = require( 'path' );

/**
* Internal dependencies
*/
const { generateMarkdownDocs } = require( './markdown' );

// For consistency, options should generally match the options used in Storybook.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to read those same options from the storybook config, instead of duplicating them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately they're internal. I just pulled them from the source code.

const OPTIONS = {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
propFilter: ( prop ) =>
prop.parent ? ! /node_modules/.test( prop.parent.fileName ) : true,
savePropValueAsString: true,
};

function getTypeDocsForComponent( {
manifestPath,
componentFilePath,
displayName,
} ) {
const resolvedPath = path.resolve(
path.dirname( manifestPath ),
componentFilePath
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we bail here if the path isn't resolved?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I combined the error handling into a single throw with any other potential react-docgen-typescript error (react-docgen-typescript handles non-existing files gracefully, and we can avoid a call to fs.existsSync() this way).


return docgen
.parse( resolvedPath, OPTIONS )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could docgen.parse() potentially throw, or return an unexpected result?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I know of, but we'll see when we run more components through the system.

.find( ( obj ) => obj.displayName === displayName );
}

const manifests = glob.sync( 'packages/components/src/**/docs-manifest.json' );
mirka marked this conversation as resolved.
Show resolved Hide resolved

manifests.forEach( ( manifestPath ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could be an opportunity to process multiple of these sync tasks in parallel and make this run faster.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted the per-file processing to async 👍

const manifest = JSON.parse( fs.readFileSync( manifestPath, 'utf8' ) );
mirka marked this conversation as resolved.
Show resolved Hide resolved

const typeDocs = getTypeDocsForComponent( {
manifestPath,
componentFilePath: manifest.filePath,
displayName: manifest.displayName,
} );
mirka marked this conversation as resolved.
Show resolved Hide resolved

const subcomponentTypeDocs = manifest.subcomponents?.map(
( subcomponent ) => {
const docs = getTypeDocsForComponent( {
manifestPath,
componentFilePath: subcomponent.filePath,
displayName: subcomponent.displayName,
} );

mirka marked this conversation as resolved.
Show resolved Hide resolved
if ( subcomponent.preferredDisplayName ) {
docs.displayName = subcomponent.preferredDisplayName;
}

return docs;
}
);

const docs = generateMarkdownDocs( { typeDocs, subcomponentTypeDocs } );
const outputFile = path.resolve(
path.dirname( manifestPath ),
'./README.md'
);

fs.writeFileSync( outputFile, docs );
mirka marked this conversation as resolved.
Show resolved Hide resolved
mirka marked this conversation as resolved.
Show resolved Hide resolved
} );
44 changes: 44 additions & 0 deletions bin/api-docs/gen-components-docs/markdown/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* External dependencies
*/
const json2md = require( 'json2md' );

/**
* Internal dependencies
*/
const { generateMarkdownPropsJson } = require( './props' );

function generateMarkdownDocs( { typeDocs, subcomponentTypeDocs } ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any good reason those are passed as an object and not as separate arguments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just that we might want to pass more arguments to this function later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need some checks if typeDocs and subcomponentTypeDocs are defined and valid before using them later?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a bit more safeguards, which I think should be sufficient for now.

const mainDocsJson = [
'<!-- This file is generated automatically and cannot be edited directly. -->\n',
{ h1: typeDocs.displayName },
{
p: `<p class="callout callout-info">See the <a href="https://wordpress.github.io/gutenberg/?path=/docs/components-${ typeDocs.displayName.toLowerCase() }--docs">WordPress Storybook</a> for more detailed, interactive documentation.</p>`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have similar callouts for private, deprecated, and experimental components?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add one for experimental when we encounter it. But probably not for private, since private component readmes shouldn't be public on Block Editor Handbook.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. And what about deprecated? Same strategy as experimental?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Probably a customizable callout, for example:

<div class="callout callout-alert">
This component is deprecated. Consider using `RadioControl` or `ToggleGroupControl` instead.
</div>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Do you think it would make sense to include an example of an experimental component and of a deprecated component in this PR or as a quick follow-up once this PR is merged?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! After merging this first iteration I'm going to continue adding at least around ten more components so I can test and add more features as necessary. Once it feels somewhat stable we can open it up to other contributors.

},
typeDocs.description,
...generateMarkdownPropsJson( typeDocs.props ),
];

const subcomponentDocsJson = subcomponentTypeDocs
? [
{ h2: 'Subcomponents' },
...subcomponentTypeDocs.flatMap( ( subcomponentTypeDoc ) => [
{
h3: subcomponentTypeDoc.displayName,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we convinced subcomponentTypeDoc will be well-formed and will contain all the fields we're using here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know, it should be sufficient for now.

},
subcomponentTypeDoc.description,
...generateMarkdownPropsJson( subcomponentTypeDoc.props, {
headingLevel: 4,
} ),
] ),
]
: [];

return json2md(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json2md is a JS-to-Markdown conversion library.

[ ...mainDocsJson, ...subcomponentDocsJson ].filter( Boolean )
);
}

module.exports = {
generateMarkdownDocs,
};
53 changes: 53 additions & 0 deletions bin/api-docs/gen-components-docs/markdown/props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function renderPropType( type ) {
switch ( type.name ) {
case 'enum': {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any other prop types to handle?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. I'll add more logic as I encounter them.

const MAX_ENUM_VALUES = 10;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this const be declared up there at the module level?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit detached to me at the module level, but I moved it up to the function level.

const string = type.value
.slice( 0, MAX_ENUM_VALUES )
.map( ( { value } ) => value )
.join( ' | ' );

if ( type.value.length > MAX_ENUM_VALUES ) {
return `${ string } | ...`;
}
return string;
}
default:
return type.name;
}
}

function generateMarkdownPropsJson( props, { headingLevel = 2 } = {} ) {
const sortedKeys = Object.keys( props ).sort( ( [ a ], [ b ] ) =>
a.localeCompare( b )
);

const propsJson = sortedKeys
.flatMap( ( key ) => {
const prop = props[ key ];

if ( prop.description.includes( '@ignore' ) ) {
mirka marked this conversation as resolved.
Show resolved Hide resolved
return null;
}

return [
{ [ `h${ headingLevel + 1 }` ]: `\`${ key }\`` },
prop.description,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes the props will always have a description. Should we add a default description here to handle if that's not the case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When undefined, it should be filtered out by the .filter( Boolean ) at the end.

{
ul: [
`Type: \`${ renderPropType( prop.type ) }\``,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For displaying the prop type, I opted to use the - Type: fooType format rather than the ### propName: fooType format, because it is more adaptable in cases where the type is long (like an enum).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds sensible to me, especially as the first iteration.

We could later consider more advanced layouts (like maybe a table layout inspired from Storybook), but that's a story for another day.

`Required: ${ prop.required ? 'Yes' : 'No' }`,
prop.defaultValue &&
`Default: \`${ prop.defaultValue.value }\``,
].filter( Boolean ),
},
];
} )
.filter( Boolean );

return [ { [ `h${ headingLevel }` ]: 'Props' }, ...propsJson ];
}

module.exports = {
generateMarkdownPropsJson,
};
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
"jest-junit": "13.0.0",
"jest-message-util": "29.6.2",
"jest-watch-typeahead": "2.2.2",
"json2md": "2.0.1",
"lerna": "7.1.4",
"lint-staged": "10.0.2",
"make-dir": "3.0.0",
Expand All @@ -235,6 +236,7 @@
"progress": "2.0.3",
"puppeteer-core": "23.1.0",
"react": "18.3.1",
"react-docgen-typescript": "2.2.2",
"react-dom": "18.3.1",
"react-native": "0.73.3",
"react-native-url-polyfill": "1.1.2",
Expand Down Expand Up @@ -282,7 +284,8 @@
"distclean": "git clean --force -d -X",
"docs:api-ref": "node ./bin/api-docs/update-api-docs.js",
"docs:blocks": "node ./bin/api-docs/gen-block-lib-list.js",
"docs:build": "npm-run-all docs:gen docs:blocks docs:api-ref docs:theme-ref",
"docs:build": "npm-run-all docs:components docs:gen docs:blocks docs:api-ref docs:theme-ref",
"docs:components": "node ./bin/api-docs/gen-components-docs",
"docs:gen": "node ./docs/tool/index.js",
"docs:theme-ref": "node ./bin/api-docs/gen-theme-reference.mjs",
"env": "wp-env",
Expand Down
38 changes: 38 additions & 0 deletions packages/components/schemas/docs-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"title": "JSON schema for @wordpress/components README manifests",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"displayName": {
"type": "string",
"description": "The `displayName` of the component, as determined in code."
},
"filePath": {
"type": "string",
"description": "The file path where the component is located."
},
"subcomponents": {
"type": "array",
"description": "List of subcomponents related to the component.",
"items": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"description": "The `displayName` of the subcomponent, as determined in code."
},
"preferredDisplayName": {
"type": "string",
"description": "The display name to use in the README, if it is different from the `displayName` as determined in code."
},
"filePath": {
"type": "string",
"description": "The file path where the subcomponent is located."
}
},
"required": [ "displayName", "filePath" ]
}
}
},
"required": [ "displayName", "filePath" ]
}
Loading
Loading