From 2e4b1fcdc0b3fd41928d27cf9ee525a15b02d617 Mon Sep 17 00:00:00 2001 From: Johan Groth Date: Tue, 6 Dec 2022 19:53:57 +0100 Subject: [PATCH] feat(compiler): copy doc block from component to generated types (#3525) The doc block from the component itself is now copied to the generated components.d.ts file. This makes it possible for other tools to get the documentation and use it, e.g. an IDE can display the documentation for a component when it's being used --- src/compiler/types/generate-app-types.ts | 22 +++++--- .../types/generate-component-types.ts | 10 ++-- src/utils/test/util.spec.ts | 51 +++++++++++++++++++ src/utils/util.ts | 50 ++++++++++++++++++ test/end-to-end/src/components.d.ts | 32 ++++++++++++ test/karma/test-app/components.d.ts | 12 +++++ 6 files changed, 168 insertions(+), 9 deletions(-) diff --git a/src/compiler/types/generate-app-types.ts b/src/compiler/types/generate-app-types.ts index f45f86804c1..c243c8ca181 100644 --- a/src/compiler/types/generate-app-types.ts +++ b/src/compiler/types/generate-app-types.ts @@ -1,4 +1,4 @@ -import { normalizePath } from '@utils'; +import { addDocBlock, normalizePath } from '@utils'; import { isAbsolute, relative, resolve } from 'path'; import type * as d from '../../declarations'; @@ -133,7 +133,12 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT c.push(`}`); c.push(`declare namespace LocalJSX {`); - c.push(...modules.map((m) => ` ${m.jsx}`)); + c.push( + ...modules.map((m) => { + const docs = components.find((c) => c.tagName === m.tagName).docs; + return addDocBlock(` ${m.jsx}`, docs, 4); + }) + ); c.push(` interface IntrinsicElements {`); c.push(...modules.map((m) => ` "${m.tagName}": ${m.tagNameAsPascal};`)); @@ -147,10 +152,15 @@ const generateComponentTypesFile = (config: d.Config, buildCtx: d.BuildCtx, areT c.push(` export namespace JSX {`); c.push(` interface IntrinsicElements {`); c.push( - ...modules.map( - (m) => - ` "${m.tagName}": LocalJSX.${m.tagNameAsPascal} & JSXBase.HTMLAttributes<${m.htmlElementName}>;` - ) + ...modules.map((m) => { + const docs = components.find((c) => c.tagName === m.tagName).docs; + + return addDocBlock( + ` "${m.tagName}": LocalJSX.${m.tagNameAsPascal} & JSXBase.HTMLAttributes<${m.htmlElementName}>;`, + docs, + 12 + ); + }) ); c.push(` }`); c.push(` }`); diff --git a/src/compiler/types/generate-component-types.ts b/src/compiler/types/generate-component-types.ts index 1f39ad44913..9e582625dc5 100644 --- a/src/compiler/types/generate-component-types.ts +++ b/src/compiler/types/generate-component-types.ts @@ -1,4 +1,4 @@ -import { dashToPascalCase, sortBy } from '@utils'; +import { addDocBlock, dashToPascalCase, sortBy } from '@utils'; import type * as d from '../../declarations'; import { generateEventTypes } from './generate-event-types'; @@ -34,7 +34,11 @@ export const generateComponentTypes = ( const jsxAttributes = attributesToMultiLineString([...propAttributes, ...eventAttributes], true, areTypesInternal); const element = [ - ` interface ${htmlElementName} extends Components.${tagNameAsPascal}, HTMLStencilElement {`, + addDocBlock( + ` interface ${htmlElementName} extends Components.${tagNameAsPascal}, HTMLStencilElement {`, + cmp.docs, + 4 + ), ` }`, ` var ${htmlElementName}: {`, ` prototype: ${htmlElementName};`, @@ -46,7 +50,7 @@ export const generateComponentTypes = ( tagName, tagNameAsPascal, htmlElementName, - component: ` interface ${tagNameAsPascal} {\n${componentAttributes} }`, + component: addDocBlock(` interface ${tagNameAsPascal} {\n${componentAttributes} }`, cmp.docs, 4), jsx: ` interface ${tagNameAsPascal} {\n${jsxAttributes} }`, element: element.join(`\n`), }; diff --git a/src/utils/test/util.spec.ts b/src/utils/test/util.spec.ts index cdc6fe702c9..6db49ba439a 100644 --- a/src/utils/test/util.spec.ts +++ b/src/utils/test/util.spec.ts @@ -200,4 +200,55 @@ describe('util', () => { }); }); }); + + describe('addDocBlock', () => { + let str: string; + let docs: d.CompilerJsDoc; + + beforeEach(() => { + str = 'interface Foo extends Components.Foo, HTMLStencilElement {'; + docs = { + tags: [{ name: 'deprecated', text: 'only for testing' }], + text: 'Lorem ipsum', + }; + }); + + it('adds a doc block to the string', () => { + expect(util.addDocBlock(str, docs)).toEqual(`/** + * Lorem ipsum + * @deprecated only for testing + */ +interface Foo extends Components.Foo, HTMLStencilElement {`); + }); + + it('indents the doc block correctly', () => { + str = ' ' + str; + expect(util.addDocBlock(str, docs, 4)).toEqual(` /** + * Lorem ipsum + * @deprecated only for testing + */ + interface Foo extends Components.Foo, HTMLStencilElement {`); + }); + + it('excludes the @internal tag', () => { + docs.tags.push({ name: 'internal' }); + expect(util.addDocBlock(str, docs).includes('@internal')).toBeFalsy(); + }); + + it('excludes empty lines', () => { + docs.text = ''; + str = ' ' + str; + expect(util.addDocBlock(str, docs, 4)).toEqual(` /** + * @deprecated only for testing + */ + interface Foo extends Components.Foo, HTMLStencilElement {`); + }); + + it.each([[null], [undefined], [{ tags: [], text: '' }]])( + 'does not add a doc block when docs are empty (%j)', + (docs) => { + expect(util.addDocBlock(str, docs)).toEqual(str); + } + ); + }); }); diff --git a/src/utils/util.ts b/src/utils/util.ts index ba5ae151cb3..69c1b1ec570 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -2,6 +2,8 @@ import type * as d from '../declarations'; import { dashToPascalCase, isString, toDashCase } from './helpers'; import { buildError } from './message-utils'; +const SUPPRESSED_JSDOC_TAGS: string[] = ['internal']; + export const createJsVarName = (fileName: string) => { if (isString(fileName)) { fileName = fileName.split('?')[0]; @@ -72,6 +74,54 @@ ${docs.tags .join('\n')}`.trim(); } +/** + * Adds a doc block to a string + * @param str the string to add a doc block to + * @param docs the compiled JS docs + * @param indentation number of spaces to indent the block with + * @returns the doc block + */ +export function addDocBlock(str: string, docs?: d.CompilerJsDoc, indentation: number = 0): string { + if (!docs) { + return str; + } + + return [formatDocBlock(docs, indentation), str].filter(Boolean).join(`\n`); +} + +/** + * Formats the given compiled docs to a JavaScript doc block + * @param docs the compiled JS docs + * @param indentation number of spaces to indent the block with + * @returns the formatted doc block + */ +function formatDocBlock(docs: d.CompilerJsDoc, indentation: number = 0): string { + const textDocs = getDocBlockLines(docs); + if (!textDocs.filter(Boolean).length) { + return ''; + } + + const spaces = new Array(indentation + 1).join(' '); + + return [spaces + '/**', ...textDocs.map((line) => spaces + ` * ${line}`), spaces + ' */'].join(`\n`); +} + +/** + * Get all lines part of the doc block + * @param docs the compiled JS docs + * @returns list of lines part of the doc block + */ +function getDocBlockLines(docs: d.CompilerJsDoc): string[] { + return [ + ...docs.text.split(lineBreakRegex), + ...docs.tags + .filter((tag) => !SUPPRESSED_JSDOC_TAGS.includes(tag.name)) + .map((tag) => `@${tag.name} ${tag.text || ''}`.split(lineBreakRegex)), + ] + .flat() + .filter(Boolean); +} + /** * Retrieve a project's dependencies from the current build context * @param buildCtx the current build context to query for a specific package diff --git a/test/end-to-end/src/components.d.ts b/test/end-to-end/src/components.d.ts index ff6b2623e32..6852676297b 100644 --- a/test/end-to-end/src/components.d.ts +++ b/test/end-to-end/src/components.d.ts @@ -14,6 +14,11 @@ export namespace Components { interface CarDetail { "car": CarData; } + /** + * Component that helps display a list of cars + * @slot header - The slot for the header content. + * @part car - The shadow part to target to style the car. + */ interface CarList { "cars": CarData[]; "selected": CarData; @@ -47,6 +52,9 @@ export namespace Components { } interface PrerenderCmp { } + /** + * @virtualProp mode - Mode + */ interface PropCmp { "first": string; "lastName": string; @@ -92,6 +100,11 @@ declare global { prototype: HTMLCarDetailElement; new (): HTMLCarDetailElement; }; + /** + * Component that helps display a list of cars + * @slot header - The slot for the header content. + * @part car - The shadow part to target to style the car. + */ interface HTMLCarListElement extends Components.CarList, HTMLStencilElement { } var HTMLCarListElement: { @@ -164,6 +177,9 @@ declare global { prototype: HTMLPrerenderCmpElement; new (): HTMLPrerenderCmpElement; }; + /** + * @virtualProp mode - Mode + */ interface HTMLPropCmpElement extends Components.PropCmp, HTMLStencilElement { } var HTMLPropCmpElement: { @@ -225,6 +241,11 @@ declare namespace LocalJSX { interface CarDetail { "car"?: CarData; } + /** + * Component that helps display a list of cars + * @slot header - The slot for the header content. + * @part car - The shadow part to target to style the car. + */ interface CarList { "cars"?: CarData[]; "onCarSelected"?: (event: CarListCustomEvent) => void; @@ -257,6 +278,9 @@ declare namespace LocalJSX { } interface PrerenderCmp { } + /** + * @virtualProp mode - Mode + */ interface PropCmp { "first"?: string; "lastName"?: string; @@ -304,6 +328,11 @@ declare module "@stencil/core" { "app-root": LocalJSX.AppRoot & JSXBase.HTMLAttributes; "build-data": LocalJSX.BuildData & JSXBase.HTMLAttributes; "car-detail": LocalJSX.CarDetail & JSXBase.HTMLAttributes; + /** + * Component that helps display a list of cars + * @slot header - The slot for the header content. + * @part car - The shadow part to target to style the car. + */ "car-list": LocalJSX.CarList & JSXBase.HTMLAttributes; "dom-api": LocalJSX.DomApi & JSXBase.HTMLAttributes; "dom-interaction": LocalJSX.DomInteraction & JSXBase.HTMLAttributes; @@ -316,6 +345,9 @@ declare module "@stencil/core" { "method-cmp": LocalJSX.MethodCmp & JSXBase.HTMLAttributes; "path-alias-cmp": LocalJSX.PathAliasCmp & JSXBase.HTMLAttributes; "prerender-cmp": LocalJSX.PrerenderCmp & JSXBase.HTMLAttributes; + /** + * @virtualProp mode - Mode + */ "prop-cmp": LocalJSX.PropCmp & JSXBase.HTMLAttributes; "slot-cmp": LocalJSX.SlotCmp & JSXBase.HTMLAttributes; "slot-cmp-container": LocalJSX.SlotCmpContainer & JSXBase.HTMLAttributes; diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index d9b069a775a..0ea66ad381c 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -226,6 +226,9 @@ export namespace Components { } interface ShadowDomBasicRoot { } + /** + * @virtualProp {string} colormode - The mode determines which platform styles to use. + */ interface ShadowDomMode { /** * The mode determines which platform styles to use. @@ -868,6 +871,9 @@ declare global { prototype: HTMLShadowDomBasicRootElement; new (): HTMLShadowDomBasicRootElement; }; + /** + * @virtualProp {string} colormode - The mode determines which platform styles to use. + */ interface HTMLShadowDomModeElement extends Components.ShadowDomMode, HTMLStencilElement { } var HTMLShadowDomModeElement: { @@ -1490,6 +1496,9 @@ declare namespace LocalJSX { } interface ShadowDomBasicRoot { } + /** + * @virtualProp {string} colormode - The mode determines which platform styles to use. + */ interface ShadowDomMode { /** * The mode determines which platform styles to use. @@ -1813,6 +1822,9 @@ declare module "@stencil/core" { "shadow-dom-array-root": LocalJSX.ShadowDomArrayRoot & JSXBase.HTMLAttributes; "shadow-dom-basic": LocalJSX.ShadowDomBasic & JSXBase.HTMLAttributes; "shadow-dom-basic-root": LocalJSX.ShadowDomBasicRoot & JSXBase.HTMLAttributes; + /** + * @virtualProp {string} colormode - The mode determines which platform styles to use. + */ "shadow-dom-mode": LocalJSX.ShadowDomMode & JSXBase.HTMLAttributes; "shadow-dom-mode-root": LocalJSX.ShadowDomModeRoot & JSXBase.HTMLAttributes; "shadow-dom-slot-basic": LocalJSX.ShadowDomSlotBasic & JSXBase.HTMLAttributes;