diff --git a/CHANGELOG.md b/CHANGELOG.md index 683c073be..22db22da6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +### Features + +- Improved support for `--entryPointStrategy Packages`. TypeDoc will now load package-specific configurations from `package.json` `typedoc` field. This configuration allows configuring a custom display name (`typedoc.displayName`) field, entry point (`typedoc.entryPoint` - this is equivalent and will override `typedocMain`), and path to a readme file to be rendered at the top of the package page (`typedoc.readmeFile`), #1658. +- The `--includeVersion` option will now be respected by `--entryPointStrategy Packages`. Also, for this combination, missing `version` field in the root `package.json` will not issue a warning. + ## v0.23.5 (2022-07-02) ### Features diff --git a/src/lib/converter/converter.ts b/src/lib/converter/converter.ts index 132134ba4..f94444d18 100644 --- a/src/lib/converter/converter.ts +++ b/src/lib/converter/converter.ts @@ -5,7 +5,7 @@ import { ProjectReflection, ReflectionKind, SomeType } from "../models/index"; import { Context } from "./context"; import { ConverterComponent } from "./components"; import { Component, ChildableComponent } from "../utils/component"; -import { BindOption } from "../utils"; +import { BindOption, MinimalSourceFile, readFile } from "../utils"; import { convertType } from "./types"; import { ConverterEvents } from "./converter-events"; import { convertSymbol } from "./symbols"; @@ -15,6 +15,8 @@ import { hasAllFlags, hasAnyFlag } from "../utils/enum"; import type { DocumentationEntryPoint } from "../utils/entry-point"; import { CommentParserConfig, getComment } from "./comments"; import type { CommentStyle } from "../utils/options/declaration"; +import { parseComment } from "./comments/parser"; +import { lexCommentString } from "./comments/rawLexer"; /** * Compiles source files using TypeScript and converts compiler symbols to reflections. @@ -201,9 +203,8 @@ export class Converter extends ChildableComponent< context.setActiveProgram(e.entryPoint.program); e.context = this.convertExports( context, - e.entryPoint.sourceFile, - entries.length === 1, - e.entryPoint.displayName + e.entryPoint, + entries.length === 1 ); }); for (const { entryPoint, context } of entries) { @@ -218,10 +219,11 @@ export class Converter extends ChildableComponent< private convertExports( context: Context, - node: ts.SourceFile, - singleEntryPoint: boolean, - entryName: string + entryPoint: DocumentationEntryPoint, + singleEntryPoint: boolean ) { + const node = entryPoint.sourceFile; + const entryName = entryPoint.displayName; const symbol = getSymbolForModuleLike(context, node); let moduleContext: Context; @@ -259,6 +261,33 @@ export class Converter extends ChildableComponent< void 0, entryName ); + + if (entryPoint.readmeFile) { + const readme = readFile(entryPoint.readmeFile); + const comment = parseComment( + lexCommentString(readme), + context.converter.config, + new MinimalSourceFile(readme, entryPoint.readmeFile), + context.logger + ); + + if (comment.blockTags.length || comment.modifierTags.size) { + const ignored = [ + ...comment.blockTags.map((tag) => tag.tag), + ...comment.modifierTags, + ]; + context.logger.warn( + `Block and modifier tags will be ignored within the readme:\n\t${ignored.join( + "\n\t" + )}` + ); + } + + reflection.readme = comment.summary; + } + + reflection.version = entryPoint.version; + context.finalizeDeclarationReflection(reflection); moduleContext = context.withScope(reflection); } diff --git a/src/lib/converter/plugins/LinkResolverPlugin.ts b/src/lib/converter/plugins/LinkResolverPlugin.ts index 78be33191..714f2f1f3 100644 --- a/src/lib/converter/plugins/LinkResolverPlugin.ts +++ b/src/lib/converter/plugins/LinkResolverPlugin.ts @@ -10,6 +10,7 @@ import type { CommentDisplayPart, InlineTagDisplayPart, } from "../../models/comments"; +import { DeclarationReflection } from "../../models"; const urlPrefix = /^(http|ftp)s?:\/\//; const brackets = /\[\[([^\]]+)\]\]/g; @@ -136,6 +137,14 @@ export class LinkResolverPlugin extends ConverterComponent { for (const tag of comment.blockTags) { tag.content = this.processParts(reflection, tag.content, warn); } + + if (reflection instanceof DeclarationReflection && reflection.readme) { + reflection.readme = this.processParts( + reflection, + reflection.readme, + warn + ); + } } private processParts( diff --git a/src/lib/converter/plugins/PackagePlugin.ts b/src/lib/converter/plugins/PackagePlugin.ts index 29309d95d..f48b43a87 100644 --- a/src/lib/converter/plugins/PackagePlugin.ts +++ b/src/lib/converter/plugins/PackagePlugin.ts @@ -4,7 +4,7 @@ import * as FS from "fs"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; -import { BindOption, readFile } from "../../utils"; +import { BindOption, EntryPointStrategy, readFile } from "../../utils"; import { getCommonDirectory } from "../../utils/fs"; import { nicePath } from "../../utils/paths"; import { lexCommentString } from "../comments/rawLexer"; @@ -23,6 +23,9 @@ export class PackagePlugin extends ConverterComponent { @BindOption("includeVersion") includeVersion!: boolean; + @BindOption("entryPointStrategy") + entryPointStrategy!: EntryPointStrategy; + /** * The file name of the found readme.md file. */ @@ -140,9 +143,15 @@ export class PackagePlugin extends ConverterComponent { if (packageInfo.version) { project.name = `${project.name} - v${packageInfo.version}`; } else { - context.logger.warn( - "--includeVersion was specified, but package.json does not specify a version." - ); + // since not all monorepo specifies a meaningful version to the main package.json + // this warning should be optional + if ( + this.entryPointStrategy !== EntryPointStrategy.Packages + ) { + context.logger.warn( + "--includeVersion was specified, but package.json does not specify a version." + ); + } } } } else { @@ -153,9 +162,13 @@ export class PackagePlugin extends ConverterComponent { project.name = "Documentation"; } if (this.includeVersion) { - context.logger.warn( - "--includeVersion was specified, but no package.json was found. Not adding package version to the documentation." - ); + // since not all monorepo specifies a meaningful version to the main package.json + // this warning should be optional + if (this.entryPointStrategy !== EntryPointStrategy.Packages) { + context.logger.warn( + "--includeVersion was specified, but no package.json was found. Not adding package version to the documentation." + ); + } } } } diff --git a/src/lib/models/reflections/declaration.ts b/src/lib/models/reflections/declaration.ts index 540918a5b..9c0db2b0f 100644 --- a/src/lib/models/reflections/declaration.ts +++ b/src/lib/models/reflections/declaration.ts @@ -5,6 +5,7 @@ import { ContainerReflection } from "./container"; import type { SignatureReflection } from "./signature"; import type { TypeParameterReflection } from "./type-parameter"; import type { Serializer, JSONOutput } from "../../serialization"; +import type { CommentDisplayPart } from "../comments"; /** * Stores hierarchical type data. @@ -129,6 +130,16 @@ export class DeclarationReflection extends ContainerReflection { */ typeHierarchy?: DeclarationHierarchy; + /** + * The contents of the readme file of the module when found. + */ + readme?: CommentDisplayPart[]; + + /** + * The version of the module when found. + */ + version?: string; + override hasGetterOrSetter(): boolean { return !!this.getSignature || !!this.setSignature; } diff --git a/src/lib/output/themes/default/partials/header.tsx b/src/lib/output/themes/default/partials/header.tsx index 29e9d13f9..d8d4e5a3e 100644 --- a/src/lib/output/themes/default/partials/header.tsx +++ b/src/lib/output/themes/default/partials/header.tsx @@ -2,7 +2,7 @@ import { hasTypeParameters, join, renderFlags } from "../../lib"; import { JSX } from "../../../../utils"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; import type { PageEvent } from "../../../events"; -import type { Reflection } from "../../../../models"; +import { DeclarationReflection, Reflection } from "../../../../models"; export const header = (context: DefaultThemeRenderContext, props: PageEvent) => { const HeadingLevel = props.model.isProject() ? "h2" : "h1"; @@ -12,6 +12,9 @@ export const header = (context: DefaultThemeRenderContext, props: PageEvent {props.model.kindString !== "Project" && `${props.model.kindString ?? ""} `} {props.model.name} + {props.model instanceof DeclarationReflection && + props.model.version !== undefined && + ` - v${props.model.version}`} {hasTypeParameters(props.model) && ( <> {"<"} diff --git a/src/lib/output/themes/default/partials/index.tsx b/src/lib/output/themes/default/partials/index.tsx index b0e895c99..ff0e46945 100644 --- a/src/lib/output/themes/default/partials/index.tsx +++ b/src/lib/output/themes/default/partials/index.tsx @@ -1,7 +1,7 @@ -import { classNames, wbr } from "../../lib"; +import { classNames, displayPartsToMarkdown, wbr } from "../../lib"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -import { JSX } from "../../../../utils"; -import type { ContainerReflection, ReflectionCategory } from "../../../../models"; +import { JSX, Raw } from "../../../../utils"; +import { ContainerReflection, DeclarationReflection, ReflectionCategory, ReflectionKind } from "../../../../models"; function renderCategory({ urlTo, icons }: DefaultThemeRenderContext, item: ReflectionCategory, prependName = "") { return ( @@ -67,8 +67,19 @@ export function index(context: DefaultThemeRenderContext, props: ContainerReflec } return ( -
-
{content}
-
+ <> + {props instanceof DeclarationReflection && + props.kind === ReflectionKind.Module && + props.readme?.length !== 0 && ( +
+
+ +
+
+ )} +
+
{content}
+
+ ); } diff --git a/src/lib/output/themes/default/partials/navigation.tsx b/src/lib/output/themes/default/partials/navigation.tsx index 7e7dcd477..bdf216b40 100644 --- a/src/lib/output/themes/default/partials/navigation.tsx +++ b/src/lib/output/themes/default/partials/navigation.tsx @@ -121,7 +121,9 @@ function primaryNavigation(context: DefaultThemeRenderContext, props: PageEvent< return (
  • - {wbr(mod.name)} + + {wbr(`${mod.name}${mod.version !== undefined ? ` - v${mod.version}` : ""}`)} + {childNav}
  • ); diff --git a/src/lib/utils/entry-point.ts b/src/lib/utils/entry-point.ts index b5b9e977a..3df6c28a2 100644 --- a/src/lib/utils/entry-point.ts +++ b/src/lib/utils/entry-point.ts @@ -1,13 +1,15 @@ import { join, relative, resolve } from "path"; import * as ts from "typescript"; import * as FS from "fs"; +import * as Path from "path"; import { expandPackages, + extractTypedocConfigFromPackageManifest, getTsEntryPointForPackage, ignorePackage, loadPackageManifest, } from "./package-manifest"; -import { createMinimatch, matchesAny } from "./paths"; +import { createMinimatch, matchesAny, nicePath } from "./paths"; import type { Logger } from "./loggers"; import type { Options } from "./options"; import { getCommonDirectory, glob, normalizePath } from "./fs"; @@ -39,8 +41,10 @@ export type EntryPointStrategy = export interface DocumentationEntryPoint { displayName: string; + readmeFile?: string; program: ts.Program; sourceFile: ts.SourceFile; + version?: string; } export function getEntryPoints( @@ -321,6 +325,10 @@ function getEntryPointsForPackages( for (const packagePath of expandedPackages) { const packageJsonPath = resolve(packagePath, "package.json"); const packageJson = loadPackageManifest(logger, packageJsonPath); + const includeVersion = options.getValue("includeVersion"); + const typedocPackageConfig = packageJson + ? extractTypedocConfigFromPackageManifest(logger, packageJsonPath) + : undefined; if (packageJson === undefined) { logger.error(`Could not load package manifest ${packageJsonPath}`); return; @@ -383,8 +391,32 @@ function getEntryPointsForPackages( return; } + if ( + includeVersion && + (!packageJson["version"] || + typeof packageJson["version"] !== "string") + ) { + logger.warn( + `--includeVersion was specified, but "${nicePath( + packageJsonPath + )}" does not properly specify a version.` + ); + } + results.push({ - displayName: packageJson["name"] as string, + displayName: + typedocPackageConfig?.displayName ?? + (packageJson["name"] as string), + version: packageJson["version"] as string | undefined, + readmeFile: typedocPackageConfig?.readmeFile + ? Path.resolve( + Path.join( + packageJsonPath, + "..", + typedocPackageConfig?.readmeFile + ) + ) + : undefined, program, sourceFile, }); diff --git a/src/lib/utils/package-manifest.ts b/src/lib/utils/package-manifest.ts index 753ad9c38..56fc65a88 100644 --- a/src/lib/utils/package-manifest.ts +++ b/src/lib/utils/package-manifest.ts @@ -6,7 +6,8 @@ import { existsSync } from "fs"; import { readFile, glob } from "./fs"; import type { Logger } from "./loggers"; import type { IMinimatch } from "minimatch"; -import { matchesAny } from "./paths"; +import { matchesAny, nicePath } from "./paths"; +import { additionalProperties, Infer, optional, validate } from "./validation"; /** * Helper for the TS type system to understand hasOwnProperty @@ -36,6 +37,47 @@ export function loadPackageManifest( return packageJson as Record; } +const typedocPackageManifestConfigSchema = { + displayName: optional(String), + entryPoint: optional(String), + readmeFile: optional(String), + + [additionalProperties]: false, +}; + +export type TypedocPackageManifestConfig = Infer< + typeof typedocPackageManifestConfigSchema +>; + +/** + * Extracts typedoc specific config from a specified package manifest + */ +export function extractTypedocConfigFromPackageManifest( + logger: Logger, + packageJsonPath: string +): TypedocPackageManifestConfig | undefined { + const packageJson = loadPackageManifest(logger, packageJsonPath); + if (!packageJson) { + return undefined; + } + if ( + hasOwnProperty(packageJson, "typedoc") && + typeof packageJson.typedoc == "object" && + packageJson.typedoc + ) { + if ( + !validate(typedocPackageManifestConfigSchema, packageJson.typedoc) + ) { + logger.error( + `Typedoc config extracted from package manifest file ${packageJsonPath} is not valid` + ); + return undefined; + } + return packageJson.typedoc; + } + return undefined; +} + /** * Load the paths to packages specified in a Yarn workspace package JSON * Returns undefined if packageJSON does not define a Yarn workspace @@ -205,10 +247,21 @@ export function getTsEntryPointForPackage( ): string | undefined | typeof ignorePackage { let packageMain = "index.js"; // The default, per the npm docs. let packageTypes = null; - if ( + const typedocPackageConfig = extractTypedocConfigFromPackageManifest( + logger, + packageJsonPath + ); + if (typedocPackageConfig?.entryPoint) { + packageMain = typedocPackageConfig.entryPoint; + } else if ( hasOwnProperty(packageJson, "typedocMain") && typeof packageJson.typedocMain == "string" ) { + logger.warn( + `Legacy typedoc entry point config (using "typedocMain" field) found for "${nicePath( + packageJsonPath + )}". Please update to use "typedoc": { "entryPoint": "..." } instead. In future upgrade, "typedocMain" field will be ignored.` + ); packageMain = packageJson.typedocMain; } else if ( hasOwnProperty(packageJson, "main") && diff --git a/src/test/packages.test.ts b/src/test/packages.test.ts index 872d030a4..6ec429bc4 100644 --- a/src/test/packages.test.ts +++ b/src/test/packages.test.ts @@ -9,7 +9,7 @@ import { import { tempdirProject } from "@typestrong/fs-fixture-builder"; import { TestLogger } from "./TestLogger"; -import { createMinimatch } from "../lib/utils/paths"; +import { createMinimatch, nicePath } from "../lib/utils/paths"; describe("Packages support", () => { let project: ReturnType; @@ -63,16 +63,18 @@ describe("Packages support", () => { }); project.addJsonFile("packages/baz/tsconfig.json", childTsconfig); - // Foo, entry point with "typedocMain" - project.addFile("packages/foo/dist/index.js", "module.exports = 123"); - project.addFile("packages/foo/index.ts", "export function foo() {}"); - project.addJsonFile("packages/foo/package.json", { - name: "typedoc-multi-package-foo", + // Bay, entry point with "typedoc.entryPoint" + project.addFile("packages/bay/dist/index.js", "module.exports = 123"); + project.addFile("packages/bay/index.ts", "export function foo() {}"); + project.addJsonFile("packages/bay/package.json", { + name: "typedoc-multi-package-bay", version: "1.0.0", main: "dist/index", - typedocMain: "index.ts", + typedoc: { + entryPoint: "index.ts", + }, }); - project.addJsonFile("packages/foo/tsconfig.json", childTsconfig); + project.addJsonFile("packages/bay/tsconfig.json", childTsconfig); // Ign, ignored package project.addFile("packages/ign/dist/index.js", "module.exports = 123"); @@ -81,7 +83,6 @@ describe("Packages support", () => { name: "typedoc-multi-package-ign", version: "1.0.0", main: "dist/index", - typedocMain: "index.ts", }); project.addJsonFile("packages/ign/tsconfig.json", childTsconfig); @@ -98,8 +99,8 @@ describe("Packages support", () => { packages, [ join(project.cwd, "packages/bar"), + join(project.cwd, "packages/bay"), join(project.cwd, "packages/baz"), - join(project.cwd, "packages/foo"), ].map(normalizePath) ); @@ -114,14 +115,75 @@ describe("Packages support", () => { equal(entries, [ join(project.cwd, "packages/bar/index.d.ts"), + join(project.cwd, "packages/bay/index.ts"), join(project.cwd, "packages/baz/index.ts"), - join(project.cwd, "packages/foo/index.ts"), ]); logger.discardDebugMessages(); logger.expectNoOtherMessages(); }); + it("handles monorepos with legacy configuration", () => { + project.addJsonFile("tsconfig.json", { + compilerOptions: { + strict: true, + sourceMap: true, + }, + exclude: ["node_modules", "dist"], + }); + const childTsconfig = { + extends: "../../tsconfig.json", + compilerOptions: { + outDir: "dist", + }, + }; + project.addJsonFile("package.json", { + name: "typedoc-multi-package-example", + main: "dist/index.js", + workspaces: ["packages/*"], + }); + + // Foo, entry point with "typedocMain" + project.addFile("packages/foo/dist/index.js", "module.exports = 123"); + project.addFile("packages/foo/index.ts", "export function foo() {}"); + project.addJsonFile("packages/foo/package.json", { + name: "typedoc-multi-package-foo", + version: "1.0.0", + main: "dist/index", + typedocMain: "index.ts", + }); + project.addJsonFile("packages/foo/tsconfig.json", childTsconfig); + + project.write(); + const logger = new TestLogger(); + const packages = expandPackages( + logger, + project.cwd, + [project.cwd], + createMinimatch(["**/ign"]) + ); + + equal(packages, [join(project.cwd, "packages/foo")].map(normalizePath)); + + const entries = packages.map((p) => { + const packageJson = join(p, "package.json"); + return getTsEntryPointForPackage( + logger, + packageJson, + JSON.parse(readFileSync(packageJson, "utf-8")) + ); + }); + + equal(entries, [join(project.cwd, "packages/foo/index.ts")]); + + logger.discardDebugMessages(); + logger.expectMessage( + `warn: Legacy typedoc entry point config (using "typedocMain" field) found for "${nicePath( + join(project.cwd, "/packages/foo/package.json") + )}". Please update to use "typedoc": { "entryPoint": "..." } instead. In future upgrade, "typedocMain" field will be ignored.` + ); + }); + it("handles single packages", () => { project.addJsonFile("tsconfig.json", { compilerOptions: {