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

feat(docs-readme): add overview to readme #3635

Merged
merged 7 commits into from
Oct 5, 2022
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
5 changes: 5 additions & 0 deletions src/compiler/build/build-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ export class BuildContext implements d.BuildCtx {
}
}

/**
* Generate a timestamp of the format `YYYY-MM-DDThh:mm:ss`, using the number of seconds that have elapsed since
* January 01, 1970, and the time this function was called
* @returns the generated timestamp
*/
export const getBuildTimestamp = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

am I missing something, or does this produce the same output as (new Date()).toISOString().split(".")[0] ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I think it does (create the same string)

Copy link
Contributor

Choose a reason for hiding this comment

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

🤷‍♀️ seems to work so...idk! let's keep it

const d = new Date();

Expand Down
87 changes: 79 additions & 8 deletions src/compiler/docs/generate-doc-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { typescriptVersion, version } from '../../version';
import { getBuildTimestamp } from '../build/build-ctx';
import { AUTO_GENERATE_COMMENT } from './constants';

/**
* Generate metadata that will be used to generate any given documentation-related output target(s)
* @param config the configuration associated with the current Stencil task run
* @param compilerCtx the current compiler context
* @param buildCtx the build context for the current Stencil task run
* @returns the generated metadata
*/
export const generateDocData = async (
config: d.ValidatedConfig,
compilerCtx: d.CompilerCtx,
Expand All @@ -23,6 +30,13 @@ export const generateDocData = async (
};
};

