Skip to content

Commit

Permalink
Components: Set up README auto-generator (#66035)
Browse files Browse the repository at this point in the history
* Add components readme generator

* Move

* AlignmentMatrixControl: Add missing `@deprecated` tag

* Add docs manifest for AlignmentMatrixControl

* Handle case with no subcomponents

* Add JSON schema

* Commit AlignmentMatrixControl readme changes

* Fixup: Handle case with no subcomponents

* Add manifest for AnglePickerControl

* Simplify

* Improve schema descriptions

* Handle docgen errors

* Convert to async

* Move glob further up

* Handle unparseable JSON

* Handle write file progress

* Fixup

* Apply feedback in markdown props handling

* Simplify

* Handle cases when `displayName` in manifest is wrong

Co-authored-by: mirka <[email protected]>
Co-authored-by: ciampo <[email protected]>
Co-authored-by: tyxla <[email protected]>
  • Loading branch information
4 people authored Oct 16, 2024
1 parent 27aed30 commit f205b11
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 60 deletions.
112 changes: 112 additions & 0 deletions bin/api-docs/gen-components-docs/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* External dependencies
*/
import docgen from 'react-docgen-typescript';
import glob from 'glob';
import fs from 'node:fs/promises';
import path from 'path';

/**
* Internal dependencies
*/
import { generateMarkdownDocs } from './markdown/index.mjs';

const MANIFEST_GLOB = 'packages/components/src/**/docs-manifest.json';

// For consistency, options should generally match the options used in Storybook.
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
);

const typeDocs = docgen.parse( resolvedPath, OPTIONS );

if ( typeDocs.length === 0 ) {
throw new Error(
`react-docgen-typescript could not generate any type docs from ${ resolvedPath }`
);
}

const matchingTypeDoc = typeDocs.find(
( obj ) => obj.displayName === displayName
);

if ( typeof matchingTypeDoc === 'undefined' ) {
const unmatchedTypeDocs = typeDocs
.map( ( obj ) => `\`${ obj.displayName }\`` )
.join( ', ' );

throw new Error(
`react-docgen-typescript could not find type docs for ${ displayName } in ${ resolvedPath }. (Found ${ unmatchedTypeDocs })`
);
}

return matchingTypeDoc;
}

async function parseManifest( manifestPath ) {
try {
return JSON.parse( await fs.readFile( manifestPath, 'utf8' ) );
} catch ( e ) {
throw new Error(
`Error parsing docs manifest at ${ manifestPath }: ${ e.message }`
);
}
}

const manifests = glob.sync( MANIFEST_GLOB );

await Promise.all(
manifests.map( async ( manifestPath ) => {
const manifest = await parseManifest( manifestPath );

const typeDocs = getTypeDocsForComponent( {
manifestPath,
componentFilePath: manifest.filePath,
displayName: manifest.displayName,
} );

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

if ( subcomponent.preferredDisplayName ) {
docs.displayName = subcomponent.preferredDisplayName;
}

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

try {
console.log( `Writing docs to ${ outputFile }` );
return fs.writeFile( outputFile, docs );
} catch ( e ) {
throw new Error(
`Error writing docs to ${ outputFile }: ${ e.message }`
);
}
} )
);
40 changes: 40 additions & 0 deletions bin/api-docs/gen-components-docs/markdown/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* External dependencies
*/
import json2md from 'json2md';

/**
* Internal dependencies
*/
import { generateMarkdownPropsJson } from './props.mjs';

export function generateMarkdownDocs( { typeDocs, subcomponentTypeDocs } ) {
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>`,
},
typeDocs.description,
...generateMarkdownPropsJson( typeDocs.props ),
];

const subcomponentDocsJson = subcomponentTypeDocs?.length
? [
{ h2: 'Subcomponents' },
...subcomponentTypeDocs.flatMap( ( subcomponentTypeDoc ) => [
{
h3: subcomponentTypeDoc.displayName,
},
subcomponentTypeDoc.description,
...generateMarkdownPropsJson( subcomponentTypeDoc.props, {
headingLevel: 4,
} ),
] ),
]
: [];

return json2md(
[ ...mainDocsJson, ...subcomponentDocsJson ].filter( Boolean )
);
}
51 changes: 51 additions & 0 deletions bin/api-docs/gen-components-docs/markdown/props.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
function renderPropType( type ) {
const MAX_ENUM_VALUES = 10;

switch ( type.name ) {
case 'enum': {
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;
}
}

export 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' ) ) {
return null;
}

return [
{ [ `h${ headingLevel + 1 }` ]: `\`${ key }\`` },
prop.description,
{
ul: [
`Type: \`${ renderPropType( prop.type ) }\``,
`Required: ${ prop.required ? 'Yes' : 'No' }`,
prop.defaultValue &&
`Default: \`${ prop.defaultValue.value }\``,
].filter( Boolean ),
},
];
} )
.filter( Boolean );

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

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/index.mjs",
"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. Used to identify the component in the specified source file."
},
"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. Used to identify the component in the specified source file."
},
"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

0 comments on commit f205b11

Please sign in to comment.