/**
* Derive the metadata for each Stencil component
* @param config the configuration associated with the current Stencil task run
* @param compilerCtx the current compiler context
* @param buildCtx the build context for the current Stencil task run
* @returns the derived metadata
*/
const getDocsComponents = async (
config: d.ValidatedConfig,
compilerCtx: d.CompilerCtx,
Expand All @@ -37,8 +51,8 @@ const getDocsComponents = async (
const readme = await getUserReadmeContent(compilerCtx, readmePath);
const usage = await generateUsages(compilerCtx, usagesDir);
return moduleFile.cmps
.filter((cmp) => !cmp.internal && !cmp.isCollectionDependency)
.map((cmp) => ({
.filter((cmp: d.ComponentCompilerMeta) => !cmp.internal && !cmp.isCollectionDependency)
.map((cmp: d.ComponentCompilerMeta) => ({
dirPath,
filePath: relative(config.rootDir, filePath),
fileName: basename(filePath),
Expand Down Expand Up @@ -69,9 +83,12 @@ const getDocsComponents = async (
return sortBy(flatOne(results), (cmp) => cmp.tag);
};

const buildDocsDepGraph = (cmp: d.ComponentCompilerMeta, cmps: d.ComponentCompilerMeta[]) => {
const buildDocsDepGraph = (
cmp: d.ComponentCompilerMeta,
cmps: d.ComponentCompilerMeta[]
): d.JsonDocsDependencyGraph => {
const dependencies: d.JsonDocsDependencyGraph = {};
function walk(tagName: string) {
function walk(tagName: string): void {
if (!dependencies[tagName]) {
const cmp = cmps.find((c) => c.tagName === tagName);
const deps = cmp.directDependencies;
Expand All @@ -94,6 +111,11 @@ const buildDocsDepGraph = (cmp: d.ComponentCompilerMeta, cmps: d.ComponentCompil
return dependencies;
};

/**
* Determines the encapsulation string to use, based on the provided compiler metadata
* @param cmp the metadata for a single component
* @returns the encapsulation level, expressed as a string
*/
const getDocsEncapsulation = (cmp: d.ComponentCompilerMeta): 'shadow' | 'scoped' | 'none' => {
if (cmp.encapsulation === 'shadow') {
return 'shadow';
Expand Down Expand Up @@ -251,7 +273,13 @@ const getDocsListeners = (listeners: d.ComponentCompilerListener[]): d.JsonDocsL
}));
};

const getDocsDeprecationText = (tags: d.JsonDocsTag[]) => {
/**
* Get the text associated with a `@deprecated` tag, if one exists
* @param tags the tags associated with a JSDoc block on a node in the AST
* @returns the text associated with the first found `@deprecated` tag. If a `@deprecated` tag exists but does not
* have associated text, an empty string is returned. If no such tag is found, return `undefined`
*/
const getDocsDeprecationText = (tags: d.JsonDocsTag[]): string | undefined => {
const deprecation = tags.find((t) => t.name === 'deprecated');
if (deprecation) {
return deprecation.text || '';
Expand Down Expand Up @@ -284,9 +312,20 @@ export const getNameText = (name: string, tags: d.JsonDocsTag[]) => {
});
};

const getUserReadmeContent = async (compilerCtx: d.CompilerCtx, readmePath: string) => {
/**
* Attempts to read a pre-existing README.md file from disk, returning any content generated by the user.
*
* For simplicity's sake, it is assumed that all user-generated content will fall before {@link AUTO_GENERATE_COMMENT}
*
* @param compilerCtx the current compiler context
* @param readmePath the path to the README file to read
* @returns the user generated content that occurs before {@link AUTO_GENERATE_COMMENT}. If no user generated content
* exists, or if there was an issue reading the file, return `undefined`
*/
const getUserReadmeContent = async (compilerCtx: d.CompilerCtx, readmePath: string): Promise<string | undefined> => {
try {
const existingContent = await compilerCtx.fs.readFile(readmePath);
// subtract one to get everything up to, but not including the auto generated comment
const userContentIndex = existingContent.indexOf(AUTO_GENERATE_COMMENT) - 1;
if (userContentIndex >= 0) {
return existingContent.substring(0, userContentIndex);
Expand All @@ -295,31 +334,63 @@ const getUserReadmeContent = async (compilerCtx: d.CompilerCtx, readmePath: stri
return undefined;
};

const generateDocs = (readme: string, jsdoc: d.CompilerJsDoc) => {
/**
* Generate documentation for a given component based on the provided JSDoc and README contents
* @param readme the contents of a component's README file, without any autogenerated contents
* @param jsdoc the JSDoc associated with the component's declaration
* @returns the generated documentation
*/
const generateDocs = (readme: string, jsdoc: d.CompilerJsDoc): string => {
const docs = jsdoc.text;
if (docs !== '' || !readme) {
// just return the existing docs if they exist. these would have been captured earlier in the compilation process.
// if they don't exist, and there's no README to process, return an empty string.
return docs;
}

/**
* Parse the README, storing the first section of content.
* Content is defined as the area between two non-consecutive lines that start with a '#':
* ```
* # Header 1
* This is some content
* # Header 2
* This is more content
* # Header 3
* Again, content
* ```
* In the example above, this chunk of code is designed to capture "This is some content"
*/
let isContent = false;
const lines = readme.split('\n');
const contentLines = [];
for (const line of lines) {
const isHeader = line.startsWith('#');
if (isHeader && isContent) {
// we were actively parsing content, but found a new header, break out
break;
}
if (!isHeader && !isContent) {
// we've found content for the first time, set this sentinel to `true`
isContent = true;
}
if (isContent) {
// we're actively parsing the first found block of content, add it to our list for later
contentLines.push(line);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nice, state machine parser! kind of neat to find

}
return contentLines.join('\n').trim();
};

const generateUsages = async (compilerCtx: d.CompilerCtx, usagesDir: string) => {
/**
* This function is responsible for reading the contents of all markdown files in a provided `usage` directory and
* returning their contents
* @param compilerCtx the current compiler context
* @param usagesDir the directory to read usage markdown files from
* @returns an object that maps the filename containing the usage example, to the file's contents. If an error occurs,
* an empty object is returned.
*/
const generateUsages = async (compilerCtx: d.CompilerCtx, usagesDir: string): Promise<d.JsonDocsUsage> => {
const rtn: d.JsonDocsUsage = {};

try {
Expand Down
18 changes: 18 additions & 0 deletions src/compiler/docs/readme/markdown-overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Generate an 'Overview' section for a markdown file
* @param overview a component-level comment string to place in a markdown file
* @returns The generated Overview section. If the provided overview is empty, return an empty list
*/
export const overviewToMarkdown = (overview: string): ReadonlyArray<string> => {
if (!overview) {
return [];
}

const content: string[] = [];
content.push(`## Overview`);
content.push('');
content.push(`${overview.trim()}`);
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 any escaping / illegal character type stuff we should be concerned about here? I know that a lot of Markdown syntax is supported in a lot of JSDoc ecosystem stuff, but not sure if there are some edge cases we need to be aware of

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah missed this the first time around. Perhaps? Although I don't think there's any sanitization done for any MD files yet. It's probably best served as a separate task, but I think the risk some someone doing something super funky is pretty low here

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah agreed, and it would just mainly be ending up in their own markdown file so 🤷‍♀️ I think we don't need to worry about it

content.push('');

return content;
};
2 changes: 2 additions & 0 deletions src/compiler/docs/readme/output-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { stylesToMarkdown } from './markdown-css-props';
import { depsToMarkdown } from './markdown-dependencies';
import { eventsToMarkdown } from './markdown-events';
import { methodsToMarkdown } from './markdown-methods';
import { overviewToMarkdown } from './markdown-overview';
import { partsToMarkdown } from './markdown-parts';
import { propsToMarkdown } from './markdown-props';
import { slotsToMarkdown } from './markdown-slots';
Expand Down Expand Up @@ -54,6 +55,7 @@ export const generateMarkdown = (
'',
'',
...getDocsDeprecation(cmp),
...overviewToMarkdown(cmp.docs),
...usageToMarkdown(cmp.usage),
...propsToMarkdown(cmp.props),
...eventsToMarkdown(cmp.events),
Expand Down
50 changes: 50 additions & 0 deletions src/compiler/docs/test/markdown-overview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { overviewToMarkdown } from '../readme/markdown-overview';

describe('markdown-overview', () => {
describe('overviewToMarkdown', () => {
it('returns no overview if no docs exist', () => {
const generatedOverview = overviewToMarkdown('').join('\n');

expect(generatedOverview).toBe('');
});

it('generates a single line overview', () => {
const generatedOverview = overviewToMarkdown('This is a custom button component').join('\n');

expect(generatedOverview).toBe(`## Overview

This is a custom button component
`);
});

it('generates a multi-line overview', () => {
const description = `This is a custom button component.
It is to be used throughout the design system.

This is a comment followed by a newline.
`;
const generatedOverview = overviewToMarkdown(description).join('\n');

expect(generatedOverview).toBe(`## Overview

This is a custom button component.
It is to be used throughout the design system.

This is a comment followed by a newline.
`);
});

it('trims all leading newlines & leaves one at the end', () => {
const description = `
This is a custom button component.

`;
const generatedOverview = overviewToMarkdown(description).join('\n');

expect(generatedOverview).toBe(`## Overview

This is a custom button component.
`);
});
});
});
12 changes: 11 additions & 1 deletion src/compiler/output-targets/output-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@ import {
isOutputTargetDocsVscode,
} from './output-utils';

export const outputDocs = async (config: d.ValidatedConfig, compilerCtx: d.CompilerCtx, buildCtx: d.BuildCtx) => {
/**
* Generate documentation-related output targets
* @param config the configuration associated with the current Stencil task run
* @param compilerCtx the current compiler context
* @param buildCtx the build context for the current Stencil task run
*/
export const outputDocs = async (
config: d.ValidatedConfig,
compilerCtx: d.CompilerCtx,
buildCtx: d.BuildCtx
): Promise<void> => {
if (!config.buildDocs) {
return;
}
Expand Down
18 changes: 18 additions & 0 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,13 +935,31 @@ export interface ComponentCompilerState {
name: string;
}

/**
* Representation of JSDoc that is pulled off a node in the AST
*/
export interface CompilerJsDoc {
/**
* The text associated with the JSDoc
*/
text: string;
/**
* Tags included in the JSDoc
*/
tags: CompilerJsDocTagInfo[];
}

/**
* Representation of a tag that exists in a JSDoc
*/
export interface CompilerJsDocTagInfo {
/**
* The name of the tag - e.g. `@deprecated`
*/
name: string;
/**
* Additional text that is associated with the tag - e.g. `@deprecated use v2 of this API`
*/
text?: string;
}

Expand Down
20 changes: 20 additions & 0 deletions src/declarations/stencil-public-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ export interface JsonDocsValue {
type: string;
}

/**
* A mapping of file names to their contents.
*
* This type is meant to be used when reading one or more usage markdown files associated with a component. For the
* given directory structure:
* ```
* src/components/my-component
* ├── my-component.tsx
* └── usage
* ├── bar.md
* └── foo.md
* ```
* an instance of this type would include the name of the markdown file, mapped to its contents:
* ```ts
* {
* 'bar': STRING_CONTENTS_OF_BAR.MD
* 'foo': STRING_CONTENTS_OF_FOO.MD
* }
* ```
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

comments like these 🙌

export interface JsonDocsUsage {
[key: string]: string;
}
Expand Down
4 changes: 4 additions & 0 deletions test/end-to-end/src/car-list/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<!-- Auto Generated Below -->


## Overview

Component that helps display a list of cars

## Properties

| Property | Attribute | Description | Type | Default |
Expand Down