diff --git a/.config/typedoc.json b/.config/typedoc.json index 611ab2929..c661b008e 100644 --- a/.config/typedoc.json +++ b/.config/typedoc.json @@ -12,11 +12,11 @@ "MeaningKeywords" ], "sort": ["kind", "instance-first", "required-first", "alphabetical"], - "entryPoints": ["../src"], - "entryPointStrategy": "resolve", + "entryPoints": ["../src/index.ts"], "excludeExternals": true, "excludeInternal": false, "excludePrivate": true, + "excludeReferences": true, "treatWarningsAsErrors": false, "validation": { "notExported": true, @@ -36,6 +36,6 @@ "Enumerations": 2.0, "Type Aliases": 2.0 }, - "searchInComments": true, + "includeVersion": true, "logLevel": "Verbose" } diff --git a/.eslintrc b/.eslintrc index def561d00..b6edb6a4c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -30,7 +30,6 @@ // Would be nice to lint these, but they shouldn't be included in the project, // so we need a second eslint config file. "example", - "src/codegen", "src/test/converter", "src/test/converter2", "src/test/module", @@ -41,33 +40,34 @@ "bin" ], "rules": { - "@typescript-eslint/no-floating-promises": 1, + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", // This one is just annoying since it complains at incomplete code - "no-empty": 0, + "no-empty": "off", // This rule is factually incorrect. Interfaces which extend some type alias can be used to introduce // new type names. This is useful particularly when dealing with mixins. - "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-empty-interface": "off", // We still use `any` fairly frequently... - "@typescript-eslint/ban-types": 0, - "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", // Really annoying, doesn't provide any value. - "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/no-empty-function": "off", // Declaration merging with a namespace is a necessary tool when working with enums. - "@typescript-eslint/no-namespace": 0, + "@typescript-eslint/no-namespace": "off", // Reported by TypeScript - "@typescript-eslint/no-unused-vars": 0, + "@typescript-eslint/no-unused-vars": "off", - "no-console": 1, + "no-console": "warn", // Feel free to turn one of these back on and submit a PR! - "@typescript-eslint/no-non-null-assertion": 0, - "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", "no-restricted-syntax": [ "warn", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9deac98b..592e27ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,6 @@ jobs: - name: Lint run: npm run lint -- --max-warnings 0 - name: Circular dependency check - uses: gerrit0/circular-dependency-check@v1 + uses: gerrit0/circular-dependency-check@v2 with: entry: dist/index.js diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index 5c3fd817e..ea4f480e5 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - id: check uses: EndBug/version-check@v1 with: diff --git a/.github/workflows/publish-lts.yml b/.github/workflows/publish-lts.yml index a02969735..5136a01c7 100644 --- a/.github/workflows/publish-lts.yml +++ b/.github/workflows/publish-lts.yml @@ -9,14 +9,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - id: check uses: EndBug/version-check@v1 with: diff-search: true - name: Set up Node if: steps.check.outputs.changed == 'true' - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: "16" - name: Upgrade npm diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index c1205a77b..892d09179 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -5,9 +5,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up Node - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: 16 - name: Upgrade npm diff --git a/.gitignore b/.gitignore index e781780c5..ff829ba53 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ yarn-error.log /coverage/ /dist/ /docs +/td*.json typedoc*.tgz tmp diff --git a/.vscode/launch.json b/.vscode/launch.json index c39a95ae0..1e9ea1e36 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,16 +6,16 @@ "configurations": [ { "args": [ + "-r", + "ts-node/register", "--timeout", "0", "--config", - "${workspaceFolder}/.config/mocha.fast.json", - "-g", - "2200" + "${workspaceFolder}/.config/mocha.fast.json" ], "internalConsoleOptions": "openOnSessionStart", "name": "Debug Tests", - "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "program": "${workspaceFolder}/node_modules/mocha/bin/mocha.js", "request": "launch", "skipFiles": ["/**"], "type": "node" @@ -24,6 +24,7 @@ "name": "Attach", "port": 9229, "request": "attach", + "internalConsoleOptions": "openOnSessionStart", "skipFiles": ["/**"], "type": "node", "sourceMaps": true diff --git a/.vscode/settings.json b/.vscode/settings.json index dc74b5984..09aac1d22 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,14 @@ "eslint.workingDirectories": [".", "./example"], "mochaExplorer.configFile": ".config/mocha.test-explorer.json", - "cSpell.words": ["cname", "tsbuildinfo", "tsdoc"] + "cSpell.words": [ + "cname", + "deserializers", + "linkcode", + "linkplain", + "shiki", + "tsbuildinfo", + "tsdoc", + "typestrong" + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4742fef..c8953db35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,79 @@ +# Beta + +### Breaking Changes + +- `@link`, `@linkcode` and `@linkplain` tags will now be resolved with TypeScript's link resolution by default. The `useTsLinkResolution` option + can be used to turn this behavior off, but be aware that doing so will mean your links will be resolved differently by editor tooling and TypeDoc. +- TypeDoc will no longer automatically load plugins from `node_modules`. Specify the `--plugin` option to indicate which modules should be loaded. +- The `packages` entry point strategy will now run TypeDoc in each provided package directory and then merge the results together. + The previous `packages` strategy has been preserved under `legacy-packages` and will be removed in 0.25. If the new strategy does not work + for your use case, please open an issue. +- Removed `--logger` option, to disable all logging, set the `logLevel` option to `none`. +- Dropped support for legacy `[[link]]`s, removed deprecated `Reflection.findReflectionByName`. +- Added `@overload` to default ignored tags. + +### API Breaking Changes + +- The `label` property on `Reflection` has moved to `Comment`. +- The default value of the `out` option has been changed from `""` to `"./docs"`, #2195. +- Renamed `DeclarationReflection#version` to `DeclarationReflection#projectVersion` to match property on `ProjectReflection`. +- Removed unused `Reflection#originalName`. +- Removed `Reflection#kindString`, use `ReflectionKind.singularString(reflection.kind)` or `ReflectionKind.pluralString(reflection.kind)` instead. +- The `named-tuple-member` and `template-literal` type kind have been replaced with `namedTupleMember` and `templateLiteral`, #2100. +- Properties related to rendering are no longer stored on `Reflection`, including `url`, `anchor`, `hasOwnDocument`, and `cssClasses`. +- `Application.bootstrap` will no longer load plugins. If you want to load plugins, use `Application.bootstrapWithPlugins` instead, #1635. +- The options passed to `Application.bootstrap` will now be applied both before _and_ after reading options files, which may cause a change in configuration + if using a custom script to run TypeDoc that includes some options, but other options are set in config files. +- Moved `sources` property previously declared on base `Reflection` class to `DeclarationReflection` and `SignatureReflection`. +- Moved `relevanceBoost` from `ContainerReflection` to `DeclarationReflection` since setting it on the parent class has no effect. +- Removed internal `ReferenceType.getSymbol`, reference types no longer reference the `ts.Symbol` to enable generation from serialized JSON. +- `OptionsReader.priority` has been renamed to `OptionsReader.order` to more accurately reflect how it works. +- `ReferenceType`s which point to type parameters will now always be intentionally broken since they were never linked and should not be warned about when validating exports. +- `ReferenceType`s now longer include an `id` property for their target. They now instead include a `target` property. +- Removed `Renderer.addExternalSymbolResolver`, use `Converter.addExternalSymbolResolver` instead. +- Removed `CallbackLogger`. +- Removed `SerializeEventData` from serialization events. +- A `PageEvent` is now required for `getRenderContext`. If caching the context object, `page` must be updated when `getRenderContext` is called. +- `PageEvent` no longer includes the `template` property. The `Theme.render` method is now expected to take the template to render the page with as its second argument. +- Removed `secondaryNavigation` member on `DefaultThemeRenderContext`. +- Renamed `navigation` to `sidebar` on `DefaultThemeRenderContext` and `navigation.begin`/`navigation.end` hooks to `sidebar.begin`/`sidebar.end`. + +### Features + +- Added `--useTsLinkResolution` option (on by default) which tells TypeDoc to use TypeScript's `@link` resolution. +- Added `--jsDocCompatibility` option (on by default) which controls TypeDoc's automatic detection of code blocks in `@example` and `@default` tags. +- Reworked default theme navigation to add support for a page table of contents, #1478, #2189. +- Added support for `@interface` on type aliases to tell TypeDoc to convert the fully resolved type as an interface, #1519 +- Added support for `@namespace` on variable declarations to tell TypeDoc to convert the variable as a namespace, #2055. +- Added support for `@prop`/`@property` to specify documentation for a child property of a symbol, intended for use with `@interface`. +- TypeDoc will now produce more informative error messages for options which cannot be set from the cli, #2022. +- TypeDoc will now attempt to guess what option you may have meant if given an invalid option name. +- Plugins may now return a `Promise` from their `load` function, #185. +- TypeDoc now supports plugins written with ESM, #1635. +- Added `Renderer.preRenderAsyncJobs` and `Renderer.postRenderAsyncJobs`, which may be used by plugins to perform async processing for rendering, #185. + Note: Conversion is still intentionally a synchronous process to ensure stability of converted projects between runs. +- TypeDoc options may now be set under the `typedocOptions` key in `package.json`, #2112. +- Added `--cacheBust` option to tell TypeDoc to include include the generation time in files, #2124. +- Added `--excludeReferences` option to tell TypeDoc to omit re-exports of a symbol already included from the documentation. +- Introduced new render hooks `pageSidebar.begin` and `pageSidebar.end`. + +### Bug Fixes + +- TypeDoc will now ignore package.json files not containing a `name` field, #2190. +- Fixed `@inheritDoc` on signatures (functions, methods, constructors, getters, setters) being unable to inherit from a non-signature. +- Interfaces/classes created via extending a module will no longer contain variables/functions where the member should have been converted as properties/methods, #2150. +- TypeDoc will now ignore a leading `v` in versions, #2212. +- Category titles now render with the same format in the page index and heading title, #2196. +- Fixed crash when using `typeof` on a reference with type arguments, #2220. +- Fixed broken anchor links generated to signatures nested within objects. + +### Thanks! + +- @bodil +- @futurGH +- @jm4rtinez +- @muratgozel + # Unreleased ## v0.23.28 (2023-03-19) diff --git a/README.md b/README.md index 90ba34bc2..531c8987a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ npm install typedoc --save-dev ## Usage -To generate documentation TypeDoc needs to know your project entry point, and TypeScript +To generate documentation TypeDoc needs to know your project entry point and TypeScript compiler options. It will automatically try to find your `tsconfig.json` file, so you can just specify the entry point of your library: @@ -38,37 +38,10 @@ By default, TypeDoc will search for a file called `index` under the directory. ### Monorepos / Workspaces -If your codebase is comprised of one or more npm packages, you can pass the paths to these -packages and TypeDoc will attempt to determine entry points based on `package.json`'s `main` -property (with default value `index.js`) and if it wasn't found, based on `types` property. -If any of the packages given are the root of an [npm Workspace](https://docs.npmjs.com/cli/v7/using-npm/workspaces) -or a [Yarn Workspace](https://classic.yarnpkg.com/en/docs/workspaces/) TypeDoc will find all -the `workspaces` defined in the `package.json`. In order to find your entry points, TypeDoc requires -either that you turn on sourcemaps so that it can discover the original TS file, or that you -specify `"typedocMain": "src/index.ts"` to explicitly state where the package entry point is. -Supports wildcard paths in the same fashion as those found in npm or Yarn workspaces. - -#### Single npm module - -```bash -typedoc --entryPointStrategy packages . -``` - -#### Monorepo with npm/Yarn workspace at the root - -```bash -typedoc --entryPointStrategy packages . -``` - -#### Monorepo with manually specified sub-packages to document - -This can be useful if you do not want all your workspaces to be processed. -Accepts the same paths as would go in the `package.json`'s workspaces - -```bash -# Note the single quotes prevent shell wildcard expansion, allowing typedoc to do the expansion -typedoc --entryPointStrategy packages a-package 'some-more-packages/*' 'some-other-packages/*' -``` +If your codebase is comprised of one or more npm packages, you can build documentation for each of them individually +and merge the results together into a single site by setting `entryPointStrategy` to `packages`. In this mode TypeDoc +requires configuration to be present in each directory to specify the entry points. For an example setup, see +https://github.com/Gerrit0/typedoc-packages-example ### Arguments diff --git a/bin/typedoc b/bin/typedoc index ce835b36e..221181e72 100755 --- a/bin/typedoc +++ b/bin/typedoc @@ -2,137 +2,4 @@ //@ts-check /* eslint-disable @typescript-eslint/no-var-requires */ - -const ExitCodes = { - Ok: 0, - OptionError: 1, - NoEntryPoints: 2, - CompileError: 3, - ValidationError: 4, - OutputError: 5, - ExceptionThrown: 6, -}; - -const td = require(".."); -const { getOptionsHelp } = require("../dist/lib/utils/options/help"); - -const app = new td.Application(); - -app.options.addReader(new td.ArgumentsReader(0)); -app.options.addReader(new td.TypeDocReader()); -app.options.addReader(new td.TSConfigReader()); -app.options.addReader(new td.ArgumentsReader(300)); - -app.bootstrap(); - -run(app) - .catch((error) => { - console.error("TypeDoc exiting with unexpected error:"); - console.error(error); - return ExitCodes.ExceptionThrown; - }) - .then((exitCode) => (process.exitCode = exitCode)); - -/** @param {td.Application} app */ -async function run(app) { - if (app.options.getValue("version")) { - console.log(app.toString()); - return ExitCodes.Ok; - } - - if (app.options.getValue("help")) { - console.log(getOptionsHelp(app.options)); - return ExitCodes.Ok; - } - - if (app.options.getValue("showConfig")) { - console.log(app.options.getRawValues()); - return ExitCodes.Ok; - } - - if (app.logger.hasErrors()) { - return ExitCodes.OptionError; - } - if ( - app.options.getValue("treatWarningsAsErrors") && - app.logger.hasWarnings() - ) { - return ExitCodes.OptionError; - } - - if (app.options.getValue("entryPoints").length === 0) { - app.logger.error("No entry points provided"); - return ExitCodes.NoEntryPoints; - } - - if (app.options.getValue("watch")) { - app.convertAndWatch(async (project) => { - const out = app.options.getValue("out"); - if (out) { - await app.generateDocs(project, out); - } - const json = app.options.getValue("json"); - if (json) { - await app.generateJson(project, json); - } - - if (!out && !json) { - await app.generateDocs(project, "./docs"); - } - }); - return ExitCodes.Ok; - } - - const project = app.convert(); - if (!project) { - return ExitCodes.CompileError; - } - if ( - app.options.getValue("treatWarningsAsErrors") && - app.logger.hasWarnings() - ) { - return ExitCodes.CompileError; - } - - const preValidationWarnCount = app.logger.warningCount; - app.validate(project); - const hadValidationWarnings = - app.logger.warningCount !== preValidationWarnCount; - if (app.logger.hasErrors()) { - return ExitCodes.ValidationError; - } - if ( - hadValidationWarnings && - (app.options.getValue("treatWarningsAsErrors") || - app.options.getValue("treatValidationWarningsAsErrors")) - ) { - return ExitCodes.ValidationError; - } - - if (app.options.getValue("emit") !== "none") { - const out = app.options.getValue("out"); - if (out) { - await app.generateDocs(project, out); - } - const json = app.options.getValue("json"); - if (json) { - await app.generateJson(project, json); - } - - if (!out && !json) { - await app.generateDocs(project, "./docs"); - } - - if (app.logger.hasErrors()) { - return ExitCodes.OutputError; - } - if ( - app.options.getValue("treatWarningsAsErrors") && - app.logger.hasWarnings() - ) { - return ExitCodes.OutputError; - } - } - - return ExitCodes.Ok; -} +require("../dist/lib/cli"); diff --git a/example/package-lock.json b/example/package-lock.json index b448972a2..6f99d5c11 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -17,7 +17,7 @@ "react-dom": "^17.0.2" }, "devDependencies": { - "typescript": "^4.5.2" + "typescript": "^5.0.3" } }, "node_modules/@types/lodash": { @@ -122,16 +122,16 @@ } }, "node_modules/typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.3.tgz", + "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } } }, @@ -226,9 +226,9 @@ } }, "typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.3.tgz", + "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", "dev": true } } diff --git a/example/package.json b/example/package.json index 2a9f6f67e..beb48c95d 100644 --- a/example/package.json +++ b/example/package.json @@ -19,6 +19,6 @@ "react-dom": "^17.0.2" }, "devDependencies": { - "typescript": "^4.5.2" + "typescript": "^5.0.3" } } diff --git a/example/src/functions.ts b/example/src/functions.ts index 980698bab..0953a2325 100644 --- a/example/src/functions.ts +++ b/example/src/functions.ts @@ -27,8 +27,7 @@ export const sqrtArrowFunction = (x: number): number => Math.sqrt(x); /** * A simple generic function that concatenates two arrays. * - * Use [`@typeParam `](https://typedoc.org/guides/doccomments/#%40typeparam-%3Cparam-name%3E-or-%40template-%3Cparam-name%3E) + * Use [`@typeParam `](https://typedoc.org/tags/typeParam/) * to document generic type parameters, e.g. * * ```text diff --git a/example/src/showcase.ts b/example/src/showcase.ts index c6f38a402..074fdd371 100644 --- a/example/src/showcase.ts +++ b/example/src/showcase.ts @@ -7,9 +7,8 @@ * * ## Symbol References * - * You can link to other classes, members or functions using double square - * brackets or an inline link tag. See the [TypeDoc - * documentation](https://typedoc.org/guides/doccomments/#symbol-references) for + * You can link to other classes, members or functions using an inline link tag. See the [TypeDoc + * documentation](https://typedoc.org/tags/link/) for * details. * * ## Code in Doc Comments diff --git a/internal-docs/custom-themes.md b/internal-docs/custom-themes.md index 6ef538072..9e019a675 100644 --- a/internal-docs/custom-themes.md +++ b/internal-docs/custom-themes.md @@ -1,7 +1,7 @@ # Custom Themes -TypeDoc 0.22 changes how themes are defined, necessarily breaking compatibility with all Handlebars based themes -created for TypeDoc 0.21 and earlier. In 0.22, themes are defined by plugins calling the `defineTheme` method on +TypeDoc 0.22 changed how themes are defined, necessarily breaking compatibility with all Handlebars based themes +created for TypeDoc 0.21 and earlier. In 0.22+, themes are defined by plugins calling the `defineTheme` method on `Application.renderer` when plugins are loaded. The most trivial theme, which exactly duplicates the default theme can be created by doing the following: @@ -84,9 +84,28 @@ export function load(app: Application) { For documentation on the available hooks, see the [RendererHooks](https://typedoc.org/api/interfaces/RendererHooks.html) documentation on the website. -## Future Work +## Async Jobs -The following is not currently supported by TypeDoc, but is planned on being included in a future version. +Themes which completely override TypeDoc's builtin renderer may need to perform some async initialization +or teardown after rendering. To support this, there are two arrays of functions available on `Renderer` +which plugins may add a callback to. The renderer will call each function within these arrays when rendering +and await the results. -- Support for pre-render and post-render async actions for copying files, preparing the output directory, etc. - In the meantime, listen to `RendererEvent.BEGIN` or `RendererEvent.END` and perform processing there. +```ts +import { Application, RendererEvent } from "typedoc"; +export function load(app: Application) { + app.renderer.preRenderAsyncJobs.push(async (output: RendererEvent) => { + app.logger.info( + "Pre render, no docs written to " + output.outputDirectory + " yet" + ); + // Slow down rendering by 1 second + await new Promise((r) => setTimeout(r, 1000)); + }); + + app.renderer.postRenderAsyncJobs.push(async (output: RendererEvent) => { + app.logger.info( + "Post render, all docs written to " + output.outputDirectory + ); + }); +} +``` diff --git a/internal-docs/third-party-symbols.md b/internal-docs/third-party-symbols.md index bbe2fce6a..161661cb5 100644 --- a/internal-docs/third-party-symbols.md +++ b/internal-docs/third-party-symbols.md @@ -2,7 +2,7 @@ TypeDoc 0.22 added support for linking to third party sites by associating a symbol name with npm packages. -Since TypeDoc 0.23.13, some mappings can be defined without a plugin by setting `externalSymbolLinkMappings`. +Since TypeDoc 0.23.13, some mappings can be defined without a plugin by setting [`externalSymbolLinkMappings`][externalSymbolLinkMappings]. This should be set to an object whose keys are package names, and values are the `.` joined qualified name of the third party symbol. If the link was defined with a user created declaration reference, it may also have a `:meaning` at the end. TypeDoc will _not_ attempt to perform fuzzy matching to remove the meaning from @@ -20,6 +20,7 @@ detected as belonging to the `typescript` package rather than the `global` packa // typedoc.json { "externalSymbolLinkMappings": { + // For these you should probably install typedoc-plugin-mdn-links instead "global": { // Handle {@link !Promise} "Promise": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise" @@ -46,7 +47,7 @@ A wildcard can be used to provide a fallback link to any unmapped type. } ``` -Plugins can add support for linking to third party sites by calling `app.converter.addUnknownSymbolResolver`. +Plugins can add support for linking to third party sites by calling [`app.converter.addUnknownSymbolResolver`][addUnknownSymbolResolver]. If the given symbol is unknown, or does not appear in the documentation site, the resolver may return `undefined` and no link will be rendered unless provided by another resolver. @@ -149,5 +150,12 @@ export function load(app: Application) { ``` The unknown symbol resolver will also be passed the reflection containing the link -and, if the link was defined by the user, the [CommentDisplayPart](https://typedoc.org/api/types/CommentDisplayPart.html) -which was parsed into the `DeclarationReference` provided as the first argument. +and, if the link was defined by the user, the [CommentDisplayPart] which was parsed into the [DeclarationReference] provided as the first argument. + +If `--useTsLinkResolution` is on (the default), it may also be passed a [ReflectionSymbolId] referencing the symbol that TypeScript resolves the link to. + +[externalSymbolLinkMappings]: https://typedoc.org/guides/options/#externalsymbollinkmappings +[CommentDisplayPart]: https://typedoc.org/api/types/CommentDisplayPart.html +[DeclarationReference]: https://typedoc.org/api/interfaces/DeclarationReference.html +[ReflectionSymbolId]: https://typedoc.org/api/classes/Application.html +[addUnknownSymbolResolver]: https://typedoc.org/api/classes/Converter.html#addUnknownSymbolResolver diff --git a/package-lock.json b/package-lock.json index 1e7f192c7..6b869564a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "typedoc", - "version": "0.23.28", + "version": "0.24.0-beta.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "typedoc", - "version": "0.23.28", + "version": "0.24.0-beta.8", "license": "Apache-2.0", "dependencies": { "lunr": "^2.3.9", @@ -24,7 +24,7 @@ "@types/node": "14", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", - "@typestrong/fs-fixture-builder": "github:TypeStrong/fs-fixture-builder#5a9486bc66f6e36988106685768396281f6cbc10", + "@typestrong/fs-fixture-builder": "github:TypeStrong/fs-fixture-builder#8abd1494280116ff5318dde2c50ad01e1663790c", "c8": "^7.13.0", "esbuild": "^0.17.12", "eslint": "^8.36.0", @@ -879,9 +879,13 @@ }, "node_modules/@typestrong/fs-fixture-builder": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/TypeStrong/fs-fixture-builder.git#5a9486bc66f6e36988106685768396281f6cbc10", - "integrity": "sha512-w4mw0puueKmRLpBuICu0uacDjjhtVkvVSYz8N03bQJ0OYfRGIfS2pKyn4VmqZDAitHVkuXSA4xdgzv4yjhWcjg==", - "dev": true + "resolved": "git+ssh://git@github.com/TypeStrong/fs-fixture-builder.git#8abd1494280116ff5318dde2c50ad01e1663790c", + "integrity": "sha512-DS1emSwvN8RjElPNzV00/qkICp2R/fuiEkraaFNVTFTXcJXLqQ1KESTJXxSbSFE8AUsVgxa/XHG51pfSM0i0kw==", + "dev": true, + "license": "MIT", + "bin": { + "capture-fs-fixture": "dist/capture-fixture.ts" + } }, "node_modules/acorn": { "version": "8.8.0", @@ -4183,10 +4187,10 @@ } }, "@typestrong/fs-fixture-builder": { - "version": "git+ssh://git@github.com/TypeStrong/fs-fixture-builder.git#5a9486bc66f6e36988106685768396281f6cbc10", - "integrity": "sha512-w4mw0puueKmRLpBuICu0uacDjjhtVkvVSYz8N03bQJ0OYfRGIfS2pKyn4VmqZDAitHVkuXSA4xdgzv4yjhWcjg==", + "version": "git+ssh://git@github.com/TypeStrong/fs-fixture-builder.git#8abd1494280116ff5318dde2c50ad01e1663790c", + "integrity": "sha512-DS1emSwvN8RjElPNzV00/qkICp2R/fuiEkraaFNVTFTXcJXLqQ1KESTJXxSbSFE8AUsVgxa/XHG51pfSM0i0kw==", "dev": true, - "from": "@typestrong/fs-fixture-builder@github:TypeStrong/fs-fixture-builder#5a9486bc66f6e36988106685768396281f6cbc10" + "from": "@typestrong/fs-fixture-builder@github:TypeStrong/fs-fixture-builder#8abd1494280116ff5318dde2c50ad01e1663790c" }, "acorn": { "version": "8.8.0", diff --git a/package.json b/package.json index 9b87f7c4d..2a004b24e 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { "name": "typedoc", "description": "Create api documentation for TypeScript projects.", - "version": "0.23.28", + "version": "0.24.0-beta.8", "homepage": "https://typedoc.org", - "main": "./dist/index.js", "exports": { ".": "./dist/index.js", "./tsdoc.json": "./tsdoc.json", @@ -40,7 +39,7 @@ "@types/node": "14", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", - "@typestrong/fs-fixture-builder": "github:TypeStrong/fs-fixture-builder#5a9486bc66f6e36988106685768396281f6cbc10", + "@typestrong/fs-fixture-builder": "github:TypeStrong/fs-fixture-builder#8abd1494280116ff5318dde2c50ad01e1663790c", "c8": "^7.13.0", "esbuild": "^0.17.12", "eslint": "^8.36.0", @@ -74,7 +73,7 @@ "build:tsc": "tsc --project .", "build:themes": "node scripts/build_themes.js", "build:prod": "npm run build:prod:tsc && npm run build:themes", - "build:prod:tsc": "tsc --project . --sourceMap false", + "build:prod:tsc": "tsc --project . --sourceMap false --declarationMap false", "lint": "eslint . && npm run prettier -- --check .", "prettier": "prettier --config .config/.prettierrc.json --ignore-path .config/.prettierignore", "prepublishOnly": "node scripts/set_strict.js false && npm run build:prod && npm test", diff --git a/scripts/accept_visual_regression.js b/scripts/accept_visual_regression.js index 5a8d0dc8a..a5ddd6709 100644 --- a/scripts/accept_visual_regression.js +++ b/scripts/accept_visual_regression.js @@ -1,7 +1,9 @@ //@ts-check +require("ts-node/register"); + const fs = require("fs/promises"); -const { copy } = require("../dist/lib/utils/fs"); +const { copy } = require("../src/lib/utils/fs"); const { join } = require("path"); const expectedDir = join(__dirname, "../tmp/baseline"); diff --git a/scripts/compare_screenshots.sh b/scripts/compare_screenshots.sh index 24480cfe8..c0cf2580b 100755 --- a/scripts/compare_screenshots.sh +++ b/scripts/compare_screenshots.sh @@ -4,6 +4,7 @@ test -d ./tmp/output && rm -rf ./tmp/output mkdir -p ./tmp/{output,screenshots,baseline} docker run \ + --rm \ --name typedoc-reg-suit \ -v "$PWD/tmp/screenshots:/new" \ -v "$PWD/tmp/baseline:/old" \ diff --git a/scripts/generate_options_schema.js b/scripts/generate_options_schema.js index b3c9f4e51..0147226a6 100644 --- a/scripts/generate_options_schema.js +++ b/scripts/generate_options_schema.js @@ -1,8 +1,10 @@ //@ts-check +require("ts-node/register"); + const { writeFileSync } = require("fs"); -const { addTypeDocOptions } = require("../dist/lib/utils/options/sources"); -const { ParameterType } = require("../dist"); +const { addTypeDocOptions } = require("../src/lib/utils/options/sources"); +const { ParameterType } = require("../src"); const IGNORED_OPTIONS = new Set(["help", "version"]); @@ -17,7 +19,7 @@ const schema = { }; addTypeDocOptions({ - /** @param {import("../dist").DeclarationOption} option */ + /** @param {import("../src").DeclarationOption} option */ addDeclaration(option) { if (IGNORED_OPTIONS.has(option.name)) return; @@ -34,7 +36,7 @@ addTypeDocOptions({ data.type = "array"; data.items = { type: "string" }; data.default = - /** @type {import("../dist").ArrayDeclarationOption} */ ( + /** @type {import("../src").ArrayDeclarationOption} */ ( option ).defaultValue ?? []; break; @@ -43,7 +45,7 @@ addTypeDocOptions({ data.type = "string"; if (!IGNORED_DEFAULT_OPTIONS.has(option.name)) { data.default = - /** @type {import("../dist").StringDeclarationOption} */ ( + /** @type {import("../src").StringDeclarationOption} */ ( option ).defaultValue ?? ""; } @@ -51,13 +53,13 @@ addTypeDocOptions({ case ParameterType.Boolean: data.type = "boolean"; data.default = - /** @type {import("../dist").BooleanDeclarationOption} */ ( + /** @type {import("../src").BooleanDeclarationOption} */ ( option ).defaultValue ?? false; break; case ParameterType.Number: { const decl = - /** @type {import("../dist").NumberDeclarationOption} */ ( + /** @type {import("../src").NumberDeclarationOption} */ ( option ); data.type = "number"; @@ -68,7 +70,7 @@ addTypeDocOptions({ } case ParameterType.Map: { const map = - /** @type {import("../dist").MapDeclarationOption} */ ( + /** @type {import("../src").MapDeclarationOption} */ ( option ).map; data.enum = @@ -80,7 +82,7 @@ addTypeDocOptions({ typeof map[key] === "number" ? key : map[key] ); data.default = - /** @type {import("../dist").MapDeclarationOption} */ ( + /** @type {import("../src").MapDeclarationOption} */ ( option ).defaultValue; if (!data.enum.includes(data.default)) { @@ -101,7 +103,7 @@ addTypeDocOptions({ properties: {}, }; const defaults = - /** @type {import("../dist").FlagsDeclarationOption>} */ ( + /** @type {import("../src").FlagsDeclarationOption>} */ ( option ).defaults; @@ -115,8 +117,9 @@ addTypeDocOptions({ data.default = defaults; } case ParameterType.Mixed: + case ParameterType.Object: data.default = - /** @type {import("../dist").MixedDeclarationOption} */ ( + /** @type {import("../src").MixedDeclarationOption} */ ( option ).defaultValue; break; @@ -130,9 +133,6 @@ addTypeDocOptions({ }, }); -schema.properties.logger.enum = ["console", "none"]; -schema.properties.logger.default = "console"; - schema.properties.visibilityFilters.type = "object"; schema.properties.visibilityFilters.properties = Object.fromEntries( Object.keys(schema.properties.visibilityFilters.default).map((x) => [ @@ -155,7 +155,7 @@ schema.properties.extends = { delete schema.properties.sort.items.type; schema.properties.sort.items.enum = - require("../dist/lib/utils/sort").SORT_STRATEGIES; + require("../src/lib/utils/sort").SORT_STRATEGIES; const output = JSON.stringify(schema, null, "\t"); diff --git a/scripts/rebuild_specs.js b/scripts/rebuild_specs.js index 89c731638..49f459080 100644 --- a/scripts/rebuild_specs.js +++ b/scripts/rebuild_specs.js @@ -1,37 +1,41 @@ // @ts-check "use strict"; + +require("ts-node/register"); + Error.stackTraceLimit = 50; const ts = require("typescript"); const fs = require("fs"); const path = require("path"); -const TypeDoc = require(".."); -const { getExpandedEntryPointsForPaths } = require("../dist/lib/utils"); +const td = require("../src"); +const { getExpandedEntryPointsForPaths } = require("../src/lib/utils"); const { ok } = require("assert"); -const { SourceReference } = require(".."); +const { basename } = require("path"); const base = path.join(__dirname, "../src/test/converter"); -const app = new TypeDoc.Application(); -app.options.addReader(new TypeDoc.TSConfigReader()); +const app = new td.Application(); +app.options.addReader(new td.TSConfigReader()); app.bootstrap({ name: "typedoc", excludeExternals: true, disableSources: false, tsconfig: path.join(base, "tsconfig.json"), externalPattern: ["**/node_modules/**"], - entryPointStrategy: TypeDoc.EntryPointStrategy.Expand, - logLevel: TypeDoc.LogLevel.Warn, + entryPointStrategy: td.EntryPointStrategy.Expand, + logLevel: td.LogLevel.Warn, gitRevision: "fake", + readme: "none", }); app.serializer.addSerializer({ priority: -1, supports(obj) { - return obj instanceof SourceReference; + return obj instanceof td.SourceReference; }, /** - * @param {SourceReference} ref + * @param {td.SourceReference} ref */ - toObject(ref, obj, _serializer) { + toObject(ref, obj) { if (obj.url) { obj.url = `typedoc://${obj.url.substring( obj.url.indexOf(ref.fileName) @@ -40,6 +44,16 @@ app.serializer.addSerializer({ return obj; }, }); +app.serializer.addSerializer({ + priority: -1, + supports(obj) { + return obj instanceof td.ProjectReflection; + }, + toObject(_refl, obj) { + delete obj.packageVersion; + return obj; + }, +}); /** @type {[string, () => void, () => void][]} */ const conversions = [ @@ -85,7 +99,7 @@ function rebuildConverterTests(dirs) { for (const [file, before, after] of conversions) { const out = path.join(fullPath, `${file}.json`); if (fs.existsSync(out)) { - TypeDoc.resetReflectionID(); + td.resetReflectionID(); before(); const entry = getExpandedEntryPointsForPaths( app.logger, @@ -95,7 +109,11 @@ function rebuildConverterTests(dirs) { ); ok(entry, "Missing entry point"); const result = app.converter.convert(entry); - const serialized = app.serializer.toObject(result); + result.name = basename(fullPath); + const serialized = app.serializer.projectToObject( + result, + process.cwd() + ); const data = JSON.stringify(serialized, null, " ") + "\n"; after(); diff --git a/src/index.ts b/src/index.ts index 34cab7db7..096a15adc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,10 @@ export { Application } from "./lib/application"; export { EventDispatcher, Event } from "./lib/utils/events"; export { resetReflectionID } from "./lib/models/reflections/abstract"; export { normalizePath } from "./lib/utils/fs"; +/** + * All symbols documented under the Models namespace are also available in the root import. + */ +export * as Models from "./lib/models"; export * from "./lib/models"; export { Converter, @@ -38,6 +42,7 @@ export { LogLevel, Logger, Options, + PackageJsonReader, ParameterHint, ParameterType, TSConfigReader, @@ -69,6 +74,7 @@ export type { ParameterTypeToOptionTypeMap, DocumentationEntryPoint, ManuallyValidatedOption, + EnumKeys, } from "./lib/utils"; export type { EventMap, EventCallback } from "./lib/utils/events"; @@ -76,10 +82,12 @@ export type { EventMap, EventCallback } from "./lib/utils/events"; export { JSONOutput, Serializer, + Deserializer, + type Deserializable, + type DeserializerComponent, type SerializerComponent, SerializeEvent, } from "./lib/serialization"; -export type { SerializeEventData } from "./lib/serialization"; import TypeScript from "typescript"; export { TypeScript }; diff --git a/src/lib/application-events.ts b/src/lib/application-events.ts index 7fc75b25d..d13ceb070 100644 --- a/src/lib/application-events.ts +++ b/src/lib/application-events.ts @@ -1,4 +1,5 @@ export const ApplicationEvents = { BOOTSTRAP_END: "bootstrapEnd", + REVIVE: "reviveProject", VALIDATE_PROJECT: "validateProject", }; diff --git a/src/lib/application.ts b/src/lib/application.ts index 16c529d6b..5f87cf434 100644 --- a/src/lib/application.ts +++ b/src/lib/application.ts @@ -3,17 +3,9 @@ import ts from "typescript"; import { Converter } from "./converter/index"; import { Renderer } from "./output/renderer"; -import { Serializer } from "./serialization"; +import { Deserializer, JSONOutput, Serializer } from "./serialization"; import type { ProjectReflection } from "./models/index"; -import { - Logger, - ConsoleLogger, - CallbackLogger, - loadPlugins, - writeFile, - discoverPlugins, - TSConfigReader, -} from "./utils/index"; +import { Logger, ConsoleLogger, loadPlugins, writeFile } from "./utils/index"; import { AbstractComponent, @@ -28,6 +20,7 @@ import { DocumentationEntryPoint, EntryPointStrategy, getEntryPoints, + getPackageDirectories, getWatchEntryPoints, } from "./utils/entry-point"; import { nicePath } from "./utils/paths"; @@ -36,6 +29,9 @@ import { validateExports } from "./validation/exports"; import { validateDocumentation } from "./validation/documentation"; import { validateLinks } from "./validation/links"; import { ApplicationEvents } from "./application-events"; +import { findTsConfigFile } from "./utils/tsconfig"; +import { getCommonDirectory, glob, readFile } from "./utils/fs"; +import { resetReflectionID } from "./models/reflections/abstract"; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageInfo = require("../../package.json") as { @@ -78,7 +74,12 @@ export class Application extends ChildableComponent< /** * The serializer used to generate JSON output. */ - serializer: Serializer; + serializer = new Serializer(); + + /** + * The deserializer used to restore previously serialized JSON output. + */ + deserializer = new Deserializer(this); /** * The logger that should be used to output messages. @@ -87,14 +88,18 @@ export class Application extends ChildableComponent< options: Options; - /** @internal */ - @BindOption("logger") - readonly loggerType!: string | Function; - /** @internal */ @BindOption("skipErrorChecking") readonly skipErrorChecking!: boolean; + /** @internal */ + @BindOption("entryPointStrategy") + readonly entryPointStrategy!: EntryPointStrategy; + + /** @internal */ + @BindOption("entryPoints") + readonly entryPoints!: string[]; + /** * The version number of TypeDoc. */ @@ -102,11 +107,16 @@ export class Application extends ChildableComponent< /** * Emitted after plugins have been loaded and options have been read, but before they have been frozen. - * The listener will be given an instance of {@link Application} and the {@link TypeDocOptions | Partial} - * passed to `bootstrap`. + * The listener will be given an instance of {@link Application}. */ static readonly EVENT_BOOTSTRAP_END = ApplicationEvents.BOOTSTRAP_END; + /** + * Emitted after a project has been deserialized from JSON. + * The listener will be given an instance of {@link ProjectReflection}. + */ + static readonly EVENT_PROJECT_REVIVE = ApplicationEvents.REVIVE; + /** * Emitted when validation is being run. * The listener will be given an instance of {@link ProjectReflection}. @@ -121,50 +131,35 @@ export class Application extends ChildableComponent< this.logger = new ConsoleLogger(); this.options = new Options(this.logger); - this.options.addDefaultDeclarations(); - this.serializer = new Serializer(); this.converter = this.addComponent("converter", Converter); this.renderer = this.addComponent("renderer", Renderer); } /** - * Initialize TypeDoc with the given options object. - * - * @param options The desired options to set. + * Initialize TypeDoc, loading plugins if applicable. */ - bootstrap(options: Partial = {}): void { - for (const [key, val] of Object.entries(options)) { - try { - this.options.setValue(key as keyof TypeDocOptions, val); - } catch { - // Ignore errors, plugins haven't been loaded yet and may declare an option. - } - } + async bootstrapWithPlugins( + options: Partial = {} + ): Promise { + this.options.reset(); + this.setOptions(options, /* reportErrors */ false); this.options.read(new Logger()); - - const logger = this.loggerType; - if (typeof logger === "function") { - this.logger = new CallbackLogger(logger); - this.options.setLogger(this.logger); - } else if (logger === "none") { - this.logger = new Logger(); - this.options.setLogger(this.logger); - } this.logger.level = this.options.getValue("logLevel"); - const plugins = discoverPlugins(this); - loadPlugins(this, plugins); + await loadPlugins(this, this.options.getValue("plugin")); + this.bootstrap(options); + } + + /** + * Initialize TypeDoc without loading plugins. + */ + bootstrap(options: Partial = {}): void { this.options.reset(); - for (const [key, val] of Object.entries(options)) { - try { - this.options.setValue(key as keyof TypeDocOptions, val); - } catch (error) { - ok(error instanceof Error); - this.logger.error(error.message); - } - } + this.setOptions(options, /* reportErrors */ false); this.options.read(this.logger); + this.setOptions(options); + this.logger.level = this.options.getValue("logLevel"); if (hasBeenLoadedMultipleTimes()) { this.logger.warn( @@ -173,7 +168,20 @@ export class Application extends ChildableComponent< )}` ); } - this.trigger(ApplicationEvents.BOOTSTRAP_END, this, options); + this.trigger(ApplicationEvents.BOOTSTRAP_END, this); + } + + private setOptions(options: Partial, reportErrors = true) { + for (const [key, val] of Object.entries(options)) { + try { + this.options.setValue(key as never, val as never); + } catch (error) { + ok(error instanceof Error); + if (reportErrors) { + this.logger.error(error.message); + } + } + } } /** @@ -202,13 +210,21 @@ export class Application extends ChildableComponent< */ public convert(): ProjectReflection | undefined { const start = Date.now(); - // We seal here rather than in the Converter class since TypeDoc's tests reuse the Application + // We freeze here rather than in the Converter class since TypeDoc's tests reuse the Application // with a few different settings. this.options.freeze(); this.logger.verbose( `Using TypeScript ${this.getTypeScriptVersion()} from ${this.getTypeScriptPath()}` ); + if (this.entryPointStrategy === EntryPointStrategy.Merge) { + return this._merge(); + } + + if (this.entryPointStrategy === EntryPointStrategy.Packages) { + return this._convertPackages(); + } + if ( !supportedVersionMajorMinor.some( (version) => version == ts.versionMajorMinor @@ -305,17 +321,17 @@ export class Application extends ChildableComponent< // Support for packages mode is currently unimplemented if ( - this.options.getValue("entryPointStrategy") === - EntryPointStrategy.Packages + this.entryPointStrategy !== EntryPointStrategy.Resolve && + this.entryPointStrategy !== EntryPointStrategy.Expand ) { this.logger.error( - "The packages option of entryPointStrategy is not supported in watch mode." + "entryPointStrategy must be set to either resolve or expand for watch mode." ); return; } const tsconfigFile = - TSConfigReader.findConfigFile(this.options.getValue("tsconfig")) ?? + findTsConfigFile(this.options.getValue("tsconfig")) ?? "tsconfig.json"; // We don't want to do it the first time to preserve initial debug status messages. They'll be lost @@ -417,7 +433,12 @@ export class Application extends ChildableComponent< const checks = this.options.getValue("validation"); const start = Date.now(); - if (checks.notExported) { + // No point in validating exports when merging. Warnings will have already been emitted when + // creating the project jsons that this run merges together. + if ( + checks.notExported && + this.entryPointStrategy !== EntryPointStrategy.Merge + ) { validateExports( project, this.logger, @@ -463,7 +484,7 @@ export class Application extends ChildableComponent< } /** - * Run the converter for the given set of files and write the reflections to a json file. + * Write the reflections to a json file. * * @param out The path and file name of the target file. * @returns Whether the JSON file could be written successfully. @@ -474,14 +495,7 @@ export class Application extends ChildableComponent< ): Promise { const start = Date.now(); out = Path.resolve(out); - const eventData = { - outputDirectory: Path.dirname(out), - outputFile: Path.basename(out), - }; - const ser = this.serializer.projectToObject(project, { - begin: eventData, - end: eventData, - }); + const ser = this.serializer.projectToObject(project, process.cwd()); const space = this.options.getValue("pretty") ? "\t" : ""; await writeFile(out, JSON.stringify(ser, null, space)); @@ -500,4 +514,107 @@ export class Application extends ChildableComponent< "", ].join("\n"); } + + private _convertPackages(): ProjectReflection | undefined { + const packageDirs = getPackageDirectories( + this.logger, + this.options, + this.options.getValue("entryPoints") + ); + + if (packageDirs.length === 0) { + this.logger.error( + "Failed to find any packages, ensure you have provided at least one directory as an entry point containing package.json" + ); + return; + } + + const origOptions = this.options; + const projects: JSONOutput.ProjectReflection[] = []; + + // Generate a json file for each package + for (const dir of packageDirs) { + this.logger.info(`Converting project at ${nicePath(dir)}`); + const opts = origOptions.copyForPackage(); + // Invalid links should only be reported after everything has been merged. + opts.setValue("validation", { invalidLink: false }); + opts.read(this.logger, dir); + if ( + opts.getValue("entryPointStrategy") === + EntryPointStrategy.Packages + ) { + this.logger.error( + `Project at ${nicePath( + dir + )} has entryPointStrategy set to packages, but nested packages are not supported.` + ); + continue; + } + + this.options = opts; + const project = this.convert(); + if (project) { + projects.push( + this.serializer.projectToObject(project, process.cwd()) + ); + } + + resetReflectionID(); + } + + this.options = origOptions; + + this.logger.info(`Merging converted projects`); + if (projects.length !== packageDirs.length) { + this.logger.error( + "Failed to convert one or more packages, result will not be merged together." + ); + return; + } + + const result = this.deserializer.reviveProjects( + this.options.getValue("name") || "Documentation", + projects + ); + this.trigger(ApplicationEvents.REVIVE, result); + return result; + } + + private _merge(): ProjectReflection | undefined { + const start = Date.now(); + + const rootDir = getCommonDirectory(this.entryPoints); + const entryPoints = this.entryPoints.flatMap((entry) => + glob(entry, rootDir) + ); + this.logger.verbose( + `Merging entry points:\n\t${entryPoints.map(nicePath).join("\n\t")}` + ); + + if (entryPoints.length < 1) { + this.logger.error("No entry points provided to merge."); + return; + } + + const jsonProjects = entryPoints.map((path) => { + try { + return JSON.parse(readFile(path)); + } catch { + this.logger.error( + `Failed to parse file at ${nicePath(path)} as json.` + ); + return null; + } + }); + if (this.logger.hasErrors()) return; + + const result = this.deserializer.reviveProjects( + this.options.getValue("name"), + jsonProjects + ); + this.logger.verbose(`Reviving projects took ${Date.now() - start}ms`); + + this.trigger(ApplicationEvents.REVIVE, result); + return result; + } } diff --git a/src/lib/cli.ts b/src/lib/cli.ts new file mode 100644 index 000000000..e80cbf95f --- /dev/null +++ b/src/lib/cli.ts @@ -0,0 +1,131 @@ +/* eslint-disable no-console */ + +const ExitCodes = { + Ok: 0, + OptionError: 1, + CompileError: 3, + ValidationError: 4, + OutputError: 5, + ExceptionThrown: 6, +}; + +import * as td from "typedoc"; + +const app = new td.Application(); + +app.options.addReader(new td.ArgumentsReader(0)); +app.options.addReader(new td.TypeDocReader()); +app.options.addReader(new td.PackageJsonReader()); +app.options.addReader(new td.TSConfigReader()); +app.options.addReader(new td.ArgumentsReader(300)); + +void run(app) + .catch((error) => { + console.error("TypeDoc exiting with unexpected error:"); + console.error(error); + if (app.options.getValue("skipErrorChecking")) { + console.error( + "Try turning off --skipErrorChecking. If TypeDoc still crashes, please report a bug." + ); + } + return ExitCodes.ExceptionThrown; + }) + .then((exitCode) => { + process.exitCode = exitCode; + }); + +async function run(app: td.Application) { + const start = Date.now(); + + await app.bootstrapWithPlugins(); + + if (app.options.getValue("version")) { + console.log(app.toString()); + return ExitCodes.Ok; + } + + if (app.options.getValue("help")) { + console.log(app.options.getHelp()); + return ExitCodes.Ok; + } + + if (app.options.getValue("showConfig")) { + console.log(app.options.getRawValues()); + return ExitCodes.Ok; + } + + if (app.logger.hasErrors()) { + return ExitCodes.OptionError; + } + if ( + app.options.getValue("treatWarningsAsErrors") && + app.logger.hasWarnings() + ) { + return ExitCodes.OptionError; + } + + if (app.options.getValue("watch")) { + app.convertAndWatch(async (project) => { + const json = app.options.getValue("json"); + + if (!json || app.options.isSet("out")) { + await app.generateDocs(project, app.options.getValue("out")); + } + + if (json) { + await app.generateJson(project, json); + } + }); + return ExitCodes.Ok; + } + + const project = app.convert(); + if (!project) { + return ExitCodes.CompileError; + } + if ( + app.options.getValue("treatWarningsAsErrors") && + app.logger.hasWarnings() + ) { + return ExitCodes.CompileError; + } + + const preValidationWarnCount = app.logger.warningCount; + app.validate(project); + const hadValidationWarnings = + app.logger.warningCount !== preValidationWarnCount; + if (app.logger.hasErrors()) { + return ExitCodes.ValidationError; + } + if ( + hadValidationWarnings && + (app.options.getValue("treatWarningsAsErrors") || + app.options.getValue("treatValidationWarningsAsErrors")) + ) { + return ExitCodes.ValidationError; + } + + if (app.options.getValue("emit") !== "none") { + const json = app.options.getValue("json"); + if (!json || app.options.isSet("out")) { + await app.generateDocs(project, app.options.getValue("out")); + } + + if (json) { + await app.generateJson(project, json); + } + + if (app.logger.hasErrors()) { + return ExitCodes.OutputError; + } + if ( + app.options.getValue("treatWarningsAsErrors") && + app.logger.hasWarnings() + ) { + return ExitCodes.OutputError; + } + } + + app.logger.verbose(`Full run took ${Date.now() - start}ms`); + return ExitCodes.Ok; +} diff --git a/src/lib/converter/comments/blockLexer.ts b/src/lib/converter/comments/blockLexer.ts index 2f26dc4cc..17b3f9fea 100644 --- a/src/lib/converter/comments/blockLexer.ts +++ b/src/lib/converter/comments/blockLexer.ts @@ -1,13 +1,24 @@ +import ts from "typescript"; import { Token, TokenSyntaxKind } from "./lexer"; +import { ReflectionSymbolId } from "../../models/reflections/ReflectionSymbolId"; +import { resolveAliasedSymbol } from "../utils/symbols"; export function* lexBlockComment( file: string, pos = 0, - end = file.length + end = file.length, + jsDoc: ts.JSDoc | undefined = undefined, + checker: ts.TypeChecker | undefined = undefined ): Generator { // Wrapper around our real lex function to collapse adjacent text tokens. let textToken: Token | undefined; - for (const token of lexBlockComment2(file, pos, end)) { + for (const token of lexBlockComment2( + file, + pos, + end, + getLinkTags(jsDoc), + checker + )) { if (token.kind === TokenSyntaxKind.Text) { if (textToken) { textToken.text += token.text; @@ -29,10 +40,33 @@ export function* lexBlockComment( return; } +function getLinkTags( + jsDoc: ts.JSDoc | undefined +): ReadonlyArray { + const result: (ts.JSDocLink | ts.JSDocLinkCode | ts.JSDocLinkPlain)[] = []; + + if (!jsDoc || typeof jsDoc.comment !== "object") return result; + + for (const part of jsDoc.comment) { + switch (part.kind) { + case ts.SyntaxKind.JSDocLink: + case ts.SyntaxKind.JSDocLinkCode: + case ts.SyntaxKind.JSDocLinkPlain: + result.push(part); + } + } + + return result; +} + function* lexBlockComment2( file: string, pos: number, - end: number + end: number, + linkTags: ReadonlyArray< + ts.JSDocLink | ts.JSDocLinkCode | ts.JSDocLinkPlain + >, + checker: ts.TypeChecker | undefined ): Generator { pos += 2; // Leading '/*' end -= 2; // Trailing '*/' @@ -57,6 +91,7 @@ function* lexBlockComment2( let lineStart = true; let braceStartsType = false; + let linkTagIndex = 0; for (;;) { if (pos >= end) { @@ -206,7 +241,12 @@ function* lexBlockComment2( (lookahead === end || /\s/.test(file[lookahead])) ) { braceStartsType = true; - yield makeToken(TokenSyntaxKind.Tag, lookahead - pos); + const token = makeToken( + TokenSyntaxKind.Tag, + lookahead - pos + ); + attachLinkTagResult(token); + yield token; break; } } @@ -263,6 +303,33 @@ function* lexBlockComment2( } } + function attachLinkTagResult(token: Token) { + // We might need to skip link tags if someone has link tags inside of an example comment + // pos-1 for opening brace, TS doesn't allow spaces between opening brace and @ sign as of 5.0.2 + while ( + linkTagIndex < linkTags.length && + linkTags[linkTagIndex].pos < token.pos - 1 + ) { + linkTagIndex++; + } + + if ( + linkTagIndex < linkTags.length && + linkTags[linkTagIndex].pos === token.pos - 1 + ) { + const link = linkTags[linkTagIndex]; + if (link.name) { + const tsTarget = checker?.getSymbolAtLocation(link.name); + if (tsTarget) { + token.tsLinkTarget = new ReflectionSymbolId( + resolveAliasedSymbol(tsTarget, checker!) + ); + token.tsLinkText = link.text.replace(/^\s*\|\s*/, ""); + } + } + } + } + function makeToken(kind: TokenSyntaxKind, size: number): Token { const start = pos; pos += size; diff --git a/src/lib/converter/comments/declarationReferenceResolver.ts b/src/lib/converter/comments/declarationReferenceResolver.ts index a82a92685..ca9e96a21 100644 --- a/src/lib/converter/comments/declarationReferenceResolver.ts +++ b/src/lib/converter/comments/declarationReferenceResolver.ts @@ -2,6 +2,7 @@ import { ok } from "assert"; import { ContainerReflection, DeclarationReflection, + ReferenceReflection, Reflection, ReflectionKind, } from "../../models"; @@ -13,6 +14,13 @@ import type { MeaningKeyword, } from "./declarationReference"; +function resolveReferenceReflection(ref: Reflection): Reflection { + if (ref instanceof ReferenceReflection) { + return ref.getTargetReflectionDeep(); + } + return ref; +} + export function resolveDeclarationReference( reflection: Reflection, ref: DeclarationReference @@ -76,14 +84,14 @@ export function resolveDeclarationReference( for (const refl of high2) { const resolved = resolveSymbolReferencePart(refl, part); - high.push(...resolved.high); - low.push(...resolved.low); + high.push(...resolved.high.map(resolveReferenceReflection)); + low.push(...resolved.low.map(resolveReferenceReflection)); } for (const refl of low2) { const resolved = resolveSymbolReferencePart(refl, part); - low.push(...resolved.high); - low.push(...resolved.low); + low.push(...resolved.high.map(resolveReferenceReflection)); + low.push(...resolved.low.map(resolveReferenceReflection)); } } @@ -103,7 +111,7 @@ function filterMapByMeaning( return filterMap(reflections, (refl): Reflection | undefined => { const kwResolved = resolveKeyword(refl, meaning.keyword) || []; if (meaning.label) { - return kwResolved.find((r) => r.label === meaning.label); + return kwResolved.find((r) => r.comment?.label === meaning.label); } return kwResolved[meaning.index || 0]; }); diff --git a/src/lib/converter/comments/discovery.ts b/src/lib/converter/comments/discovery.ts index 0afac4d95..51a4079f1 100644 --- a/src/lib/converter/comments/discovery.ts +++ b/src/lib/converter/comments/discovery.ts @@ -5,6 +5,20 @@ import { CommentStyle } from "../../utils/options/declaration"; import { nicePath } from "../../utils/paths"; import { ok } from "assert"; +const variablePropertyKinds = [ + ts.SyntaxKind.PropertyDeclaration, + ts.SyntaxKind.PropertySignature, + ts.SyntaxKind.BinaryExpression, + ts.SyntaxKind.PropertyAssignment, + // class X { constructor(/** Comment */ readonly z: string) } + ts.SyntaxKind.Parameter, + // Variable values + ts.SyntaxKind.VariableDeclaration, + ts.SyntaxKind.BindingElement, + ts.SyntaxKind.ExportAssignment, + ts.SyntaxKind.PropertyAccessExpression, +]; + // Note: This does NOT include JSDoc syntax kinds. This is important! // Comments from @typedef and @callback tags are handled specially by // the JSDoc converter because we only want part of the comment when @@ -18,6 +32,11 @@ const wantedKinds: Record = { ts.SyntaxKind.BindingElement, ts.SyntaxKind.ExportSpecifier, ts.SyntaxKind.NamespaceExport, + // @namespace support + ts.SyntaxKind.VariableDeclaration, + ts.SyntaxKind.BindingElement, + ts.SyntaxKind.ExportAssignment, + ts.SyntaxKind.PropertyAccessExpression, ], [ReflectionKind.Enum]: [ ts.SyntaxKind.EnumDeclaration, @@ -29,12 +48,7 @@ const wantedKinds: Record = { ts.SyntaxKind.PropertyAssignment, ts.SyntaxKind.PropertySignature, ], - [ReflectionKind.Variable]: [ - ts.SyntaxKind.VariableDeclaration, - ts.SyntaxKind.BindingElement, - ts.SyntaxKind.ExportAssignment, - ts.SyntaxKind.PropertyAccessExpression, - ], + [ReflectionKind.Variable]: variablePropertyKinds, [ReflectionKind.Function]: [ ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.BindingElement, @@ -46,16 +60,12 @@ const wantedKinds: Record = { ts.SyntaxKind.ClassDeclaration, ts.SyntaxKind.BindingElement, ], - [ReflectionKind.Interface]: [ts.SyntaxKind.InterfaceDeclaration], - [ReflectionKind.Constructor]: [ts.SyntaxKind.Constructor], - [ReflectionKind.Property]: [ - ts.SyntaxKind.PropertyDeclaration, - ts.SyntaxKind.PropertySignature, - ts.SyntaxKind.BinaryExpression, - ts.SyntaxKind.PropertyAssignment, - // class X { constructor(/** Comment */ readonly z: string) } - ts.SyntaxKind.Parameter, + [ReflectionKind.Interface]: [ + ts.SyntaxKind.InterfaceDeclaration, + ts.SyntaxKind.TypeAliasDeclaration, ], + [ReflectionKind.Constructor]: [ts.SyntaxKind.Constructor], + [ReflectionKind.Property]: variablePropertyKinds, [ReflectionKind.Method]: [ ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.MethodDeclaration, @@ -85,17 +95,23 @@ const wantedKinds: Record = { ], }; +export interface DiscoveredComment { + file: ts.SourceFile; + ranges: ts.CommentRange[]; + jsDoc: ts.JSDoc | undefined; +} + export function discoverComment( symbol: ts.Symbol, kind: ReflectionKind, logger: Logger, commentStyle: CommentStyle -): [ts.SourceFile, ts.CommentRange[]] | undefined { +): DiscoveredComment | undefined { // For a module comment, we want the first one defined in the file, // not the last one, since that will apply to the import or declaration. const reverse = !symbol.declarations?.some(ts.isSourceFile); - const discovered: [ts.SourceFile, ts.CommentRange[]][] = []; + const discovered: DiscoveredComment[] = []; for (const decl of symbol.declarations || []) { const text = decl.getSourceFile().text; @@ -135,7 +151,11 @@ export function discoverComment( ); if (selectedDocComment) { - discovered.push([decl.getSourceFile(), selectedDocComment]); + discovered.push({ + file: decl.getSourceFile(), + ranges: selectedDocComment, + jsDoc: findJsDocForComment(node, selectedDocComment), + }); } } } @@ -149,9 +169,10 @@ export function discoverComment( logger.warn( `${symbol.name} has multiple declarations with a comment. An arbitrary comment will be used.` ); - const locations = discovered.map(([sf, [{ pos }]]) => { - const path = nicePath(sf.fileName); - const line = ts.getLineAndCharacterOfPosition(sf, pos).line + 1; + const locations = discovered.map(({ file, ranges: [{ pos }] }) => { + const path = nicePath(file.fileName); + const line = + ts.getLineAndCharacterOfPosition(file, pos).line + 1; return `${path}:${line}`; }); logger.info( @@ -167,7 +188,7 @@ export function discoverComment( export function discoverSignatureComment( declaration: ts.SignatureDeclaration | ts.JSDocSignature, commentStyle: CommentStyle -): [ts.SourceFile, ts.CommentRange[]] | undefined { +): DiscoveredComment | undefined { const node = declarationToCommentNode(declaration); if (!node) { return; @@ -177,16 +198,17 @@ export function discoverSignatureComment( const comment = node.parent.parent; ok(ts.isJSDoc(comment)); - return [ - node.getSourceFile(), - [ + return { + file: node.getSourceFile(), + ranges: [ { kind: ts.SyntaxKind.MultiLineCommentTrivia, pos: comment.pos, end: comment.end, }, ], - ]; + jsDoc: comment, + }; } const text = node.getSourceFile().text; @@ -200,7 +222,24 @@ export function discoverSignatureComment( permittedRange(text, ranges, commentStyle) ); if (comment) { - return [node.getSourceFile(), comment]; + return { + file: node.getSourceFile(), + ranges: comment, + jsDoc: findJsDocForComment(node, comment), + }; + } +} + +function findJsDocForComment( + node: ts.Node, + ranges: ts.CommentRange[] +): ts.JSDoc | undefined { + if (ranges[0].kind === ts.SyntaxKind.MultiLineCommentTrivia) { + const jsDocs = ts + .getJSDocCommentsAndTags(node) + .map((doc) => ts.findAncestor(doc, ts.isJSDoc)) as ts.JSDoc[]; + + return jsDocs.find((doc) => doc.pos === ranges[0].pos); } } diff --git a/src/lib/converter/comments/index.ts b/src/lib/converter/comments/index.ts index 7ca407e94..5d8e3d518 100644 --- a/src/lib/converter/comments/index.ts +++ b/src/lib/converter/comments/index.ts @@ -1,9 +1,16 @@ import ts from "typescript"; import { Comment, ReflectionKind } from "../../models"; import { assertNever, Logger } from "../../utils"; -import type { CommentStyle } from "../../utils/options/declaration"; +import type { + CommentStyle, + JsDocCompatibility, +} from "../../utils/options/declaration"; import { lexBlockComment } from "./blockLexer"; -import { discoverComment, discoverSignatureComment } from "./discovery"; +import { + DiscoveredComment, + discoverComment, + discoverSignatureComment, +} from "./discovery"; import { lexLineComments } from "./lineLexer"; import { parseComment } from "./parser"; @@ -11,6 +18,7 @@ export interface CommentParserConfig { blockTags: Set; inlineTags: Set; modifierTags: Set; + jsDocCompatibility: JsDocCompatibility; } const jsDocCommentKinds = [ @@ -21,16 +29,24 @@ const jsDocCommentKinds = [ ts.SyntaxKind.JSDocEnumTag, ]; -const commentCache = new WeakMap>(); +let commentCache = new WeakMap>(); + +// We need to do this for tests so that changing the tsLinkResolution option +// actually works. Without it, we'd get the old parsed comment which doesn't +// have the TS symbols attached. +export function clearCommentCache() { + commentCache = new WeakMap(); +} function getCommentWithCache( - discovered: [ts.SourceFile, ts.CommentRange[]] | undefined, + discovered: DiscoveredComment | undefined, config: CommentParserConfig, - logger: Logger + logger: Logger, + checker: ts.TypeChecker | undefined ) { if (!discovered) return; - const [file, ranges] = discovered; + const { file, ranges, jsDoc } = discovered; const cache = commentCache.get(file) || new Map(); if (cache?.has(ranges[0].pos)) { return cache.get(ranges[0].pos)!.clone(); @@ -40,7 +56,13 @@ function getCommentWithCache( switch (ranges[0].kind) { case ts.SyntaxKind.MultiLineCommentTrivia: comment = parseComment( - lexBlockComment(file.text, ranges[0].pos, ranges[0].end), + lexBlockComment( + file.text, + ranges[0].pos, + ranges[0].end, + jsDoc, + checker + ), config, file, logger @@ -65,12 +87,13 @@ function getCommentWithCache( } function getCommentImpl( - commentSource: [ts.SourceFile, ts.CommentRange[]] | undefined, + commentSource: DiscoveredComment | undefined, config: CommentParserConfig, logger: Logger, - moduleComment: boolean + moduleComment: boolean, + checker: ts.TypeChecker | undefined ) { - const comment = getCommentWithCache(commentSource, config, logger); + const comment = getCommentWithCache(commentSource, config, logger, checker); if (moduleComment && comment) { // Module comment, make sure it is tagged with @packageDocumentation or @module. @@ -101,7 +124,8 @@ export function getComment( kind: ReflectionKind, config: CommentParserConfig, logger: Logger, - commentStyle: CommentStyle + commentStyle: CommentStyle, + checker: ts.TypeChecker | undefined ): Comment | undefined { const declarations = symbol.declarations || []; @@ -112,7 +136,8 @@ export function getComment( return getJsDocComment( declarations[0] as ts.JSDocPropertyLikeTag, config, - logger + logger, + checker ); } @@ -120,7 +145,8 @@ export function getComment( discoverComment(symbol, kind, logger, commentStyle), config, logger, - declarations.some(ts.isSourceFile) + declarations.some(ts.isSourceFile), + checker ); if (!comment && kind === ReflectionKind.Property) { @@ -128,7 +154,8 @@ export function getComment( symbol, config, logger, - commentStyle + commentStyle, + checker ); } @@ -139,13 +166,20 @@ function getConstructorParamPropertyComment( symbol: ts.Symbol, config: CommentParserConfig, logger: Logger, - commentStyle: CommentStyle + commentStyle: CommentStyle, + checker: ts.TypeChecker | undefined ): Comment | undefined { const decl = symbol.declarations?.find(ts.isParameter); if (!decl) return; const ctor = decl.parent; - const comment = getSignatureComment(ctor, config, logger, commentStyle); + const comment = getSignatureComment( + ctor, + config, + logger, + commentStyle, + checker + ); const paramTag = comment?.getIdentifiedTag(symbol.name, "@param"); if (paramTag) { @@ -157,13 +191,15 @@ export function getSignatureComment( declaration: ts.SignatureDeclaration | ts.JSDocSignature, config: CommentParserConfig, logger: Logger, - commentStyle: CommentStyle + commentStyle: CommentStyle, + checker: ts.TypeChecker | undefined ): Comment | undefined { return getCommentImpl( discoverSignatureComment(declaration, commentStyle), config, logger, - false + false, + checker ); } @@ -175,7 +211,8 @@ export function getJsDocComment( | ts.JSDocTemplateTag | ts.JSDocEnumTag, config: CommentParserConfig, - logger: Logger + logger: Logger, + checker: ts.TypeChecker | undefined ): Comment | undefined { const file = declaration.getSourceFile(); @@ -187,18 +224,20 @@ export function getJsDocComment( // Then parse it. const comment = getCommentWithCache( - [ + { file, - [ + ranges: [ { kind: ts.SyntaxKind.MultiLineCommentTrivia, pos: parent.pos, end: parent.end, }, ], - ], + jsDoc: parent, + }, config, - logger + logger, + checker )!; // And pull out the tag we actually care about. diff --git a/src/lib/converter/comments/lexer.ts b/src/lib/converter/comments/lexer.ts index 0aa90e2e2..8905d87ec 100644 --- a/src/lib/converter/comments/lexer.ts +++ b/src/lib/converter/comments/lexer.ts @@ -1,3 +1,5 @@ +import type { ReflectionSymbolId } from "../../models"; + export enum TokenSyntaxKind { Text = "text", NewLine = "new_line", @@ -13,4 +15,8 @@ export interface Token { text: string; pos: number; + + // These come from the compiler for use if useTsLinkResolution is on + tsLinkTarget?: ReflectionSymbolId; + tsLinkText?: string; } diff --git a/src/lib/converter/comments/linkResolver.ts b/src/lib/converter/comments/linkResolver.ts index daa69f4d0..089622792 100644 --- a/src/lib/converter/comments/linkResolver.ts +++ b/src/lib/converter/comments/linkResolver.ts @@ -5,8 +5,8 @@ import { DeclarationReflection, InlineTagDisplayPart, Reflection, + ReflectionSymbolId, } from "../../models"; -import type { Logger, ValidationOptions } from "../../utils"; import { DeclarationReference, parseDeclarationReference, @@ -14,47 +14,38 @@ import { import { resolveDeclarationReference } from "./declarationReferenceResolver"; const urlPrefix = /^(http|ftp)s?:\/\//; -const brackets = /\[\[(?!include:)([^\]]+)\]\]/g; export type ExternalResolveResult = { target: string; caption?: string }; + +/** + * @param ref - Parsed declaration reference to resolve. This may be created automatically for some symbol, or + * parsed from user input. + * @param refl - Reflection that contains the resolved link + * @param part - If the declaration reference was created from a comment, the originating part. + * @param symbolId - If the declaration reference was created from a symbol, or `useTsLinkResolution` is turned + * on and TypeScript resolved the link to some symbol, the ID of that symbol. + */ export type ExternalSymbolResolver = ( ref: DeclarationReference, refl: Reflection, - part: Readonly | undefined + part: Readonly | undefined, + symbolId: ReflectionSymbolId | undefined ) => ExternalResolveResult | string | undefined; export function resolveLinks( comment: Comment, reflection: Reflection, - validation: ValidationOptions, - logger: Logger, externalResolver: ExternalSymbolResolver ) { - let warned = false; - const warn = () => { - if (!warned) { - warned = true; - logger.warn( - `${reflection.getFriendlyFullName()}: Comment [[target]] style links are deprecated and will be removed in 0.24` - ); - } - }; - comment.summary = resolvePartLinks( reflection, comment.summary, - warn, - validation, - logger, externalResolver ); for (const tag of comment.blockTags) { tag.content = resolvePartLinks( reflection, tag.content, - warn, - validation, - logger, externalResolver ); } @@ -63,9 +54,6 @@ export function resolveLinks( reflection.readme = resolvePartLinks( reflection, reflection.readme, - warn, - validation, - logger, externalResolver ); } @@ -74,52 +62,25 @@ export function resolveLinks( export function resolvePartLinks( reflection: Reflection, parts: readonly CommentDisplayPart[], - warn: () => void, - validation: ValidationOptions, - logger: Logger, externalResolver: ExternalSymbolResolver ): CommentDisplayPart[] { return parts.flatMap((part) => - processPart( - reflection, - part, - warn, - validation, - logger, - externalResolver - ) + processPart(reflection, part, externalResolver) ); } function processPart( reflection: Reflection, part: CommentDisplayPart, - warn: () => void, - validation: ValidationOptions, - logger: Logger, externalResolver: ExternalSymbolResolver ): CommentDisplayPart | CommentDisplayPart[] { - if (part.kind === "text" && brackets.test(part.text)) { - warn(); - return replaceBrackets(reflection, part.text, validation, logger); - } - if (part.kind === "inline-tag") { if ( part.tag === "@link" || part.tag === "@linkcode" || part.tag === "@linkplain" ) { - return resolveLinkTag( - reflection, - part, - externalResolver, - (msg: string) => { - if (validation.invalidLink) { - logger.warn(msg); - } - } - ); + return resolveLinkTag(reflection, part, externalResolver); } } @@ -129,22 +90,58 @@ function processPart( function resolveLinkTag( reflection: Reflection, part: InlineTagDisplayPart, - externalResolver: ExternalSymbolResolver, - warn: (message: string) => void -) { + externalResolver: ExternalSymbolResolver +): InlineTagDisplayPart { + let defaultDisplayText = ""; let pos = 0; const end = part.text.length; while (pos < end && ts.isWhiteSpaceLike(part.text.charCodeAt(pos))) { pos++; } - const origText = part.text; - // Try to parse one + let target: Reflection | string | undefined; + // Try to parse a declaration reference if we didn't use the TS symbol for resolution const declRef = parseDeclarationReference(part.text, pos, end); - let target: Reflection | string | undefined; - let defaultDisplayText = ""; - if (declRef) { + // Might already know where it should go if useTsLinkResolution is turned on + if (part.target instanceof ReflectionSymbolId) { + const tsTarget = reflection.project.getReflectionFromSymbolId( + part.target + ); + + if (tsTarget) { + target = tsTarget; + pos = end; + defaultDisplayText = part.tsLinkText || target.name; + } else if (declRef) { + // If we didn't find a target, we might be pointing to a symbol in another project that will be merged in + // or some external symbol, so ask external resolvers to try resolution. Don't use regular declaration ref + // resolution in case it matches something that would have been merged in later. + + const externalResolveResult = externalResolver( + declRef[0], + reflection, + part, + part.target instanceof ReflectionSymbolId + ? part.target + : undefined + ); + + defaultDisplayText = part.text.substring(0, pos); + + switch (typeof externalResolveResult) { + case "string": + target = externalResolveResult; + break; + case "object": + target = externalResolveResult.target; + defaultDisplayText = + externalResolveResult.caption || defaultDisplayText; + } + } + } + + if (!target && declRef) { // Got one, great! Try to resolve the link target = resolveDeclarationReference(reflection, declRef[0]); pos = declRef[1]; @@ -156,7 +153,10 @@ function resolveLinkTag( const externalResolveResult = externalResolver( declRef[0], reflection, - part + part, + part.target instanceof ReflectionSymbolId + ? part.target + : undefined ); defaultDisplayText = part.text.substring(0, pos); @@ -173,26 +173,11 @@ function resolveLinkTag( } } - if (!target) { - if (urlPrefix.test(part.text)) { - const wsIndex = part.text.search(/\s/); - target = - wsIndex === -1 ? part.text : part.text.substring(0, wsIndex); - pos = target.length; - defaultDisplayText = target; - } - } - - // If resolution via a declaration reference failed, revert to the legacy "split and check" - // method... this should go away in 0.24, once people have had a chance to migrate any failing links. - if (!target) { - const resolved = legacyResolveLinkTag(reflection, part); - if (resolved.target) { - warn( - `Failed to resolve {@link ${origText}} in ${reflection.getFriendlyFullName()} with declaration references. This link will break in v0.24.` - ); - } - return resolved; + if (!target && urlPrefix.test(part.text)) { + const wsIndex = part.text.search(/\s/); + target = wsIndex === -1 ? part.text : part.text.substring(0, wsIndex); + pos = target.length; + defaultDisplayText = target; } // Remaining text after an optional pipe is the link text, so advance @@ -204,115 +189,13 @@ function resolveLinkTag( pos++; } + if (!target) { + return part; + } + part.target = target; part.text = part.text.substring(pos).trim() || defaultDisplayText || part.text; return part; } - -function legacyResolveLinkTag( - reflection: Reflection, - part: InlineTagDisplayPart -) { - const { caption, target } = splitLinkText(part.text); - - if (urlPrefix.test(target)) { - part.text = caption; - part.target = target; - } else { - const targetRefl = reflection.findReflectionByName(target); - if (targetRefl) { - part.text = caption; - part.target = targetRefl; - } - } - - return part; -} - -function replaceBrackets( - reflection: Reflection, - text: string, - validation: ValidationOptions, - logger: Logger -): CommentDisplayPart[] { - const parts: CommentDisplayPart[] = []; - - let begin = 0; - brackets.lastIndex = 0; - for (const match of text.matchAll(brackets)) { - if (begin != match.index) { - parts.push({ - kind: "text", - text: text.substring(begin, match.index), - }); - } - begin = match.index! + match[0].length; - const content = match[1]; - - const { target, caption } = splitLinkText(content); - - if (urlPrefix.test(target)) { - parts.push({ - kind: "inline-tag", - tag: "@link", - text: caption, - target, - }); - } else { - const targetRefl = reflection.findReflectionByName(target); - if (targetRefl) { - parts.push({ - kind: "inline-tag", - tag: "@link", - text: caption, - target: targetRefl, - }); - } else { - if (validation.invalidLink) { - logger.warn("Failed to find target: " + content); - } - parts.push({ - kind: "inline-tag", - tag: "@link", - text: content, - }); - } - } - } - parts.push({ - kind: "text", - text: text.substring(begin), - }); - - return parts; -} - -/** - * Split the given link into text and target at first pipe or space. - * - * @param text The source string that should be checked for a split character. - * @returns An object containing the link text and target. - */ -function splitLinkText(text: string): { caption: string; target: string } { - let splitIndex = text.indexOf("|"); - if (splitIndex === -1) { - splitIndex = text.search(/\s/); - } - - if (splitIndex !== -1) { - return { - caption: text - .substring(splitIndex + 1) - .replace(/\n+/, " ") - .trim(), - target: text.substring(0, splitIndex).trim(), - }; - } else { - return { - caption: text, - target: text, - }; - } -} diff --git a/src/lib/converter/comments/parser.ts b/src/lib/converter/comments/parser.ts index 7ed3564d5..a29d98f5b 100644 --- a/src/lib/converter/comments/parser.ts +++ b/src/lib/converter/comments/parser.ts @@ -1,6 +1,11 @@ import { ok } from "assert"; import type { CommentParserConfig } from "."; -import { Comment, CommentDisplayPart, CommentTag } from "../../models"; +import { + Comment, + CommentDisplayPart, + CommentTag, + InlineTagDisplayPart, +} from "../../models"; import { assertNever, Logger, removeIf } from "../../utils"; import type { MinimalSourceFile } from "../../utils/minimalSourceFile"; import { nicePath } from "../../utils/paths"; @@ -200,8 +205,10 @@ function blockTag( const tagName = aliasedTags.get(blockTag.text) || blockTag.text; let content: CommentDisplayPart[]; - if (tagName === "@example") { + if (tagName === "@example" && config.jsDocCompatibility.exampleTag) { content = exampleBlockContent(comment, lexer, config, warning); + } else if (tagName === "@default" && config.jsDocCompatibility.defaultTag) { + content = defaultBlockContent(comment, lexer, config, warning); } else { content = blockContent(comment, lexer, config, warning); } @@ -209,9 +216,46 @@ function blockTag( return new CommentTag(tagName as `@${string}`, content); } +/** + * The `@default` tag gets a special case because otherwise we will produce many warnings + * about unescaped/mismatched/missing braces in legacy JSDoc comments + */ +function defaultBlockContent( + comment: Comment, + lexer: LookaheadGenerator, + config: CommentParserConfig, + warning: (msg: string, token: Token) => void +): CommentDisplayPart[] { + lexer.mark(); + const content = blockContent(comment, lexer, config, () => {}); + const end = lexer.done() || lexer.peek(); + lexer.release(); + + if (content.some((part) => part.kind === "code")) { + return blockContent(comment, lexer, config, warning); + } + + const tokens: Token[] = []; + while ((lexer.done() || lexer.peek()) !== end) { + tokens.push(lexer.take()); + } + + const blockText = tokens + .map((tok) => tok.text) + .join("") + .trim(); + + return [ + { + kind: "code", + text: makeCodeBlock(blockText), + }, + ]; +} + /** * The `@example` tag gets a special case because otherwise we will produce many warnings - * about unescaped/mismatched/missing braces + * about unescaped/mismatched/missing braces in legacy JSDoc comments. */ function exampleBlockContent( comment: Comment, @@ -431,9 +475,14 @@ function inlineTag( lexer.take(); // Close brace } - block.push({ + const inlineTag: InlineTagDisplayPart = { kind: "inline-tag", tag: tagName.text as `@${string}`, text: content.join(""), - }); + }; + if (tagName.tsLinkTarget) { + inlineTag.target = tagName.tsLinkTarget; + inlineTag.tsLinkText = tagName.tsLinkText; + } + block.push(inlineTag); } diff --git a/src/lib/converter/context.ts b/src/lib/converter/context.ts index 25bcb11a8..3616084f4 100644 --- a/src/lib/converter/context.ts +++ b/src/lib/converter/context.ts @@ -14,7 +14,8 @@ import type { Converter } from "./converter"; import { isNamedNode } from "./utils/nodes"; import { ConverterEvents } from "./converter-events"; import { resolveAliasedSymbol } from "./utils/symbols"; -import { getComment } from "./comments"; +import { getComment, getJsDocComment, getSignatureComment } from "./comments"; +import { getHumanName } from "../utils/tsutils"; /** * The context describes the current state the converter is in. @@ -60,20 +61,9 @@ export class Context { */ readonly scope: Reflection; - /** @internal */ - isConvertingTypeNode(): boolean { - return this.convertingTypeNode; - } - - /** @internal */ - setConvertingTypeNode() { - this.convertingTypeNode = true; - } - - /** @internal */ - shouldBeStatic = false; - - private convertingTypeNode = false; + convertingTypeNode = false; // Inherited by withScope + convertingClassOrInterface = false; // Not inherited + shouldBeStatic = false; // Not inherited /** * Create a new Context instance. @@ -99,13 +89,6 @@ export class Context { return this.converter.application.logger; } - /** - * Return the compiler options. - */ - getCompilerOptions(): ts.CompilerOptions { - return this.converter.application.options.getCompilerOptions(); - } - /** * Return the type declaration of the given node. * @@ -174,6 +157,15 @@ export class Context { nameOverride ?? exportSymbol?.name ?? symbol?.name ?? "unknown" ); + if (this.convertingClassOrInterface) { + if (kind === ReflectionKind.Function) { + kind = ReflectionKind.Method; + } + if (kind === ReflectionKind.Variable) { + kind = ReflectionKind.Property; + } + } + const reflection = new DeclarationReflection(name, kind, this.scope); this.postReflectionCreation(reflection, symbol, exportSymbol); @@ -190,22 +182,10 @@ export class Context { reflection.kind & (ReflectionKind.SomeModule | ReflectionKind.Reference) ) { - reflection.comment = getComment( - exportSymbol, - reflection.kind, - this.converter.config, - this.logger, - this.converter.commentStyle - ); + reflection.comment = this.getComment(exportSymbol, reflection.kind); } if (symbol && !reflection.comment) { - reflection.comment = getComment( - symbol, - reflection.kind, - this.converter.config, - this.logger, - this.converter.commentStyle - ); + reflection.comment = this.getComment(symbol, reflection.kind); } if (this.shouldBeStatic) { @@ -274,6 +254,45 @@ export class Context { this._program = program; } + getComment(symbol: ts.Symbol, kind: ReflectionKind) { + return getComment( + symbol, + kind, + this.converter.config, + this.logger, + this.converter.commentStyle, + this.converter.useTsLinkResolution ? this.checker : undefined + ); + } + + getJsDocComment( + declaration: + | ts.JSDocPropertyLikeTag + | ts.JSDocCallbackTag + | ts.JSDocTypedefTag + | ts.JSDocTemplateTag + | ts.JSDocEnumTag + ) { + return getJsDocComment( + declaration, + this.converter.config, + this.logger, + this.converter.useTsLinkResolution ? this.checker : undefined + ); + } + + getSignatureComment( + declaration: ts.SignatureDeclaration | ts.JSDocSignature + ) { + return getSignatureComment( + declaration, + this.converter.config, + this.logger, + this.converter.commentStyle, + this.converter.useTsLinkResolution ? this.checker : undefined + ); + } + /** * @param callback The callback function that should be executed with the changed context. */ @@ -289,14 +308,3 @@ export class Context { return context; } } - -const uniqueSymbolRegExp = /^__@(.*)@\d+$/; - -function getHumanName(name: string) { - const match = uniqueSymbolRegExp.exec(name); - if (match) { - return `[${match[1]}]`; - } - - return name; -} diff --git a/src/lib/converter/converter.ts b/src/lib/converter/converter.ts index d934d6898..8a4dab1ad 100644 --- a/src/lib/converter/converter.ts +++ b/src/lib/converter/converter.ts @@ -7,6 +7,7 @@ import { ProjectReflection, Reflection, ReflectionKind, + ReflectionSymbolId, SomeType, } from "../models/index"; import { Context } from "./context"; @@ -20,7 +21,7 @@ import { createMinimatch, matchesAny } from "../utils/paths"; import type { Minimatch } from "minimatch"; import { hasAllFlags, hasAnyFlag } from "../utils/enum"; import type { DocumentationEntryPoint } from "../utils/entry-point"; -import { CommentParserConfig, getComment } from "./comments"; +import type { CommentParserConfig } from "./comments"; import type { CommentStyle, ValidationOptions, @@ -69,6 +70,10 @@ export class Converter extends ChildableComponent< @BindOption("excludeProtected") excludeProtected!: boolean; + /** @internal */ + @BindOption("excludeReferences") + excludeReferences!: boolean; + /** @internal */ @BindOption("commentStyle") commentStyle!: CommentStyle; @@ -81,6 +86,10 @@ export class Converter extends ChildableComponent< @BindOption("externalSymbolLinkMappings") externalSymbolLinkMappings!: Record>; + /** @internal */ + @BindOption("useTsLinkResolution") + useTsLinkResolution!: boolean; + private _config?: CommentParserConfig; private _externalSymbolResolvers: Array = []; @@ -112,7 +121,7 @@ export class Converter extends ChildableComponent< /** * Triggered when the converter has created a declaration reflection. - * The listener will be given {@link Context} and a {@link DeclarationReflection}. + * The listener will be given {@link Context} and a {@link Models.DeclarationReflection}. * @event */ static readonly EVENT_CREATE_DECLARATION = @@ -120,7 +129,7 @@ export class Converter extends ChildableComponent< /** * Triggered when the converter has created a signature reflection. - * The listener will be given {@link Context}, {@link SignatureReflection} | {@link ProjectReflection} the declaration, + * The listener will be given {@link Context}, {@link Models.SignatureReflection} | {@link Models.ProjectReflection} the declaration, * `ts.SignatureDeclaration | ts.IndexSignatureDeclaration | ts.JSDocSignature | undefined`, * and `ts.Signature | undefined`. The signature will be undefined if the created signature is an index signature. * @event @@ -129,14 +138,14 @@ export class Converter extends ChildableComponent< /** * Triggered when the converter has created a parameter reflection. - * The listener will be given {@link Context}, {@link ParameterReflection} and a `ts.Node?` + * The listener will be given {@link Context}, {@link Models.ParameterReflection} and a `ts.Node?` * @event */ static readonly EVENT_CREATE_PARAMETER = ConverterEvents.CREATE_PARAMETER; /** * Triggered when the converter has created a type parameter reflection. - * The listener will be given {@link Context} and a {@link TypeParameterReflection} + * The listener will be given {@link Context} and a {@link Models.TypeParameterReflection} * @event */ static readonly EVENT_CREATE_TYPE_PARAMETER = @@ -218,10 +227,9 @@ export class Converter extends ChildableComponent< this.compile(entryPoints, context); this.resolve(context); - // This should only do anything if a plugin does something bad. - project.removeDanglingReferences(); this.trigger(Converter.EVENT_END, context); + this._config = undefined; return project; } @@ -283,10 +291,11 @@ export class Converter extends ChildableComponent< resolveExternalLink( ref: DeclarationReference, refl: Reflection, - part?: CommentDisplayPart + part: CommentDisplayPart | undefined, + symbolId: ReflectionSymbolId | undefined ): ExternalResolveResult | string | undefined { for (const resolver of this._externalSymbolResolvers) { - const resolved = resolver(ref, refl, part); + const resolved = resolver(ref, refl, part, symbolId); if (resolved) return resolved; } } @@ -301,31 +310,12 @@ export class Converter extends ChildableComponent< owner: Reflection ): CommentDisplayPart[] | undefined { if (comment instanceof Comment) { - resolveLinks( - comment, - owner, - this.validation, - this.owner.logger, - (ref, part, refl) => this.resolveExternalLink(ref, part, refl) + resolveLinks(comment, owner, (ref, part, refl, id) => + this.resolveExternalLink(ref, part, refl, id) ); } else { - let warned = false; - const warn = () => { - if (!warned) { - warned = true; - this.application.logger.warn( - `${owner.name}: Comment [[target]] style links are deprecated and will be removed in 0.24` - ); - } - }; - - return resolvePartLinks( - owner, - comment, - warn, - this.validation, - this.owner.logger, - (ref, part, refl) => this.resolveExternalLink(ref, part, refl) + return resolvePartLinks(owner, comment, (ref, part, refl, id) => + this.resolveExternalLink(ref, part, refl, id) ); } } @@ -379,14 +369,7 @@ export class Converter extends ChildableComponent< // create modules for each entry. Register the project as this module. context.project.registerReflection(context.project, symbol); context.project.comment = - symbol && - getComment( - symbol, - context.project.kind, - this.config, - this.application.logger, - this.commentStyle - ); + symbol && context.getComment(symbol, context.project.kind); context.trigger( Converter.EVENT_CREATE_DECLARATION, context.project @@ -421,7 +404,7 @@ export class Converter extends ChildableComponent< reflection.readme = comment.summary; } - reflection.version = entryPoint.version; + reflection.packageVersion = entryPoint.version; context.finalizeDeclarationReflection(reflection); moduleContext = context.withScope(reflection); @@ -511,6 +494,8 @@ export class Converter extends ChildableComponent< modifierTags: new Set( this.application.options.getValue("modifierTags") ), + jsDocCompatibility: + this.application.options.getValue("jsDocCompatibility"), }; return this._config; } diff --git a/src/lib/converter/factories/signature.ts b/src/lib/converter/factories/signature.ts index 3d6719957..4566fa27d 100644 --- a/src/lib/converter/factories/signature.ts +++ b/src/lib/converter/factories/signature.ts @@ -17,7 +17,7 @@ import type { Context } from "../context"; import { ConverterEvents } from "../converter-events"; import { convertDefaultValue } from "../convert-expression"; import { removeUndefined } from "../utils/reflections"; -import { getComment, getJsDocComment, getSignatureComment } from "../comments"; +import { ReflectionSymbolId } from "../../models/reflections/ReflectionSymbolId"; export function createSignature( context: Context, @@ -27,6 +27,7 @@ export function createSignature( | ReflectionKind.GetSignature | ReflectionKind.SetSignature, signature: ts.Signature, + symbol: ts.Symbol | undefined, declaration?: ts.SignatureDeclaration | ts.JSDocSignature ) { assert(context.scope instanceof DeclarationReflection); @@ -42,6 +43,13 @@ export function createSignature( kind, context.scope ); + const sigRefCtx = context.withScope(sigRef); + if (symbol && declaration) { + context.project.registerSymbolId( + sigRef, + new ReflectionSymbolId(symbol, declaration) + ); + } // If we are creating signatures for a variable or property and it has a comment associated with it // then we should prefer that comment over any comment on the signature. The comment plugin @@ -62,16 +70,11 @@ export function createSignature( ConversionFlags.VariableOrPropertySource )) ) { - sigRef.comment = getSignatureComment( - declaration, - context.converter.config, - context.logger, - context.converter.commentStyle - ); + sigRef.comment = context.getSignatureComment(declaration); } sigRef.typeParameters = convertTypeParameters( - context, + sigRefCtx, sigRef, signature.typeParameters ); @@ -82,7 +85,7 @@ export function createSignature( : signature.parameters; sigRef.parameters = convertParameters( - context, + sigRefCtx, sigRef, parameterSymbols, declaration?.parameters @@ -90,12 +93,12 @@ export function createSignature( const predicate = context.checker.getTypePredicateOfSignature(signature); if (predicate) { - sigRef.type = convertPredicate(predicate, context.withScope(sigRef)); + sigRef.type = convertPredicate(predicate, sigRefCtx); } else if (kind == ReflectionKind.SetSignature) { sigRef.type = new IntrinsicType("void"); } else { sigRef.type = context.converter.convertType( - context.withScope(sigRef), + sigRefCtx, (declaration?.kind === ts.SyntaxKind.FunctionDeclaration && declaration.type) || signature.getReturnType() @@ -151,19 +154,9 @@ function convertParameters( sigRef ); if (declaration && ts.isJSDocParameterTag(declaration)) { - paramRefl.comment = getJsDocComment( - declaration, - context.converter.config, - context.logger - ); + paramRefl.comment = context.getJsDocComment(declaration); } - paramRefl.comment ||= getComment( - param, - paramRefl.kind, - context.converter.config, - context.logger, - context.converter.commentStyle - ); + paramRefl.comment ||= context.getComment(param, paramRefl.kind); context.registerReflection(paramRefl, param); context.trigger(ConverterEvents.CREATE_PARAMETER, paramRefl); @@ -229,11 +222,7 @@ export function convertParameterNodes( sigRef ); if (ts.isJSDocParameterTag(param)) { - paramRefl.comment = getJsDocComment( - param, - context.converter.config, - context.logger - ); + paramRefl.comment = context.getJsDocComment(param); } context.registerReflection( paramRefl, @@ -275,13 +264,6 @@ function convertTypeParameters( const constraintT = param.getConstraint(); const defaultT = param.getDefault(); - const constraint = constraintT - ? context.converter.convertType(context, constraintT) - : void 0; - const defaultType = defaultT - ? context.converter.convertType(context, defaultT) - : void 0; - // There's no way to determine directly from a ts.TypeParameter what it's variance modifiers are // so unfortunately we have to go back to the node for this... const declaration = param @@ -291,11 +273,17 @@ function convertTypeParameters( const paramRefl = new TypeParameterReflection( param.symbol.name, - constraint, - defaultType, parent, variance ); + const paramCtx = context.withScope(paramRefl); + + paramRefl.type = constraintT + ? context.converter.convertType(paramCtx, constraintT) + : void 0; + paramRefl.default = defaultT + ? context.converter.convertType(paramCtx, defaultT) + : void 0; // No way to determine this from the type parameter itself, need to go back to the declaration if ( @@ -326,19 +314,18 @@ export function createTypeParamReflection( param: ts.TypeParameterDeclaration, context: Context ) { - const constraint = param.constraint - ? context.converter.convertType(context, param.constraint) - : void 0; - const defaultType = param.default - ? context.converter.convertType(context, param.default) - : void 0; const paramRefl = new TypeParameterReflection( param.name.text, - constraint, - defaultType, context.scope, getVariance(param.modifiers) ); + const paramScope = context.withScope(paramRefl); + paramRefl.type = param.constraint + ? context.converter.convertType(paramScope, param.constraint) + : void 0; + paramRefl.default = param.default + ? context.converter.convertType(paramScope, param.default) + : void 0; if (param.modifiers?.some((m) => m.kind === ts.SyntaxKind.ConstKeyword)) { paramRefl.flags.setFlag(ReflectionFlag.Const, true); } @@ -346,11 +333,7 @@ export function createTypeParamReflection( context.registerReflection(paramRefl, param.symbol); if (ts.isJSDocTemplateTag(param.parent)) { - paramRefl.comment = getJsDocComment( - param.parent, - context.converter.config, - context.logger - ); + paramRefl.comment = context.getJsDocComment(param.parent); } context.trigger(ConverterEvents.CREATE_TYPE_PARAMETER, paramRefl, param); diff --git a/src/lib/converter/jsdoc.ts b/src/lib/converter/jsdoc.ts index 04e56a32d..f88bd08bb 100644 --- a/src/lib/converter/jsdoc.ts +++ b/src/lib/converter/jsdoc.ts @@ -11,7 +11,7 @@ import { ReflectionType, SignatureReflection, } from "../models"; -import { getJsDocComment } from "./comments"; +import { ReflectionSymbolId } from "../models/reflections/ReflectionSymbolId"; import type { Context } from "./context"; import { ConverterEvents } from "./converter-events"; import { @@ -51,11 +51,7 @@ export function convertJsDocAlias( symbol, exportSymbol ); - reflection.comment = getJsDocComment( - declaration, - context.converter.config, - context.logger - ); + reflection.comment = context.getJsDocComment(declaration); reflection.type = context.converter.convertType( context.withScope(reflection), @@ -81,11 +77,7 @@ export function convertJsDocCallback( symbol, exportSymbol ); - alias.comment = getJsDocComment( - declaration, - context.converter.config, - context.logger - ); + alias.comment = context.getJsDocComment(declaration); context.finalizeDeclarationReflection(alias); const ac = context.withScope(alias); @@ -105,11 +97,7 @@ function convertJsDocInterface( symbol, exportSymbol ); - reflection.comment = getJsDocComment( - declaration, - context.converter.config, - context.logger - ); + reflection.comment = context.getJsDocComment(declaration); context.finalizeDeclarationReflection(reflection); const rc = context.withScope(reflection); @@ -142,6 +130,10 @@ function convertJsDocSignature(context: Context, node: ts.JSDocSignature) { ReflectionKind.CallSignature, reflection ); + context.project.registerSymbolId( + signature, + new ReflectionSymbolId(symbol, node) + ); context.registerReflection(signature, void 0); const signatureCtx = context.withScope(signature); diff --git a/src/lib/converter/plugins/CommentPlugin.ts b/src/lib/converter/plugins/CommentPlugin.ts index 705deff4c..42e167aa9 100644 --- a/src/lib/converter/plugins/CommentPlugin.ts +++ b/src/lib/converter/plugins/CommentPlugin.ts @@ -122,7 +122,7 @@ export class CommentPlugin extends ConverterComponent { private get excludeNotDocumentedKinds(): number { this._excludeKinds ??= this.application.options .getValue("excludeNotDocumentedKinds") - .reduce((a, b) => a | ReflectionKind[b], 0); + .reduce((a, b) => a | (ReflectionKind[b] as number), 0); return this._excludeKinds; } @@ -149,6 +149,14 @@ export class CommentPlugin extends ConverterComponent { * @param comment The comment that should be searched for modifiers. */ private applyModifiers(reflection: Reflection, comment: Comment) { + if (reflection.kindOf(ReflectionKind.SomeModule)) { + comment.removeModifier("@namespace"); + } + + if (reflection.kindOf(ReflectionKind.Interface)) { + comment.removeModifier("@interface"); + } + if (comment.hasModifier("@private")) { reflection.setFlag(ReflectionFlag.Private); if (reflection.kindOf(ReflectionKind.CallSignature)) { @@ -327,17 +335,20 @@ export class CommentPlugin extends ConverterComponent { */ private onResolve(context: Context, reflection: Reflection) { if (reflection.comment) { - reflection.label = extractLabelTag(reflection.comment); - if (reflection.label && !/[A-Z_][A-Z0-9_]/.test(reflection.label)) { + if ( + reflection.comment.label && + !/[A-Z_][A-Z0-9_]/.test(reflection.comment.label) + ) { context.logger.warn( `The label "${ - reflection.label + reflection.comment.label }" for ${reflection.getFriendlyFullName()} cannot be referenced with a declaration reference. ` + `Labels may only contain A-Z, 0-9, and _, and may not start with a number.` ); } mergeSeeTags(reflection.comment); + movePropertyTags(reflection.comment, reflection); } if (!(reflection instanceof DeclarationReflection)) { @@ -576,15 +587,25 @@ function moveNestedParamTags(comment: Comment, parameter: ParameterReflection) { parameter.type?.visit(visitor); } -function extractLabelTag(comment: Comment): string | undefined { - const index = comment.summary.findIndex( - (part) => part.kind === "inline-tag" && part.tag === "@label" +function movePropertyTags(comment: Comment, container: Reflection) { + const propTags = comment.blockTags.filter( + (tag) => tag.tag === "@prop" || tag.tag === "@property" ); + comment.removeTags("@prop"); + comment.removeTags("@property"); + + for (const prop of propTags) { + if (!prop.name) continue; - if (index !== -1) { - return comment.summary.splice(index, 1)[0].text; + const child = container.getChildByName(prop.name); + if (child) { + child.comment = new Comment( + Comment.cloneDisplayParts(prop.content) + ); + } } } + function mergeSeeTags(comment: Comment) { const see = comment.getTags("@see"); diff --git a/src/lib/converter/plugins/GroupPlugin.ts b/src/lib/converter/plugins/GroupPlugin.ts index 3fbefadad..5b11a67af 100644 --- a/src/lib/converter/plugins/GroupPlugin.ts +++ b/src/lib/converter/plugins/GroupPlugin.ts @@ -19,25 +19,6 @@ import { Comment } from "../../models"; */ @Component({ name: "group" }) export class GroupPlugin extends ConverterComponent { - /** - * Define the singular name of individual reflection kinds. - */ - static SINGULARS = { - [ReflectionKind.Enum]: "Enumeration", - [ReflectionKind.EnumMember]: "Enumeration Member", - }; - - /** - * Define the plural name of individual reflection kinds. - */ - static PLURALS = { - [ReflectionKind.Class]: "Classes", - [ReflectionKind.Property]: "Properties", - [ReflectionKind.Enum]: "Enumerations", - [ReflectionKind.EnumMember]: "Enumeration Members", - [ReflectionKind.TypeAlias]: "Type Aliases", - }; - sortFunction!: (reflections: DeclarationReflection[]) => void; @BindOption("searchGroupBoosts") @@ -65,8 +46,6 @@ export class GroupPlugin extends ConverterComponent { * @param reflection The reflection that is currently resolved. */ private onResolve(_context: Context, reflection: Reflection) { - reflection.kindString = GroupPlugin.getKindSingular(reflection.kind); - if (reflection instanceof ContainerReflection) { this.group(reflection); } @@ -144,7 +123,7 @@ export class GroupPlugin extends ConverterComponent { groups.delete(""); if (groups.size === 0) { - groups.add(GroupPlugin.getKindPlural(reflection.kind)); + groups.add(ReflectionKind.pluralString(reflection.kind)); } for (const group of groups) { @@ -185,51 +164,4 @@ export class GroupPlugin extends ConverterComponent { return Array.from(groups.values()); } - - /** - * Transform the internal typescript kind identifier into a human readable version. - * - * @param kind The original typescript kind identifier. - * @returns A human readable version of the given typescript kind identifier. - */ - private static getKindString(kind: ReflectionKind): string { - let str = ReflectionKind[kind]; - str = str.replace( - /(.)([A-Z])/g, - (_m, a, b) => a + " " + b.toLowerCase() - ); - return str; - } - - /** - * Return the singular name of a internal typescript kind identifier. - * - * @param kind The original internal typescript kind identifier. - * @returns The singular name of the given internal typescript kind identifier - */ - static getKindSingular(kind: ReflectionKind): string { - if (kind in GroupPlugin.SINGULARS) { - return GroupPlugin.SINGULARS[ - kind as keyof typeof GroupPlugin.SINGULARS - ]; - } else { - return GroupPlugin.getKindString(kind); - } - } - - /** - * Return the plural name of a internal typescript kind identifier. - * - * @param kind The original internal typescript kind identifier. - * @returns The plural name of the given internal typescript kind identifier - */ - static getKindPlural(kind: ReflectionKind): string { - if (kind in GroupPlugin.PLURALS) { - return GroupPlugin.PLURALS[ - kind as keyof typeof GroupPlugin.PLURALS - ]; - } else { - return this.getKindString(kind) + "s"; - } - } } diff --git a/src/lib/converter/plugins/ImplementsPlugin.ts b/src/lib/converter/plugins/ImplementsPlugin.ts index e949a30fc..3726c6b8c 100644 --- a/src/lib/converter/plugins/ImplementsPlugin.ts +++ b/src/lib/converter/plugins/ImplementsPlugin.ts @@ -1,7 +1,9 @@ import ts from "typescript"; +import { ApplicationEvents } from "../../application-events"; import { ContainerReflection, DeclarationReflection, + ProjectReflection, Reflection, ReflectionKind, SignatureReflection, @@ -42,17 +44,14 @@ export class ImplementsPlugin extends ConverterComponent { this.onSignature, 1000 ); + this.listenTo(this.application, ApplicationEvents.REVIVE, this.resolve); } /** * Mark all members of the given class to be the implementation of the matching interface member. - * - * @param context The context object describing the current state the converter is in. - * @param classReflection The reflection of the classReflection class. - * @param interfaceReflection The reflection of the interfaceReflection interface. */ private analyzeImplements( - context: Context, + project: ProjectReflection, classReflection: DeclarationReflection, interfaceReflection: DeclarationReflection ) { @@ -77,7 +76,7 @@ export class ImplementsPlugin extends ConverterComponent { ReferenceType.createResolvedReference( interfaceMemberName, interfaceMember, - context.project + project ); if ( @@ -94,7 +93,7 @@ export class ImplementsPlugin extends ConverterComponent { ReferenceType.createResolvedReference( clsSig.implementationOf.name, intSig, - context.project + project ); } } @@ -105,7 +104,7 @@ export class ImplementsPlugin extends ConverterComponent { } private analyzeInheritance( - context: Context, + project: ProjectReflection, reflection: DeclarationReflection ) { const extendedTypes = filterMap( @@ -138,14 +137,14 @@ export class ImplementsPlugin extends ConverterComponent { childSig[key] = ReferenceType.createResolvedReference( `${parent.name}.${parentMember.name}`, parentSig, - context.project + project ); } child[key] = ReferenceType.createResolvedReference( `${parent.name}.${parentMember.name}`, parentMember, - context.project + project ); handleInheritedComments(child, parentMember); @@ -155,14 +154,21 @@ export class ImplementsPlugin extends ConverterComponent { } private onResolveEnd(context: Context) { - for (const reflection of Object.values(context.project.reflections)) { + this.resolve(context.project); + } + + private resolve(project: ProjectReflection) { + for (const reflection of Object.values(project.reflections)) { if (reflection instanceof DeclarationReflection) { - this.tryResolve(context, reflection); + this.tryResolve(project, reflection); } } } - private tryResolve(context: Context, reflection: DeclarationReflection) { + private tryResolve( + project: ProjectReflection, + reflection: DeclarationReflection + ) { const requirements = filterMap( [ ...(reflection.implementedTypes ?? []), @@ -174,11 +180,11 @@ export class ImplementsPlugin extends ConverterComponent { ); if (requirements.every((req) => this.resolved.has(req))) { - this.doResolve(context, reflection); + this.doResolve(project, reflection); this.resolved.add(reflection); for (const refl of this.postponed.get(reflection) ?? []) { - this.tryResolve(context, refl); + this.tryResolve(project, refl); } this.postponed.delete(reflection); } else { @@ -190,7 +196,10 @@ export class ImplementsPlugin extends ConverterComponent { } } - private doResolve(context: Context, reflection: DeclarationReflection) { + private doResolve( + project: ProjectReflection, + reflection: DeclarationReflection + ) { if ( reflection.kindOf(ReflectionKind.Class) && reflection.implementedTypes @@ -205,7 +214,7 @@ export class ImplementsPlugin extends ConverterComponent { type.reflection.kindOf(ReflectionKind.ClassOrInterface) ) { this.analyzeImplements( - context, + project, reflection, type.reflection as DeclarationReflection ); @@ -217,7 +226,7 @@ export class ImplementsPlugin extends ConverterComponent { reflection.kindOf(ReflectionKind.ClassOrInterface) && reflection.extendedTypes ) { - this.analyzeInheritance(context, reflection); + this.analyzeInheritance(project, reflection); } } @@ -373,7 +382,7 @@ function findProperty(reflection: DeclarationReflection, parent: ts.Type) { return parent.getProperties().find((prop) => { return reflection.escapedName ? prop.escapedName === reflection.escapedName - : prop.name === reflection.escapedName; + : prop.name === reflection.name; }); } diff --git a/src/lib/converter/plugins/InheritDocPlugin.ts b/src/lib/converter/plugins/InheritDocPlugin.ts index 4ba1f0317..9bb81f0ec 100644 --- a/src/lib/converter/plugins/InheritDocPlugin.ts +++ b/src/lib/converter/plugins/InheritDocPlugin.ts @@ -1,6 +1,7 @@ import { Comment, DeclarationReflection, + ProjectReflection, ReflectionKind, ReflectionType, SignatureReflection, @@ -13,6 +14,7 @@ import { DefaultMap } from "../../utils"; import { zip } from "../../utils/array"; import { parseDeclarationReference } from "../comments/declarationReference"; import { resolveDeclarationReference } from "../comments/declarationReferenceResolver"; +import { ApplicationEvents } from "../../application-events"; /** * A plugin that handles `@inheritDoc` tags by copying documentation from another API item. @@ -36,29 +38,25 @@ export class InheritDocPlugin extends ConverterComponent { * Create a new InheritDocPlugin instance. */ override initialize() { - this.owner.on( - Converter.EVENT_RESOLVE_END, - this.processInheritDoc, - this + this.owner.on(Converter.EVENT_RESOLVE_END, (context: Context) => + this.processInheritDoc(context.project) ); + this.owner.on(ApplicationEvents.REVIVE, this.processInheritDoc, this); } /** * Traverse through reflection descendant to check for `inheritDoc` tag. * If encountered, the parameter of the tag is used to determine a source reflection * that will provide actual comment. - * - * @param context The context object describing the current state the converter is in. - * @param reflection The reflection that is currently resolved. */ - private processInheritDoc(context: Context) { - for (const reflection of Object.values(context.project.reflections)) { + private processInheritDoc(project: ProjectReflection) { + for (const reflection of Object.values(project.reflections)) { const source = extractInheritDocTagReference(reflection); if (!source) continue; const declRef = parseDeclarationReference(source, 0, source.length); if (!declRef || /\S/.test(source.substring(declRef[1]))) { - context.logger.warn( + this.application.logger.warn( `Declaration reference in @inheritDoc for ${reflection.getFriendlyFullName()} was not fully parsed and may resolve incorrectly.` ); } @@ -75,7 +73,8 @@ export class InheritDocPlugin extends ConverterComponent { const index = reflection.parent .getAllSignatures() .indexOf(reflection); - sourceRefl = sourceRefl.getAllSignatures()[index]; + sourceRefl = + sourceRefl.getAllSignatures()[index] || sourceRefl; } } diff --git a/src/lib/converter/plugins/LinkResolverPlugin.ts b/src/lib/converter/plugins/LinkResolverPlugin.ts index 4efee2546..8ae4ad356 100644 --- a/src/lib/converter/plugins/LinkResolverPlugin.ts +++ b/src/lib/converter/plugins/LinkResolverPlugin.ts @@ -2,8 +2,9 @@ import { Component, ConverterComponent } from "../components"; import type { Context, ExternalResolveResult } from "../../converter"; import { ConverterEvents } from "../converter-events"; import { BindOption, ValidationOptions } from "../../utils"; -import { DeclarationReflection } from "../../models"; +import { DeclarationReflection, ProjectReflection } from "../../models"; import { discoverAllReferenceTypes } from "../../utils/reflections"; +import { ApplicationEvents } from "../../application-events"; /** * A plugin that resolves `{@link Foo}` tags. @@ -13,46 +14,52 @@ export class LinkResolverPlugin extends ConverterComponent { @BindOption("validation") validation!: ValidationOptions; - /** - * Create a new LinkResolverPlugin instance. - */ override initialize() { super.initialize(); this.owner.on(ConverterEvents.RESOLVE_END, this.onResolve, this, -300); + this.application.on( + ApplicationEvents.REVIVE, + this.resolveLinks, + this, + -300 + ); } onResolve(context: Context) { - for (const reflection of Object.values(context.project.reflections)) { + this.resolveLinks(context.project); + } + + resolveLinks(project: ProjectReflection) { + for (const reflection of Object.values(project.reflections)) { if (reflection.comment) { - context.converter.resolveLinks(reflection.comment, reflection); + this.owner.resolveLinks(reflection.comment, reflection); } if ( reflection instanceof DeclarationReflection && reflection.readme ) { - reflection.readme = context.converter.resolveLinks( + reflection.readme = this.owner.resolveLinks( reflection.readme, reflection ); } } - if (context.project.readme) { - context.project.readme = context.converter.resolveLinks( - context.project.readme, - context.project - ); + if (project.readme) { + project.readme = this.owner.resolveLinks(project.readme, project); } for (const { type, owner } of discoverAllReferenceTypes( - context.project, + project, false )) { if (!type.reflection) { - const resolveResult = context.converter.resolveExternalLink( + const resolveResult = this.owner.resolveExternalLink( type.toDeclarationReference(), - owner + owner, + undefined, + type.symbolId ); switch (typeof resolveResult) { case "string": diff --git a/src/lib/converter/plugins/PackagePlugin.ts b/src/lib/converter/plugins/PackagePlugin.ts index 85c7d0770..a04921fa2 100644 --- a/src/lib/converter/plugins/PackagePlugin.ts +++ b/src/lib/converter/plugins/PackagePlugin.ts @@ -1,13 +1,19 @@ import * as Path from "path"; -import * as FS from "fs"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; import { BindOption, EntryPointStrategy, readFile } from "../../utils"; -import { getCommonDirectory } from "../../utils/fs"; +import { + discoverInParentDir, + discoverPackageJson, + getCommonDirectory, +} from "../../utils/fs"; import { nicePath } from "../../utils/paths"; import { MinimalSourceFile } from "../../utils/minimalSourceFile"; +import type { ProjectReflection } from "../../models/index"; +import { ApplicationEvents } from "../../application-events"; +import { join } from "path"; /** * A handler that tries to find the package.json and readme.md files of the @@ -18,93 +24,111 @@ export class PackagePlugin extends ConverterComponent { @BindOption("readme") readme!: string; - @BindOption("includeVersion") - includeVersion!: boolean; - @BindOption("entryPointStrategy") entryPointStrategy!: EntryPointStrategy; + @BindOption("entryPoints") + entryPoints!: string[]; + + @BindOption("includeVersion") + includeVersion!: boolean; + /** * The file name of the found readme.md file. */ private readmeFile?: string; /** - * The file name of the found package.json file. + * Contents of the readme.md file discovered, if any */ - private packageFile?: string; + private readmeContents?: string; /** - * Create a new PackageHandler instance. + * Contents of package.json for the active project */ + private packageJson?: { name: string; version?: string }; + override initialize() { this.listenTo(this.owner, { [Converter.EVENT_BEGIN]: this.onBegin, [Converter.EVENT_RESOLVE_BEGIN]: this.onBeginResolve, [Converter.EVENT_END]: () => { delete this.readmeFile; - delete this.packageFile; + delete this.readmeContents; + delete this.packageJson; }, }); + this.listenTo(this.application, { + [ApplicationEvents.REVIVE]: this.onRevive, + }); } - /** - * Triggered when the converter begins converting a project. - */ - private onBegin(_context: Context) { + private onRevive(project: ProjectReflection) { + this.onBegin(); + this.addEntries(project); + delete this.readmeFile; + delete this.packageJson; + delete this.readmeContents; + } + + private onBegin() { this.readmeFile = undefined; - this.packageFile = undefined; + this.readmeContents = undefined; + this.packageJson = undefined; - // Path will be resolved already. This is kind of ugly, but... - const noReadmeFile = this.readme.endsWith("none"); - if (!noReadmeFile && this.readme) { - if (FS.existsSync(this.readme)) { - this.readmeFile = this.readme; - } - } + const entryFiles = + this.entryPointStrategy === EntryPointStrategy.Packages + ? this.entryPoints.map((d) => join(d, "package.json")) + : this.entryPoints; - const packageAndReadmeFound = () => - (noReadmeFile || this.readmeFile) && this.packageFile; - const reachedTopDirectory = (dirName: string) => - dirName === Path.resolve(Path.join(dirName, "..")); + const dirName = Path.resolve(getCommonDirectory(entryFiles)); - let dirName = Path.resolve( - getCommonDirectory(this.application.options.getValue("entryPoints")) - ); this.application.logger.verbose( `Begin readme.md/package.json search at ${nicePath(dirName)}` ); - while (!packageAndReadmeFound() && !reachedTopDirectory(dirName)) { - FS.readdirSync(dirName).forEach((file) => { - const lowercaseFileName = file.toLowerCase(); - if ( - !noReadmeFile && - !this.readmeFile && - lowercaseFileName === "readme.md" - ) { - this.readmeFile = Path.join(dirName, file); - } - - if (!this.packageFile && lowercaseFileName === "package.json") { - this.packageFile = Path.join(dirName, file); - } - }); - - dirName = Path.resolve(Path.join(dirName, "..")); + + this.packageJson = discoverPackageJson(dirName)?.content; + + // Path will be resolved already. This is kind of ugly, but... + if (this.readme.endsWith("none")) { + return; // No readme, we're done + } + + if (this.readme) { + // Readme path provided, read only that file. + try { + this.readmeContents = readFile(this.readme); + this.readmeFile = this.readme; + } catch { + this.application.logger.error( + `Provided README path, ${nicePath( + this.readme + )} could not be read.` + ); + } + } else { + // No readme provided, automatically find the readme + const result = discoverInParentDir( + "readme.md", + dirName, + (content) => content + ); + + if (result) { + this.readmeFile = result.file; + this.readmeContents = result.content; + } } } - /** - * Triggered when the converter begins resolving a project. - * - * @param context The context object describing the current state the converter is in. - */ private onBeginResolve(context: Context) { - const project = context.project; - if (this.readmeFile) { - const readme = readFile(this.readmeFile); - const comment = context.converter.parseRawComment( - new MinimalSourceFile(readme, this.readmeFile) + this.addEntries(context.project); + } + + private addEntries(project: ProjectReflection) { + if (this.readmeFile && this.readmeContents) { + const comment = this.application.converter.parseRawComment( + new MinimalSourceFile(this.readmeContents, this.readmeFile) ); if (comment.blockTags.length || comment.modifierTags.size) { @@ -122,49 +146,22 @@ export class PackagePlugin extends ConverterComponent { project.readme = comment.summary; } - if (this.packageFile) { - const packageInfo = JSON.parse(readFile(this.packageFile)); + if (this.packageJson) { + project.packageName = this.packageJson.name; if (!project.name) { - if (!packageInfo.name) { - context.logger.warn( - 'The --name option was not specified, and package.json does not have a name field. Defaulting project name to "Documentation".' - ); - project.name = "Documentation"; - } else { - project.name = String(packageInfo.name); - } + project.name = project.packageName || "Documentation"; } if (this.includeVersion) { - if (packageInfo.version) { - project.name = `${project.name} - v${packageInfo.version}`; - } else { - // 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 { - if (!project.name) { - context.logger.warn( - 'The --name option was not specified, and no package.json was found. Defaulting project name to "Documentation".' + project.packageVersion = this.packageJson.version?.replace( + /^v/, + "" ); - project.name = "Documentation"; - } - if (this.includeVersion) { - // 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." - ); - } } + } else if (!project.name) { + this.application.logger.warn( + 'The --name option was not specified, and no package.json was found. Defaulting project name to "Documentation".' + ); + project.name = "Documentation"; } } } diff --git a/src/lib/converter/plugins/SourcePlugin.ts b/src/lib/converter/plugins/SourcePlugin.ts index 62076940d..4fee93445 100644 --- a/src/lib/converter/plugins/SourcePlugin.ts +++ b/src/lib/converter/plugins/SourcePlugin.ts @@ -1,6 +1,9 @@ import ts from "typescript"; -import type { Reflection } from "../../models/reflections/index"; +import { + DeclarationReflection, + SignatureReflection, +} from "../../models/reflections/index"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; @@ -72,7 +75,10 @@ export class SourcePlugin extends ConverterComponent { * @param _context The context object describing the current state the converter is in. * @param reflection The reflection that is currently processed. */ - private onDeclaration(_context: Context, reflection: Reflection) { + private onDeclaration( + _context: Context, + reflection: DeclarationReflection + ) { if (this.disableSources) return; const symbol = reflection.project.getSymbolFromReflection(reflection); @@ -87,6 +93,8 @@ export class SourcePlugin extends ConverterComponent { sourceFile, node.name.getStart() ); + } else if (ts.isSourceFile(node)) { + position = { character: 0, line: 0 }; } else { position = ts.getLineAndCharacterOfPosition( sourceFile, @@ -107,7 +115,7 @@ export class SourcePlugin extends ConverterComponent { private onSignature( _context: Context, - reflection: Reflection, + reflection: SignatureReflection, sig?: | ts.SignatureDeclaration | ts.IndexSignatureDeclaration @@ -142,6 +150,15 @@ export class SourcePlugin extends ConverterComponent { this.basePath || getCommonDirectory([...this.fileNames]); for (const refl of Object.values(context.project.reflections)) { + if ( + !( + refl instanceof DeclarationReflection || + refl instanceof SignatureReflection + ) + ) { + continue; + } + for (const source of refl.sources || []) { if (gitIsInstalled) { const repo = this.getRepository(source.fullFileName); diff --git a/src/lib/converter/plugins/TypePlugin.ts b/src/lib/converter/plugins/TypePlugin.ts index 3e720c0f8..3295da70e 100644 --- a/src/lib/converter/plugins/TypePlugin.ts +++ b/src/lib/converter/plugins/TypePlugin.ts @@ -2,14 +2,17 @@ import { ReflectionKind, DeclarationReflection, DeclarationHierarchy, + ProjectReflection, + Reflection, } from "../../models/reflections/index"; import { Type, ReferenceType } from "../../models/types"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; +import { ApplicationEvents } from "../../application-events"; /** - * A handler that converts all instances of {@link LateResolvingType} to their renderable equivalents. + * Responsible for adding `implementedBy` / `implementedFrom` */ @Component({ name: "type" }) export class TypePlugin extends ConverterComponent { @@ -24,15 +27,26 @@ export class TypePlugin extends ConverterComponent { [Converter.EVENT_RESOLVE_END]: this.onResolveEnd, [Converter.EVENT_END]: () => this.reflections.clear(), }); + this.listenTo(this.application, { + [ApplicationEvents.REVIVE]: this.onRevive, + }); + } + + private onRevive(project: ProjectReflection) { + for (const refl of Object.values(project.reflections)) { + this.resolve(project, refl); + } + this.finishResolve(project); + this.reflections.clear(); } - /** - * Triggered when the converter resolves a reflection. - * - * @param context The context object describing the current state the converter is in. - * @param reflection The reflection that is currently resolved. - */ private onResolve(context: Context, reflection: DeclarationReflection) { + this.resolve(context.project, reflection); + } + + private resolve(project: ProjectReflection, reflection: Reflection) { + if (!(reflection instanceof DeclarationReflection)) return; + if (reflection.kindOf(ReflectionKind.ClassOrInterface)) { this.postpone(reflection); @@ -45,7 +59,7 @@ export class TypePlugin extends ConverterComponent { ReferenceType.createResolvedReference( reflection.name, reflection, - context.project + project ) ); }); @@ -59,7 +73,7 @@ export class TypePlugin extends ConverterComponent { ReferenceType.createResolvedReference( reflection.name, reflection, - context.project + project ) ); }); @@ -91,10 +105,11 @@ export class TypePlugin extends ConverterComponent { this.reflections.add(reflection); } - /** - * Triggered when the converter has finished resolving a project. - */ private onResolveEnd(context: Context) { + this.finishResolve(context.project); + } + + private finishResolve(project: ProjectReflection) { this.reflections.forEach((reflection) => { if (reflection.implementedBy) { reflection.implementedBy.sort((a, b) => { @@ -125,7 +140,7 @@ export class TypePlugin extends ConverterComponent { ReferenceType.createResolvedReference( reflection.name, reflection, - context.project + project ), ]); hierarchy.isTarget = true; diff --git a/src/lib/converter/symbols.ts b/src/lib/converter/symbols.ts index 4af17148b..086c0da85 100644 --- a/src/lib/converter/symbols.ts +++ b/src/lib/converter/symbols.ts @@ -128,7 +128,9 @@ export function convertSymbol( const previous = context.project.getReflectionFromSymbol(symbol); if ( previous && - previous.parent?.kindOf(ReflectionKind.Module | ReflectionKind.Project) + previous.parent?.kindOf( + ReflectionKind.SomeModule | ReflectionKind.Project + ) ) { createAlias(previous, context, symbol, exportSymbol); return; @@ -323,6 +325,19 @@ function convertTypeAlias( assert(declaration); if (ts.isTypeAliasDeclaration(declaration)) { + if ( + context + .getComment(symbol, ReflectionKind.TypeAlias) + ?.hasModifier("@interface") + ) { + return convertTypeAliasAsInterface( + context, + symbol, + exportSymbol, + declaration + ); + } + const reflection = context.createDeclarationReflection( ReflectionKind.TypeAlias, symbol, @@ -351,6 +366,48 @@ function convertTypeAlias( } } +function convertTypeAliasAsInterface( + context: Context, + symbol: ts.Symbol, + exportSymbol: ts.Symbol | undefined, + declaration: ts.TypeAliasDeclaration +) { + const reflection = context.createDeclarationReflection( + ReflectionKind.Interface, + symbol, + exportSymbol + ); + context.finalizeDeclarationReflection(reflection); + const rc = context.withScope(reflection); + + const type = context.checker.getTypeAtLocation(declaration); + + // Interfaces have properties + convertSymbols(rc, type.getProperties()); + + // And type arguments + if (declaration.typeParameters) { + reflection.typeParameters = declaration.typeParameters.map((param) => { + const declaration = param.symbol?.declarations?.[0]; + assert(declaration && ts.isTypeParameterDeclaration(declaration)); + return createTypeParamReflection(declaration, rc); + }); + } + + // And maybe call signatures + context.checker + .getSignaturesOfType(type, ts.SignatureKind.Call) + .forEach((sig) => + createSignature(rc, ReflectionKind.CallSignature, sig, symbol) + ); + + // And maybe constructor signatures + convertConstructSignatures(rc, symbol); + + // And finally, index signatures + convertIndexSignature(rc, symbol); +} + function convertFunctionOrMethod( context: Context, symbol: ts.Symbol, @@ -379,15 +436,13 @@ function convertFunctionOrMethod( return; } - const parentSymbol = context.project.getSymbolFromReflection(context.scope); - const locationDeclaration = - parentSymbol + symbol.parent ?.getDeclarations() ?.find( (d) => ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d) ) ?? - parentSymbol?.getDeclarations()?.[0]?.getSourceFile() ?? + symbol.parent?.getDeclarations()?.[0]?.getSourceFile() ?? symbol.getDeclarations()?.[0]?.getSourceFile(); assert(locationDeclaration, "Missing declaration context"); @@ -423,7 +478,7 @@ function convertFunctionOrMethod( // Can't use zip here. We might have less declarations than signatures // or less signatures than declarations. for (const sig of signatures) { - createSignature(scope, ReflectionKind.CallSignature, sig); + createSignature(scope, ReflectionKind.CallSignature, sig, symbol); } } @@ -449,6 +504,7 @@ function convertClassOrInterface( if (classDeclaration) setModifiers(symbol, classDeclaration, reflection); const reflectionContext = context.withScope(reflection); + reflectionContext.convertingClassOrInterface = true; const instanceType = context.checker.getDeclaredTypeOfSymbol(symbol); assert(instanceType.isClassOrInterface()); @@ -521,7 +577,8 @@ function convertClassOrInterface( createSignature( constructContext, ReflectionKind.ConstructorSignature, - sig + sig, + symbol ); }); } @@ -549,7 +606,8 @@ function convertClassOrInterface( createSignature( reflectionContext, ReflectionKind.CallSignature, - sig + sig, + symbol ) ); @@ -643,7 +701,7 @@ function convertProperty( reflection.type = context.converter.convertType( context.withScope(reflection), - (context.isConvertingTypeNode() ? parameterType : void 0) ?? + (context.convertingTypeNode ? parameterType : void 0) ?? context.checker.getTypeOfSymbol(symbol) ); @@ -674,7 +732,7 @@ function convertArrowAsMethod( const signature = context.checker.getSignatureFromDeclaration(arrow); assert(signature); - createSignature(rc, ReflectionKind.CallSignature, signature, arrow); + createSignature(rc, ReflectionKind.CallSignature, signature, symbol, arrow); } function convertConstructor(context: Context, symbol: ts.Symbol) { @@ -700,7 +758,8 @@ function convertConstructor(context: Context, symbol: ts.Symbol) { createSignature( reflectionContext, ReflectionKind.ConstructorSignature, - sig + sig, + symbol ); } } @@ -729,7 +788,8 @@ function convertConstructSignatures(context: Context, symbol: ts.Symbol) { createSignature( constructContext, ReflectionKind.ConstructorSignature, - sig + sig, + symbol ) ); } @@ -761,6 +821,8 @@ function createAlias( symbol: ts.Symbol, exportSymbol: ts.Symbol | undefined ) { + if (context.converter.excludeReferences) return; + // We already have this. Create a reference. const ref = new ReferenceReflection( exportSymbol?.name ?? symbol.name, @@ -779,15 +841,20 @@ function convertVariable( const declaration = symbol.getDeclarations()?.[0]; assert(declaration); + const comment = context.getComment(symbol, ReflectionKind.Variable); const type = context.checker.getTypeOfSymbolAtLocation(symbol, declaration); if ( isEnumLike(context.checker, type, declaration) && - symbol.getJsDocTags().some((tag) => tag.name === "enum") + comment?.hasModifier("@enum") ) { return convertVariableAsEnum(context, symbol, exportSymbol); } + if (comment?.hasModifier("@namespace")) { + return convertVariableAsNamespace(context, symbol, exportSymbol); + } + if (type.getCallSignatures().length) { return convertVariableAsFunction(context, symbol, exportSymbol); } @@ -873,6 +940,27 @@ function convertVariableAsEnum( return ts.SymbolFlags.TypeAlias; } +function convertVariableAsNamespace( + context: Context, + symbol: ts.Symbol, + exportSymbol?: ts.Symbol +) { + const reflection = context.createDeclarationReflection( + ReflectionKind.Namespace, + symbol, + exportSymbol + ); + context.finalizeDeclarationReflection(reflection); + const rc = context.withScope(reflection); + + const declaration = symbol.declarations![0] as ts.VariableDeclaration; + const type = context.checker.getTypeAtLocation(declaration); + + convertSymbols(rc, type.getProperties()); + + return ts.SymbolFlags.Property; +} + function convertVariableAsFunction( context: Context, symbol: ts.Symbol, @@ -904,7 +992,8 @@ function convertVariableAsFunction( createSignature( reflectionContext, ReflectionKind.CallSignature, - signature + signature, + symbol ); } @@ -939,6 +1028,7 @@ function convertAccessor( rc, ReflectionKind.GetSignature, signature, + symbol, getDeclaration ); } @@ -953,6 +1043,7 @@ function convertAccessor( rc, ReflectionKind.SetSignature, signature, + symbol, setDeclaration ); } @@ -1016,11 +1107,8 @@ function setModifiers( ); reflection.setFlag( ReflectionFlag.Readonly, - hasAllFlags( - // TS 4.9: symbol.checkFlags, links was introduced in 5.0 - symbol.checkFlags ?? symbol.links?.checkFlags ?? 0, - ts.CheckFlags.Readonly - ) || hasAllFlags(modifiers, ts.ModifierFlags.Readonly) + hasAllFlags(ts.getCheckFlags(symbol), ts.CheckFlags.Readonly) || + hasAllFlags(modifiers, ts.ModifierFlags.Readonly) ); reflection.setFlag( ReflectionFlag.Abstract, diff --git a/src/lib/converter/types.ts b/src/lib/converter/types.ts index 7de9a3cc2..ee6da23a6 100644 --- a/src/lib/converter/types.ts +++ b/src/lib/converter/types.ts @@ -27,6 +27,7 @@ import { TemplateLiteralType, SomeType, } from "../models"; +import { ReflectionSymbolId } from "../models/reflections/ReflectionSymbolId"; import { zip } from "../utils/array"; import type { Context } from "./context"; import { ConverterEvents } from "./converter-events"; @@ -224,7 +225,7 @@ const constructorConverter: TypeConverter = { context.scope ); const rc = context.withScope(reflection); - rc.setConvertingTypeNode(); + rc.convertingTypeNode = true; context.registerReflection(reflection, symbol); context.trigger(ConverterEvents.CREATE_DECLARATION, reflection); @@ -244,6 +245,10 @@ const constructorConverter: TypeConverter = { ) { signature.setFlag(ReflectionFlag.Abstract); } + context.project.registerSymbolId( + signature, + new ReflectionSymbolId(symbol, node) + ); context.registerReflection(signature, void 0); const signatureCtx = rc.withScope(signature); @@ -277,7 +282,8 @@ const constructorConverter: TypeConverter = { createSignature( context.withScope(reflection), ReflectionKind.ConstructorSignature, - type.getConstructSignatures()[0] + type.getConstructSignatures()[0], + type.symbol ); return new ReflectionType(reflection); @@ -334,6 +340,10 @@ const functionTypeConverter: TypeConverter = { ReflectionKind.CallSignature, reflection ); + context.project.registerSymbolId( + signature, + new ReflectionSymbolId(symbol, node) + ); context.registerReflection(signature, void 0); const signatureCtx = rc.withScope(signature); @@ -367,7 +377,8 @@ const functionTypeConverter: TypeConverter = { createSignature( context.withScope(reflection), ReflectionKind.CallSignature, - type.getCallSignatures()[0] + type.getCallSignatures()[0], + type.getSymbol() ); return new ReflectionType(reflection); @@ -546,7 +557,7 @@ const typeLiteralConverter: TypeConverter = { context.scope ); const rc = context.withScope(reflection); - rc.setConvertingTypeNode(); + rc.convertingTypeNode = true; context.registerReflection(reflection, symbol); context.trigger(ConverterEvents.CREATE_DECLARATION, reflection); @@ -555,10 +566,20 @@ const typeLiteralConverter: TypeConverter = { convertSymbol(rc, prop); } for (const signature of type.getCallSignatures()) { - createSignature(rc, ReflectionKind.CallSignature, signature); + createSignature( + rc, + ReflectionKind.CallSignature, + signature, + symbol + ); } for (const signature of type.getConstructSignatures()) { - createSignature(rc, ReflectionKind.ConstructorSignature, signature); + createSignature( + rc, + ReflectionKind.ConstructorSignature, + signature, + symbol + ); } convertIndexSignature(rc, symbol); @@ -585,14 +606,16 @@ const typeLiteralConverter: TypeConverter = { createSignature( context.withScope(reflection), ReflectionKind.CallSignature, - signature + signature, + type.symbol ); } for (const signature of type.getConstructSignatures()) { createSignature( context.withScope(reflection), ReflectionKind.ConstructorSignature, - signature + signature, + type.symbol ); } @@ -625,8 +648,9 @@ const queryConverter: TypeConverter = { ) ); }, - convertType(context, type) { - const symbol = type.getSymbol(); + convertType(context, type, node) { + const symbol = + type.getSymbol() || context.getSymbolAtLocation(node.exprName); assert( symbol, `Query type failed to get a symbol for: ${context.checker.typeToString( diff --git a/src/lib/models/ReflectionCategory.ts b/src/lib/models/ReflectionCategory.ts index ae2014b29..caec0ecb6 100644 --- a/src/lib/models/ReflectionCategory.ts +++ b/src/lib/models/ReflectionCategory.ts @@ -1,5 +1,5 @@ import type { DeclarationReflection } from "."; -import type { Serializer, JSONOutput } from "../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../serialization"; /** * A category of reflections. @@ -44,4 +44,19 @@ export class ReflectionCategory { : undefined, }; } + + fromObject(de: Deserializer, obj: JSONOutput.ReflectionCategory) { + if (obj.children) { + de.defer((project) => { + for (const childId of obj.children || []) { + const child = project.getReflectionById( + de.oldIdToNewId[childId] ?? -1 + ); + if (child?.isDeclaration()) { + this.children.push(child); + } + } + }); + } + } } diff --git a/src/lib/models/ReflectionGroup.ts b/src/lib/models/ReflectionGroup.ts index be3ce0f5e..02be5e885 100644 --- a/src/lib/models/ReflectionGroup.ts +++ b/src/lib/models/ReflectionGroup.ts @@ -1,6 +1,6 @@ -import type { ReflectionCategory } from "./ReflectionCategory"; +import { ReflectionCategory } from "./ReflectionCategory"; import type { DeclarationReflection } from "."; -import type { Serializer, JSONOutput } from "../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../serialization"; /** * A group of reflections. All reflections in a group are of the same kind. @@ -51,4 +51,27 @@ export class ReflectionGroup { categories: serializer.toObjectsOptional(this.categories), }; } + + fromObject(de: Deserializer, obj: JSONOutput.ReflectionGroup) { + if (obj.categories) { + this.categories = obj.categories.map((catObj) => { + const cat = new ReflectionCategory(catObj.title); + de.fromObject(cat, catObj); + return cat; + }); + } + + if (obj.children) { + de.defer((project) => { + for (const childId of obj.children || []) { + const child = project.getReflectionById( + de.oldIdToNewId[childId] ?? -1 + ); + if (child?.isDeclaration()) { + this.children.push(child); + } + } + }); + } + } } diff --git a/src/lib/models/comments/comment.ts b/src/lib/models/comments/comment.ts index c39e2f8e4..2990351a5 100644 --- a/src/lib/models/comments/comment.ts +++ b/src/lib/models/comments/comment.ts @@ -1,7 +1,8 @@ import { assertNever, removeIf } from "../../utils"; import type { Reflection } from "../reflections"; +import { ReflectionSymbolId } from "../reflections/ReflectionSymbolId"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, Deserializer, JSONOutput } from "../../serialization"; export type CommentDisplayPart = | { kind: "text"; text: string } @@ -10,32 +11,16 @@ export type CommentDisplayPart = /** * The `@link`, `@linkcode`, and `@linkplain` tags may have a `target` - * property set indicating which reflection/url they link to. + * property set indicating which reflection/url they link to. They may also + * have a `tsLinkText` property which includes the part of the `text` which + * TypeScript thinks should be displayed as the link text. */ export interface InlineTagDisplayPart { kind: "inline-tag"; tag: `@${string}`; text: string; - target?: Reflection | string; -} - -function serializeDisplayPart( - part: CommentDisplayPart -): JSONOutput.CommentDisplayPart { - switch (part.kind) { - case "text": - case "code": - return part; - case "inline-tag": { - return { - ...part, - target: - typeof part.target === "object" - ? part.target.id - : part.target, - }; - } - } + target?: Reflection | string | ReflectionSymbolId; + tsLinkText?: string; } /** @@ -79,13 +64,19 @@ export class CommentTag { return tag; } - toObject(): JSONOutput.CommentTag { + toObject(serializer: Serializer): JSONOutput.CommentTag { return { tag: this.tag, name: this.name, - content: this.content.map(serializeDisplayPart), + content: Comment.serializeDisplayParts(serializer, this.content), }; } + + fromObject(de: Deserializer, obj: JSONOutput.CommentTag) { + // tag already set by Comment.fromObject + this.name = obj.name; + this.content = Comment.deserializeDisplayParts(de, obj.content); + } } /** @@ -147,10 +138,14 @@ export class Comment { case "@linkcode": case "@linkplain": { if (part.target) { - const url = - typeof part.target === "string" - ? part.target - : urlTo(part.target); + let url: string | undefined; + if (typeof part.target === "string") { + url = part.target; + } else if (part.target && "id" in part.target) { + // No point in trying to resolve a ReflectionSymbolId at this point, we've already + // tried and failed during the resolution step. + url = urlTo(part.target); + } const text = part.tag === "@linkcode" ? `${part.text}` @@ -187,6 +182,112 @@ export class Comment { return parts.map((p) => ({ ...p })); } + //Since display parts are plain objects, this lives here + static serializeDisplayParts( + serializer: Serializer, + parts: CommentDisplayPart[] + ): JSONOutput.CommentDisplayPart[]; + /** @hidden no point in showing this signature in api docs */ + static serializeDisplayParts( + serializer: Serializer, + parts: CommentDisplayPart[] | undefined + ): JSONOutput.CommentDisplayPart[] | undefined; + static serializeDisplayParts( + serializer: Serializer, + parts: CommentDisplayPart[] | undefined + ): JSONOutput.CommentDisplayPart[] | undefined { + return parts?.map((part) => { + switch (part.kind) { + case "text": + case "code": + return { ...part }; + case "inline-tag": { + let target: JSONOutput.InlineTagDisplayPart["target"]; + if (typeof part.target === "string") { + target = part.target; + } else if (part.target) { + if ("id" in part.target) { + target = part.target.id; + } else { + target = part.target.toObject(serializer); + } + } + return { + ...part, + target, + }; + } + } + }); + } + + //Since display parts are plain objects, this lives here + static deserializeDisplayParts( + de: Deserializer, + parts: JSONOutput.CommentDisplayPart[] + ): CommentDisplayPart[] { + const links: [number, InlineTagDisplayPart][] = []; + + const result = parts.map((part): CommentDisplayPart => { + switch (part.kind) { + case "text": + case "code": + return { ...part }; + case "inline-tag": { + if (typeof part.target === "number") { + const part2 = { + kind: part.kind, + tag: part.tag, + text: part.text, + target: undefined, + tsLinkText: part.tsLinkText, + } satisfies InlineTagDisplayPart; + links.push([part.target, part2]); + return part2; + } else if ( + typeof part.target === "string" || + part.target === undefined + ) { + return { + kind: "inline-tag", + tag: part.tag, + text: part.text, + target: part.target, + tsLinkText: part.tsLinkText, + } satisfies InlineTagDisplayPart; + } else if (typeof part.target === "object") { + return { + kind: "inline-tag", + tag: part.tag, + text: part.text, + target: new ReflectionSymbolId(part.target), + tsLinkText: part.tsLinkText, + } satisfies InlineTagDisplayPart; + } else { + assertNever(part.target); + } + } + } + }); + + if (links.length) { + de.defer((project) => { + for (const [oldId, part] of links) { + part.target = project.getReflectionById( + de.oldIdToNewId[oldId] ?? -1 + ); + if (!part.target) { + de.logger.warn( + `Serialized project contained a link to ${oldId} (${part.text}), which was not a part of the project.` + ); + } + } + }); + } + + return result; + } + /** * The content of the comment which is not associated with a block tag. */ @@ -202,6 +303,11 @@ export class Comment { */ modifierTags: Set = new Set(); + /** + * Label associated with this reflection, if any (https://tsdoc.org/pages/tags/label/) + */ + label?: string; + /** * Creates a new Comment instance. */ @@ -213,6 +319,7 @@ export class Comment { this.summary = summary; this.blockTags = blockTags; this.modifierTags = modifierTags; + extractLabelTag(this); } /** @@ -294,12 +401,35 @@ export class Comment { toObject(serializer: Serializer): JSONOutput.Comment { return { - summary: this.summary.map(serializeDisplayPart), + summary: Comment.serializeDisplayParts(serializer, this.summary), blockTags: serializer.toObjectsOptional(this.blockTags), modifierTags: this.modifierTags.size > 0 ? Array.from(this.modifierTags) : undefined, + label: this.label, }; } + + fromObject(de: Deserializer, obj: JSONOutput.Comment) { + this.summary = Comment.deserializeDisplayParts(de, obj.summary); + this.blockTags = + obj.blockTags?.map((tagObj) => { + const tag = new CommentTag(tagObj.tag, []); + de.fromObject(tag, tagObj); + return tag; + }) || []; + this.modifierTags = new Set(obj.modifierTags); + this.label = obj.label; + } +} + +function extractLabelTag(comment: Comment) { + const index = comment.summary.findIndex( + (part) => part.kind === "inline-tag" && part.tag === "@label" + ); + + if (index !== -1) { + comment.label = comment.summary.splice(index, 1)[0].text; + } } diff --git a/src/lib/models/reflections/ReflectionSymbolId.ts b/src/lib/models/reflections/ReflectionSymbolId.ts new file mode 100644 index 000000000..50f83aa70 --- /dev/null +++ b/src/lib/models/reflections/ReflectionSymbolId.ts @@ -0,0 +1,120 @@ +import { existsSync } from "fs"; +import { isAbsolute, join, relative, resolve } from "path"; +import ts from "typescript"; +import type { JSONOutput, Serializer } from "../../serialization/index"; +import { readFile } from "../../utils/fs"; +import { getQualifiedName } from "../../utils/tsutils"; +import { optional, validate } from "../../utils/validation"; + +/** + * See {@link ReflectionSymbolId} + */ +export type ReflectionSymbolIdString = string & { + readonly __reflectionSymbolId: unique symbol; +}; + +/** + * This exists so that TypeDoc can store a unique identifier for a `ts.Symbol` without + * keeping a reference to the `ts.Symbol` itself. This identifier should be stable across + * runs so long as the symbol is exported from the same file. + */ +export class ReflectionSymbolId { + readonly fileName: string; + readonly qualifiedName: string; + /** + * Note: This is **not** serialized. It exists for sorting by declaration order, but + * should not be needed when deserializing from JSON. + */ + pos: number; + + constructor(symbol: ts.Symbol, declaration?: ts.Declaration); + constructor(json: JSONOutput.ReflectionSymbolId); + constructor( + symbol: ts.Symbol | JSONOutput.ReflectionSymbolId, + declaration?: ts.Declaration + ) { + if ("name" in symbol) { + declaration ??= symbol?.declarations?.[0]; + this.fileName = declaration?.getSourceFile().fileName ?? "\0"; + if (symbol.declarations?.some(ts.isSourceFile)) { + this.qualifiedName = ""; + } else { + this.qualifiedName = getQualifiedName(symbol, symbol.name); + } + this.pos = declaration?.pos ?? Infinity; + } else { + this.fileName = symbol.sourceFileName; + this.qualifiedName = symbol.qualifiedName; + this.pos = Infinity; + } + } + + getStableKey(): ReflectionSymbolIdString { + if (Number.isFinite(this.pos)) { + return `${this.fileName}\0${this.qualifiedName}\0${this.pos}` as ReflectionSymbolIdString; + } else { + return `${this.fileName}\0${this.qualifiedName}` as ReflectionSymbolIdString; + } + } + + toObject(serializer: Serializer) { + return { + sourceFileName: isAbsolute(this.fileName) + ? relative( + serializer.projectRoot, + resolveDeclarationMaps(this.fileName) + ) + : this.fileName, + qualifiedName: this.qualifiedName, + }; + } +} + +const declarationMapCache = new Map(); + +/** + * See also getTsSourceFromJsSource in package-manifest.ts. + */ +function resolveDeclarationMaps(file: string): string { + if (!file.endsWith(".d.ts")) return file; + if (declarationMapCache.has(file)) return declarationMapCache.get(file)!; + + const mapFile = file + ".map"; + if (!existsSync(mapFile)) return file; + + let sourceMap: unknown; + try { + sourceMap = JSON.parse(readFile(mapFile)) as unknown; + } catch { + return file; + } + + if ( + validate( + { + file: String, + sourceRoot: optional(String), + sources: [Array, String], + }, + sourceMap + ) + ) { + // There's a pretty large assumption in here that we only have + // 1 source file per js file. This is a pretty standard typescript approach, + // but people might do interesting things with transpilation that could break this. + let source = sourceMap.sources[0]; + + // If we have a sourceRoot, trim any leading slash from the source, and join them + // Similar to how it's done at https://github.com/mozilla/source-map/blob/58819f09018d56ef84dc41ba9c93f554e0645169/lib/util.js#L412 + if (sourceMap.sourceRoot !== undefined) { + source = source.replace(/^\//, ""); + source = join(sourceMap.sourceRoot, source); + } + + const result = resolve(mapFile, "..", source); + declarationMapCache.set(file, result); + return result; + } + + return file; +} diff --git a/src/lib/models/reflections/abstract.ts b/src/lib/models/reflections/abstract.ts index 33af2a770..84c67dfa5 100644 --- a/src/lib/models/reflections/abstract.ts +++ b/src/lib/models/reflections/abstract.ts @@ -1,23 +1,12 @@ import { ok } from "assert"; -import type { SourceReference } from "../sources/file"; -import type { Comment } from "../comments/comment"; +import { Comment } from "../comments/comment"; import { splitUnquotedString } from "./utils"; import type { ProjectReflection } from "./project"; import type { NeverIfInternal } from "../../utils"; import { ReflectionKind } from "./kind"; -import type { Serializer, JSONOutput } from "../../serialization"; - -/** - * Holds all data models used by TypeDoc. - * - * The {@link BaseReflection} is base class of all reflection models. The subclass {@link ProjectReflection} - * serves as the root container for the current project while {@link DeclarationReflection} instances - * form the structure of the project. Most of the other classes in this namespace are referenced by this - * two base classes. - * - * The models {@link NavigationItem} and {@link UrlMapping} are special as they are only used by the {@link Renderer} - * while creating the final output. - */ +import type { Serializer, Deserializer, JSONOutput } from "../../serialization"; +import type { ReflectionVariant } from "./variant"; +import type { DeclarationReflection } from "./declaration"; /** * Current reflection id. @@ -207,6 +196,18 @@ export class ReflectionFlags extends Array { .map((flag) => [flag, true]) ); } + + fromObject(obj: JSONOutput.ReflectionFlags) { + for (const key of Object.keys(obj)) { + const flagName = key.substring(2); // isPublic => Public + if (flagName in ReflectionFlag) { + this.setFlag( + ReflectionFlag[flagName as keyof typeof ReflectionFlag], + true + ); + } + } + } } export enum TraverseProperty { @@ -242,6 +243,11 @@ export interface TraverseCallback { * contains a list of all children grouped and sorted for rendering. */ export abstract class Reflection { + /** + * Discriminator representing the type of reflection represented by this object. + */ + abstract readonly variant: keyof ReflectionVariant; + /** * Unique id of this reflection. */ @@ -252,28 +258,11 @@ export abstract class Reflection { */ name: string; - /** - * The original name of the TypeScript declaration. - */ - originalName: string; - - /** - * Label associated with this reflection, if any (https://tsdoc.org/pages/tags/label/) - * Added by the CommentPlugin during resolution. - */ - label?: string; - /** * The kind of this reflection. */ kind: ReflectionKind; - /** - * The human readable string representation of the kind of this reflection. - * Set during the resolution phase by GroupPlugin - */ - kindString?: string; - flags: ReflectionFlags = new ReflectionFlags(); /** @@ -295,11 +284,6 @@ export abstract class Reflection { */ comment?: Comment; - /** - * A list of all source files that contributed to this reflection. - */ - sources?: SourceReference[]; - /** * The url of this reflection in the generated documentation. * TODO: Reflections shouldn't know urls exist. Move this to a serializer. @@ -320,13 +304,6 @@ export abstract class Reflection { */ hasOwnDocument?: boolean; - /** - * A list of generated css classes that should be applied to representations of this - * reflection in the generated markup. - * TODO: Reflections shouldn't know about CSS. Move this property to the correct serializer. - */ - cssClasses?: string; - /** * Url safe alias for this reflection. * @@ -340,7 +317,6 @@ export abstract class Reflection { this.id = REFLECTION_ID++; this.parent = parent; this.name = name; - this.originalName = name; this.kind = kind; // If our parent is external, we are too. @@ -487,9 +463,12 @@ export abstract class Reflection { isProject(): this is ProjectReflection { return false; } + isDeclaration(): this is DeclarationReflection { + return false; + } /** - * Check if this reflection has been marked with the `@deprecated` tag. + * Check if this reflection or any of its parents have been marked with the `@deprecated` tag. */ isDeprecated(): boolean { if (this.comment?.getTag("@deprecated")) { @@ -499,27 +478,6 @@ export abstract class Reflection { return this.parent?.isDeprecated() ?? false; } - /** - * Try to find a reflection by its name. - * - * @return The found reflection or null. - * @deprecated This method not be used, it naively splits the name by a `.` and searches recursively up - * the parent tree, which is not how any other name resolver works. If you are currently using this and - * need another method, please open an issue. For tests {@link getChildByName} should generally be sufficient. - */ - findReflectionByName(arg: string | string[]): Reflection | undefined { - const names: string[] = Array.isArray(arg) - ? arg - : splitUnquotedString(arg, "."); - - const reflection = this.getChildByName(names); - if (reflection) { - return reflection; - } else if (this.parent) { - return this.parent.findReflectionByName(names); - } - } - /** * Traverse all potential child reflections of this reflection. * @@ -560,15 +518,27 @@ export abstract class Reflection { return { id: this.id, name: this.name, + variant: this.variant, kind: this.kind, - kindString: this.kindString, flags: this.flags.toObject(), comment: this.comment && !this.comment.isEmpty() ? serializer.toObject(this.comment) : undefined, - originalName: - this.originalName !== this.name ? this.originalName : undefined, }; } + + fromObject(de: Deserializer, obj: JSONOutput.Reflection) { + // DO NOT copy id from obj. When deserializing reflections + // they should be given new ids since they belong to a different project. + this.name = obj.name; + // Skip copying variant, we know it's already the correct value because the deserializer + // will construct the correct class type. + this.kind = obj.kind; + this.flags.fromObject(obj.flags); + // Parent is set during construction, so we don't need to do it here. + this.comment = de.revive(obj.comment, () => new Comment()); + // url, anchor, hasOwnDocument, _alias, _aliases are set during rendering and only relevant during render. + // It doesn't make sense to serialize them to json, or restore them. + } } diff --git a/src/lib/models/reflections/container.ts b/src/lib/models/reflections/container.ts index 181f4d799..d0d845a64 100644 --- a/src/lib/models/reflections/container.ts +++ b/src/lib/models/reflections/container.ts @@ -1,11 +1,11 @@ import { Reflection, TraverseCallback, TraverseProperty } from "./abstract"; -import type { ReflectionCategory } from "../ReflectionCategory"; -import type { ReflectionGroup } from "../ReflectionGroup"; -import type { DeclarationReflection } from "./declaration"; +import { ReflectionCategory } from "../ReflectionCategory"; +import { ReflectionGroup } from "../ReflectionGroup"; import type { ReflectionKind } from "./kind"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; +import type { DeclarationReflection } from "./declaration"; -export class ContainerReflection extends Reflection { +export abstract class ContainerReflection extends Reflection { /** * The children of this reflection. */ @@ -21,12 +21,6 @@ export class ContainerReflection extends Reflection { */ categories?: ReflectionCategory[]; - /** - * A precomputed boost derived from the searchCategoryBoosts and searchGroupBoosts options, used when - * boosting search relevance scores at runtime. May be modified by plugins. - */ - relevanceBoost?: number; - /** * Return a list of all children of a certain kind. * @@ -59,7 +53,21 @@ export class ContainerReflection extends Reflection { children: serializer.toObjectsOptional(this.children), groups: serializer.toObjectsOptional(this.groups), categories: serializer.toObjectsOptional(this.categories), - sources: serializer.toObjectsOptional(this.sources), }; } + + override fromObject(de: Deserializer, obj: JSONOutput.ContainerReflection) { + super.fromObject(de, obj); + this.children = de.reviveMany(obj.children, (child) => + de.constructReflection(child) + ); + this.groups = de.reviveMany( + obj.groups, + (group) => new ReflectionGroup(group.title) + ); + this.categories = de.reviveMany( + obj.categories, + (cat) => new ReflectionCategory(cat.title) + ); + } } diff --git a/src/lib/models/reflections/declaration.ts b/src/lib/models/reflections/declaration.ts index cbc59d9ff..bc8ccf694 100644 --- a/src/lib/models/reflections/declaration.ts +++ b/src/lib/models/reflections/declaration.ts @@ -4,8 +4,11 @@ import { type TraverseCallback, TraverseProperty } from "./abstract"; 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"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; +import { Comment, CommentDisplayPart } from "../comments"; +import { SourceReference } from "../sources/file"; +import { ReflectionSymbolId } from "./ReflectionSymbolId"; +import { ReflectionKind } from "./kind"; /** * Stores hierarchical type data. @@ -44,9 +47,24 @@ export enum ConversionFlags { * kind of a reflection is stored in its ´kind´ member. */ export class DeclarationReflection extends ContainerReflection { + readonly variant = "declaration" as "declaration" | "reference"; + + /** + * A list of all source files that contributed to this reflection. + */ + sources?: SourceReference[]; + + /** + * A precomputed boost derived from the searchCategoryBoosts and searchGroupBoosts options, used when + * boosting search relevance scores at runtime. May be modified by plugins. + */ + relevanceBoost?: number; + /** * The escaped name of this declaration assigned by the TS compiler if there is an associated symbol. * This is used to retrieve properties for analyzing inherited members. + * + * Not serialized, only useful during conversion. * @internal */ escapedName?: ts.__String; @@ -146,7 +164,7 @@ export class DeclarationReflection extends ContainerReflection { /** * The version of the module when found. */ - version?: string; + packageVersion?: string; /** * Flags for information about a reflection which is needed solely during conversion. @@ -154,6 +172,10 @@ export class DeclarationReflection extends ContainerReflection { */ conversionFlags = ConversionFlags.None; + override isDeclaration(): this is DeclarationReflection { + return true; + } + override hasGetterOrSetter(): boolean { return !!this.getSignature || !!this.setSignature; } @@ -275,6 +297,11 @@ export class DeclarationReflection extends ContainerReflection { ): JSONOutput.DeclarationReflection { return { ...super.toObject(serializer), + variant: this.variant, + packageVersion: this.packageVersion, + sources: serializer.toObjectsOptional(this.sources), + relevanceBoost: + this.relevanceBoost === 1 ? undefined : this.relevanceBoost, typeParameters: serializer.toObjectsOptional(this.typeParameters), type: serializer.toObject(this.type), signatures: serializer.toObjectsOptional(this.signatures), @@ -293,4 +320,80 @@ export class DeclarationReflection extends ContainerReflection { implementedBy: serializer.toObjectsOptional(this.implementedBy), }; } + + override fromObject( + de: Deserializer, + obj: JSONOutput.DeclarationReflection | JSONOutput.ProjectReflection + ): void { + super.fromObject(de, obj); + + // This happens when merging multiple projects together. + // If updating this, also check ProjectReflection.fromObject. + if (obj.variant === "project") { + this.kind = ReflectionKind.Module; + this.packageVersion = obj.packageVersion; + if (obj.readme) { + this.readme = Comment.deserializeDisplayParts(de, obj.readme); + } + + de.defer(() => { + for (const [id, sid] of Object.entries(obj.symbolIdMap || {})) { + const refl = this.project.getReflectionById( + de.oldIdToNewId[+id] ?? -1 + ); + if (refl) { + this.project.registerSymbolId( + refl, + new ReflectionSymbolId(sid) + ); + } else { + de.logger.warn( + `Serialized project contained a reflection with id ${id} but it was not present in deserialized project.` + ); + } + } + }); + return; + } + + this.packageVersion = obj.packageVersion; + this.sources = de.reviveMany( + obj.sources, + (src) => new SourceReference(src.fileName, src.line, src.character) + ); + this.relevanceBoost = obj.relevanceBoost; + + this.typeParameters = de.reviveMany(obj.typeParameters, (tp) => + de.constructReflection(tp) + ); + this.type = de.revive(obj.type, (t) => de.constructType(t)); + this.signatures = de.reviveMany(obj.signatures, (r) => + de.constructReflection(r) + ); + this.indexSignature = de.revive(obj.indexSignature, (r) => + de.constructReflection(r) + ); + this.getSignature = de.revive(obj.getSignature, (r) => + de.constructReflection(r) + ); + this.setSignature = de.revive(obj.setSignature, (r) => + de.constructReflection(r) + ); + this.defaultValue = obj.defaultValue; + this.overwrites = de.reviveType(obj.overwrites); + this.inheritedFrom = de.reviveType(obj.inheritedFrom); + this.implementationOf = de.reviveType(obj.implementationOf); + this.extendedTypes = de.reviveMany(obj.extendedTypes, (t) => + de.reviveType(t) + ); + this.extendedBy = de.reviveMany(obj.extendedBy, (t) => + de.reviveType(t) + ); + this.implementedTypes = de.reviveMany(obj.implementedTypes, (t) => + de.reviveType(t) + ); + this.implementedBy = de.reviveMany(obj.implementedBy, (t) => + de.reviveType(t) + ); + } } diff --git a/src/lib/models/reflections/index.ts b/src/lib/models/reflections/index.ts index a85c09024..d95e34882 100644 --- a/src/lib/models/reflections/index.ts +++ b/src/lib/models/reflections/index.ts @@ -15,3 +15,8 @@ export { ReferenceReflection } from "./reference"; export { SignatureReflection } from "./signature"; export { TypeParameterReflection, VarianceModifier } from "./type-parameter"; export { splitUnquotedString } from "./utils"; +export type { ReflectionVariant } from "./variant"; +export { + ReflectionSymbolId, + type ReflectionSymbolIdString, +} from "./ReflectionSymbolId"; diff --git a/src/lib/models/reflections/kind.ts b/src/lib/models/reflections/kind.ts index ea7c4c096..8491d6e8a 100644 --- a/src/lib/models/reflections/kind.ts +++ b/src/lib/models/reflections/kind.ts @@ -1,3 +1,5 @@ +import type { EnumKeys } from "../../utils"; + /** * Defines the available reflection kinds. */ @@ -28,44 +30,53 @@ export enum ReflectionKind { Reference = 0x800000, } -/** @hidden */ export namespace ReflectionKind { + export type KindString = EnumKeys; + export const All = ReflectionKind.Reference * 2 - 1; + /** @internal */ export const ClassOrInterface = ReflectionKind.Class | ReflectionKind.Interface; + /** @internal */ export const VariableOrProperty = ReflectionKind.Variable | ReflectionKind.Property; + /** @internal */ export const FunctionOrMethod = ReflectionKind.Function | ReflectionKind.Method; + /** @internal */ export const ClassMember = ReflectionKind.Accessor | ReflectionKind.Constructor | ReflectionKind.Method | ReflectionKind.Property; + /** @internal */ export const SomeSignature = ReflectionKind.CallSignature | ReflectionKind.IndexSignature | ReflectionKind.ConstructorSignature | ReflectionKind.GetSignature | ReflectionKind.SetSignature; + /** @internal */ export const SomeModule = ReflectionKind.Namespace | ReflectionKind.Module; + /** @internal */ export const SomeType = ReflectionKind.Interface | ReflectionKind.TypeLiteral | ReflectionKind.TypeParameter | ReflectionKind.TypeAlias; + /** @internal */ export const SomeValue = ReflectionKind.Variable | ReflectionKind.Function | ReflectionKind.ObjectLiteral; - + /** @internal */ export const SomeMember = ReflectionKind.EnumMember | ReflectionKind.Property | ReflectionKind.Method | ReflectionKind.Accessor; - + /** @internal */ export const SomeExport = ReflectionKind.Module | ReflectionKind.Namespace | @@ -76,7 +87,7 @@ export namespace ReflectionKind { ReflectionKind.Interface | ReflectionKind.TypeAlias | ReflectionKind.Reference; - + /** @internal */ export const ExportContainer = ReflectionKind.SomeModule | ReflectionKind.Project; @@ -100,4 +111,40 @@ export namespace ReflectionKind { */ export const SignatureContainer = ContainsCallSignatures | ReflectionKind.Accessor; + + const SINGULARS = { + [ReflectionKind.Enum]: "Enumeration", + [ReflectionKind.EnumMember]: "Enumeration Member", + }; + + const PLURALS = { + [ReflectionKind.Class]: "Classes", + [ReflectionKind.Property]: "Properties", + [ReflectionKind.Enum]: "Enumerations", + [ReflectionKind.EnumMember]: "Enumeration Members", + [ReflectionKind.TypeAlias]: "Type Aliases", + }; + + export function singularString(kind: ReflectionKind): string { + if (kind in SINGULARS) { + return SINGULARS[kind as keyof typeof SINGULARS]; + } else { + return getKindString(kind); + } + } + + export function pluralString(kind: ReflectionKind): string { + if (kind in PLURALS) { + return PLURALS[kind as keyof typeof PLURALS]; + } else { + return getKindString(kind) + "s"; + } + } +} + +function getKindString(kind: ReflectionKind): string { + return ReflectionKind[kind].replace( + /(.)([A-Z])/g, + (_m, a, b) => a + " " + b.toLowerCase() + ); } diff --git a/src/lib/models/reflections/parameter.ts b/src/lib/models/reflections/parameter.ts index 9bfa6b69d..74eb3af6d 100644 --- a/src/lib/models/reflections/parameter.ts +++ b/src/lib/models/reflections/parameter.ts @@ -2,9 +2,11 @@ import type { SomeType } from ".."; import { ReflectionType } from "../types"; import { Reflection, TraverseCallback, TraverseProperty } from "./abstract"; import type { SignatureReflection } from "./signature"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; export class ParameterReflection extends Reflection { + readonly variant = "param"; + override parent?: SignatureReflection; defaultValue?: string; @@ -44,8 +46,18 @@ export class ParameterReflection extends Reflection { override toObject(serializer: Serializer): JSONOutput.ParameterReflection { return { ...super.toObject(serializer), + variant: this.variant, type: serializer.toObject(this.type), defaultValue: this.defaultValue, }; } + + override fromObject( + de: Deserializer, + obj: JSONOutput.ParameterReflection + ): void { + super.fromObject(de, obj); + this.type = de.reviveType(obj.type); + this.defaultValue = obj.defaultValue; + } } diff --git a/src/lib/models/reflections/project.ts b/src/lib/models/reflections/project.ts index 3d25107d5..7b29e9f26 100644 --- a/src/lib/models/reflections/project.ts +++ b/src/lib/models/reflections/project.ts @@ -9,7 +9,11 @@ import type { TypeParameterReflection } from "./type-parameter"; import { removeIfPresent } from "../../utils"; import type * as ts from "typescript"; import { ReflectionKind } from "./kind"; -import type { CommentDisplayPart } from "../comments"; +import { Comment, CommentDisplayPart } from "../comments"; +import { ReflectionSymbolId } from "./ReflectionSymbolId"; +import type { Serializer } from "../../serialization/serializer"; +import type { Deserializer, JSONOutput } from "../../serialization/index"; +import { StableKeyMap } from "../../utils/map"; /** * A reflection that represents the root of the project. @@ -18,8 +22,13 @@ import type { CommentDisplayPart } from "../comments"; * and source files of the processed project through this reflection. */ export class ProjectReflection extends ContainerReflection { + readonly variant = "project"; + // Used to resolve references. - private symbolToReflectionIdMap = new Map(); + private symbolToReflectionIdMap: Map = + new StableKeyMap(); + + private reflectionIdToSymbolIdMap = new Map(); private reflectionIdToSymbolMap = new Map(); @@ -35,6 +44,16 @@ export class ProjectReflection extends ContainerReflection { */ reflections: { [id: number]: Reflection } = {}; + /** + * The name of the package that this reflection documents according to package.json. + */ + packageName?: string; + + /** + * The version of the package that this reflection documents according to package.json. + */ + packageVersion?: string; + /** * The contents of the readme.md file of the project when found. */ @@ -42,6 +61,7 @@ export class ProjectReflection extends ContainerReflection { constructor(name: string) { super(name, ReflectionKind.Project); + this.reflections[this.id] = this; } /** @@ -63,25 +83,6 @@ export class ProjectReflection extends ContainerReflection { ); } - /** - * When excludeNotExported is set, if a symbol is exported only under a different name - * there will be a reference which points to the symbol, but the symbol will not be converted - * and the rename will point to nothing. Warn the user if this happens. - */ - removeDanglingReferences() { - const dangling = new Set(); - for (const ref of Object.values(this.reflections)) { - if (ref instanceof ReferenceReflection) { - if (!ref.tryGetTargetReflection()) { - dangling.add(ref); - } - } - } - for (const refl of dangling) { - this.removeReflection(refl); - } - } - /** * Registers the given reflection so that it can be quickly looked up by helper methods. * Should be called for *every* reflection added to the project. @@ -91,10 +92,12 @@ export class ProjectReflection extends ContainerReflection { this.reflections[reflection.id] = reflection; if (symbol) { + const id = new ReflectionSymbolId(symbol); this.symbolToReflectionIdMap.set( - symbol, - this.symbolToReflectionIdMap.get(symbol) ?? reflection.id + id, + this.symbolToReflectionIdMap.get(id) ?? reflection.id ); + this.reflectionIdToSymbolIdMap.set(reflection.id, id); this.reflectionIdToSymbolMap.set(reflection.id, symbol); } } @@ -156,13 +159,14 @@ export class ProjectReflection extends ContainerReflection { }); const symbol = this.reflectionIdToSymbolMap.get(reflection.id); - if ( - symbol && - this.symbolToReflectionIdMap.get(symbol) === reflection.id - ) { - this.symbolToReflectionIdMap.delete(symbol); + if (symbol) { + const id = new ReflectionSymbolId(symbol); + if (this.symbolToReflectionIdMap.get(id) === reflection.id) { + this.symbolToReflectionIdMap.delete(id); + } } + this.reflectionIdToSymbolIdMap.delete(reflection.id); delete this.reflections[reflection.id]; } @@ -179,13 +183,37 @@ export class ProjectReflection extends ContainerReflection { * @internal */ getReflectionFromSymbol(symbol: ts.Symbol) { - const id = this.symbolToReflectionIdMap.get(symbol); + return this.getReflectionFromSymbolId(new ReflectionSymbolId(symbol)); + } + + /** + * Gets the reflection associated with the given symbol id, if it exists. + * @internal + */ + getReflectionFromSymbolId(symbolId: ReflectionSymbolId) { + const id = this.symbolToReflectionIdMap.get(symbolId); if (typeof id === "number") { return this.getReflectionById(id); } } /** @internal */ + getSymbolIdFromReflection(reflection: Reflection) { + return this.reflectionIdToSymbolIdMap.get(reflection.id); + } + + /** @internal */ + registerSymbolId(reflection: Reflection, id: ReflectionSymbolId) { + this.reflectionIdToSymbolIdMap.set(reflection.id, id); + if (!this.symbolToReflectionIdMap.has(id)) { + this.symbolToReflectionIdMap.set(id, reflection.id); + } + } + + /** + * THIS MAY NOT BE USED AFTER CONVERSION HAS FINISHED. + * @internal + */ getSymbolFromReflection(reflection: Reflection) { return this.reflectionIdToSymbolMap.get(reflection.id); } @@ -207,4 +235,46 @@ export class ProjectReflection extends ContainerReflection { return this.referenceGraph; } + + override toObject(serializer: Serializer): JSONOutput.ProjectReflection { + const symbolIdMap: Record = {}; + this.reflectionIdToSymbolIdMap.forEach((sid, id) => { + symbolIdMap[id] = sid.toObject(serializer); + }); + + return { + ...super.toObject(serializer), + variant: this.variant, + packageName: this.packageName, + packageVersion: this.packageVersion, + readme: Comment.serializeDisplayParts(serializer, this.readme), + symbolIdMap, + }; + } + + override fromObject( + de: Deserializer, + obj: JSONOutput.ProjectReflection + ): void { + super.fromObject(de, obj); + // If updating this, also check the block in DeclarationReflection.fromObject. + this.packageName = obj.packageName; + this.packageVersion = obj.packageVersion; + if (obj.readme) { + this.readme = Comment.deserializeDisplayParts(de, obj.readme); + } + + de.defer(() => { + for (const [id, sid] of Object.entries(obj.symbolIdMap || {})) { + const refl = this.getReflectionById(de.oldIdToNewId[+id] ?? -1); + if (refl) { + this.registerSymbolId(refl, new ReflectionSymbolId(sid)); + } else { + de.logger.warn( + `Serialized project contained a reflection with id ${id} but it was not present in deserialized project.` + ); + } + } + }); + } } diff --git a/src/lib/models/reflections/reference.ts b/src/lib/models/reflections/reference.ts index 1a83372d2..ffec46e4a 100644 --- a/src/lib/models/reflections/reference.ts +++ b/src/lib/models/reflections/reference.ts @@ -1,9 +1,7 @@ -import type * as ts from "typescript"; -import { Reflection } from "./abstract"; import { DeclarationReflection } from "./declaration"; import { ReflectionKind } from "./kind"; -import type { ProjectReflection } from "./project"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; +import type { Reflection } from "./abstract"; /** * Describes a reflection which does not exist at this location, but is referenced. Used for imported reflections. @@ -19,24 +17,17 @@ import type { Serializer, JSONOutput } from "../../serialization"; * ``` */ export class ReferenceReflection extends DeclarationReflection { - private _target: Reflection | ts.Symbol; - private _project?: ProjectReflection; + override readonly variant = "reference"; + + private _target: number; /** * Creates a reference reflection. Should only be used within the factory function. - * @param name - * @param state - * @param parent - * * @internal */ - constructor( - name: string, - state: ReferenceReflection["_target"], - parent?: Reflection - ) { + constructor(name: string, reflection: Reflection, parent?: Reflection) { super(name, ReflectionKind.Reference, parent); - this._target = state; + this._target = reflection.id; } /** @@ -44,13 +35,7 @@ export class ReferenceReflection extends DeclarationReflection { * To fully resolve any references, use {@link tryGetTargetReflectionDeep}. */ tryGetTargetReflection(): Reflection | undefined { - this._ensureProject(); - if (this._target instanceof Reflection) { - return this._target; - } - const target = this._project!.getReflectionFromSymbol(this._target); - if (target) this._target = target; - return target; + return this.project.getReflectionById(this._target); } /** @@ -70,8 +55,6 @@ export class ReferenceReflection extends DeclarationReflection { * To fully resolve any references, use {@link getTargetReflectionDeep}. */ getTargetReflection(): Reflection { - this._ensureProject(); - const target = this.tryGetTargetReflection(); if (!target) { throw new Error("Reference was unresolved."); @@ -95,28 +78,23 @@ export class ReferenceReflection extends DeclarationReflection { return this.getTargetReflection().getChildByName(arg); } - private _ensureProject() { - if (this._project) { - return; - } - - let project = this.parent; - while (project && !project.isProject()) { - project = project.parent; - } - this._project = project; - - if (!this._project) { - throw new Error( - "Reference reflection has no project and is unable to resolve." - ); - } - } - override toObject(serializer: Serializer): JSONOutput.ReferenceReflection { return { ...super.toObject(serializer), + variant: this.variant, target: this.tryGetTargetReflection()?.id ?? -1, }; } + + override fromObject( + de: Deserializer, + obj: JSONOutput.ReferenceReflection + ): void { + super.fromObject(de, obj); + de.defer((project) => { + this._target = + project.getReflectionById(de.oldIdToNewId[obj.target] ?? -1) + ?.id ?? -1; + }); + } } diff --git a/src/lib/models/reflections/signature.ts b/src/lib/models/reflections/signature.ts index 73660275d..e59420a70 100644 --- a/src/lib/models/reflections/signature.ts +++ b/src/lib/models/reflections/signature.ts @@ -4,9 +4,12 @@ import type { ParameterReflection } from "./parameter"; import type { TypeParameterReflection } from "./type-parameter"; import type { DeclarationReflection } from "./declaration"; import type { ReflectionKind } from "./kind"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; +import { SourceReference } from "../sources/file"; export class SignatureReflection extends Reflection { + readonly variant = "signature"; + constructor( name: string, kind: SignatureReflection["kind"], @@ -24,6 +27,11 @@ export class SignatureReflection extends Reflection { override parent!: DeclarationReflection; + /** + * A list of all source files that contributed to this reflection. + */ + sources?: SourceReference[]; + parameters?: ParameterReflection[]; typeParameters?: TypeParameterReflection[]; @@ -109,6 +117,8 @@ export class SignatureReflection extends Reflection { override toObject(serializer: Serializer): JSONOutput.SignatureReflection { return { ...super.toObject(serializer), + variant: this.variant, + sources: serializer.toObjectsOptional(this.sources), typeParameter: serializer.toObjectsOptional(this.typeParameters), parameters: serializer.toObjectsOptional(this.parameters), type: serializer.toObject(this.type), @@ -117,4 +127,26 @@ export class SignatureReflection extends Reflection { implementationOf: serializer.toObject(this.implementationOf), }; } + + override fromObject( + de: Deserializer, + obj: JSONOutput.SignatureReflection + ): void { + super.fromObject(de, obj); + + this.sources = de.reviveMany( + obj.sources, + (t) => new SourceReference(t.fileName, t.line, t.character) + ); + this.typeParameters = de.reviveMany(obj.typeParameter, (t) => + de.constructReflection(t) + ); + this.parameters = de.reviveMany(obj.parameters, (t) => + de.constructReflection(t) + ); + this.type = de.reviveType(obj.type); + this.overwrites = de.reviveType(obj.overwrites); + this.inheritedFrom = de.reviveType(obj.inheritedFrom); + this.implementationOf = de.reviveType(obj.implementationOf); + } } diff --git a/src/lib/models/reflections/type-parameter.ts b/src/lib/models/reflections/type-parameter.ts index 2cd4d399c..f35e0c5ec 100644 --- a/src/lib/models/reflections/type-parameter.ts +++ b/src/lib/models/reflections/type-parameter.ts @@ -2,7 +2,8 @@ import type { SomeType } from "../types"; import { Reflection } from "./abstract"; import type { DeclarationReflection } from "./declaration"; import { ReflectionKind } from "./kind"; -import type { Serializer, JSONOutput } from "../../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../../serialization"; +import type { SignatureReflection } from "./signature"; /** * Modifier flags for type parameters, added in TS 4.7 @@ -17,7 +18,9 @@ export type VarianceModifier = (typeof VarianceModifier)[keyof typeof VarianceModifier]; export class TypeParameterReflection extends Reflection { - override parent?: DeclarationReflection; + readonly variant = "typeParam"; + + override parent?: DeclarationReflection | SignatureReflection; type?: SomeType; @@ -27,14 +30,10 @@ export class TypeParameterReflection extends Reflection { constructor( name: string, - constraint: SomeType | undefined, - defaultType: SomeType | undefined, parent: Reflection, varianceModifier: VarianceModifier | undefined ) { super(name, ReflectionKind.TypeParameter, parent); - this.type = constraint; - this.default = defaultType; this.varianceModifier = varianceModifier; } @@ -43,9 +42,20 @@ export class TypeParameterReflection extends Reflection { ): JSONOutput.TypeParameterReflection { return { ...super.toObject(serializer), + variant: this.variant, type: serializer.toObject(this.type), default: serializer.toObject(this.default), varianceModifier: this.varianceModifier, }; } + + override fromObject( + de: Deserializer, + obj: JSONOutput.TypeParameterReflection + ): void { + super.fromObject(de, obj); + this.type = de.reviveType(obj.type); + this.default = de.reviveType(obj.default); + this.varianceModifier = obj.varianceModifier; + } } diff --git a/src/lib/models/reflections/variant.ts b/src/lib/models/reflections/variant.ts new file mode 100644 index 000000000..361a855db --- /dev/null +++ b/src/lib/models/reflections/variant.ts @@ -0,0 +1,19 @@ +import type { DeclarationReflection } from "./declaration"; +import type { ParameterReflection } from "./parameter"; +import type { ProjectReflection } from "./project"; +import type { ReferenceReflection } from "./reference"; +import type { SignatureReflection } from "./signature"; +import type { TypeParameterReflection } from "./type-parameter"; + +/** + * A map of known {@link Reflection} concrete subclasses. + * This is used during deserialization to reconstruct serialized objects. + */ +export interface ReflectionVariant { + declaration: DeclarationReflection; + param: ParameterReflection; + project: ProjectReflection; + reference: ReferenceReflection; + signature: SignatureReflection; + typeParam: TypeParameterReflection; +} diff --git a/src/lib/models/sources/file.ts b/src/lib/models/sources/file.ts index 5753aecc6..aa0b152e5 100644 --- a/src/lib/models/sources/file.ts +++ b/src/lib/models/sources/file.ts @@ -1,3 +1,4 @@ +import type { Deserializer } from "../../serialization/deserializer"; import type { SourceReference as JSONSourceReference } from "../../serialization/schema"; /** @@ -46,4 +47,8 @@ export class SourceReference { url: this.url, }; } + + fromObject(_de: Deserializer, obj: JSONSourceReference) { + this.url = obj.url; + } } diff --git a/src/lib/models/types.ts b/src/lib/models/types.ts index 44aab04c8..b08ed191d 100644 --- a/src/lib/models/types.ts +++ b/src/lib/models/types.ts @@ -1,11 +1,13 @@ -import type * as ts from "typescript"; +import * as ts from "typescript"; import type { Context } from "../converter"; -import { Reflection } from "./reflections/abstract"; +import type { Reflection } from "./reflections/abstract"; import type { DeclarationReflection } from "./reflections/declaration"; import type { ProjectReflection } from "./reflections/project"; -import type { Serializer, JSONOutput } from "../serialization"; +import type { Serializer, JSONOutput, Deserializer } from "../serialization"; import { getQualifiedName } from "../utils/tsutils"; +import { ReflectionSymbolId } from "./reflections/ReflectionSymbolId"; import type { DeclarationReference } from "../converter/comments/declarationReference"; +import { findPackageForPath } from "../utils/fs"; /** * Base class of all type definitions. @@ -41,6 +43,9 @@ export abstract class Type { abstract toObject(serializer: Serializer): JSONOutput.SomeType; + // Nothing to do for the majority of types. + fromObject(_de: Deserializer, _obj: JSONOutput.SomeType) {} + abstract needsParenthesis(context: TypeContext): boolean; /** @@ -65,9 +70,9 @@ export interface TypeKindMap { reference: ReferenceType; reflection: ReflectionType; rest: RestType; - "template-literal": TemplateLiteralType; + templateLiteral: TemplateLiteralType; tuple: TupleType; - "named-tuple-member": NamedTupleMember; + namedTupleMember: NamedTupleMember; typeOperator: TypeOperatorType; union: UnionType; unknown: UnknownType; @@ -81,12 +86,12 @@ export function makeRecursiveVisitor( visitor: Partial ): TypeVisitor { const recursiveVisitor: TypeVisitor = { - "named-tuple-member"(type) { - visitor["named-tuple-member"]?.(type); + namedTupleMember(type) { + visitor.namedTupleMember?.(type); type.element.visit(recursiveVisitor); }, - "template-literal"(type) { - visitor["template-literal"]?.(type); + templateLiteral(type) { + visitor.templateLiteral?.(type); for (const [h] of type.tail) { h.visit(recursiveVisitor); } @@ -625,11 +630,8 @@ export class MappedType extends Type { export class OptionalType extends Type { override readonly type = "optional"; - elementType: SomeType; - - constructor(elementType: SomeType) { + constructor(public elementType: SomeType) { super(); - this.elementType = elementType; } protected override getTypeString() { @@ -714,13 +716,10 @@ export class PredicateType extends Type { * ``` */ export class QueryType extends Type { - readonly queryType: ReferenceType; - override readonly type = "query"; - constructor(reference: ReferenceType) { + constructor(public queryType: ReferenceType) { super(); - this.queryType = reference; } protected override getTypeString() { @@ -760,7 +759,7 @@ export class ReferenceType extends Type { /** * The name of the referenced type. * - * If the symbol cannot be found cause it's not part of the documentation this + * If the symbol cannot be found because it's not part of the documentation this * can be used to represent the type. */ name: string; @@ -777,22 +776,26 @@ export class ReferenceType extends Type { if (typeof this._target === "number") { return this._project?.getReflectionById(this._target); } - const resolved = this._project?.getReflectionFromSymbol(this._target); + const resolved = this._project?.getReflectionFromSymbolId(this._target); if (resolved) this._target = resolved.id; return resolved; } /** - * Don't use this if at all possible. It will eventually go away since models may not - * retain information from the original TS objects to enable documentation generation from - * previously generated JSON. - * @internal + * If not resolved, the symbol id of the reflection, otherwise undefined. */ - getSymbol(): ts.Symbol | undefined { - if (typeof this._target === "number") { - return; + get symbolId(): ReflectionSymbolId | undefined { + if (!this.reflection && typeof this._target === "object") { + return this._target; } - return this._target; + } + + /** + * Checks if this type is a reference type because it uses a name, but is intentionally not pointing + * to a reflection. This happens for type parameters and when representing a mapped type. + */ + isIntentionallyBroken(): boolean { + return this._target === -1; } /** @@ -818,7 +821,6 @@ export class ReferenceType extends Type { /** * The package that this type is referencing. - * Will only be set for `ReferenceType`s pointing to a symbol within `node_modules`. */ package?: string; @@ -828,18 +830,22 @@ export class ReferenceType extends Type { */ externalUrl?: string; - private _target: ts.Symbol | number; - private _project: ProjectReflection; + private _target: ReflectionSymbolId | number; + private _project: ProjectReflection | null; private constructor( name: string, - target: ts.Symbol | Reflection | number, - project: ProjectReflection, + target: ReflectionSymbolId | Reflection | number, + project: ProjectReflection | null, qualifiedName: string ) { super(); this.name = name; - this._target = target instanceof Reflection ? target.id : target; + if (typeof target === "number") { + this._target = target; + } else { + this._target = "variant" in target ? target.id : target; + } this._project = project; this.qualifiedName = qualifiedName; } @@ -847,7 +853,7 @@ export class ReferenceType extends Type { static createResolvedReference( name: string, target: Reflection | number, - project: ProjectReflection + project: ProjectReflection | null ) { return new ReferenceType(name, target, project, name); } @@ -857,11 +863,20 @@ export class ReferenceType extends Type { context: Context, name?: string ) { + // Type parameters should never have resolved references because they + // cannot be linked to, and might be declared within the type with conditional types. + if (symbol.flags & ts.SymbolFlags.TypeParameter) { + return ReferenceType.createBrokenReference( + name ?? symbol.name, + context.project + ); + } + const ref = new ReferenceType( name ?? symbol.name, - symbol, + new ReflectionSymbolId(symbol), context.project, - getQualifiedName(context.checker, symbol) + getQualifiedName(symbol, name ?? symbol.name) ); const symbolPath = symbol?.declarations?.[0] @@ -869,18 +884,22 @@ export class ReferenceType extends Type { .fileName.replace(/\\/g, "/"); if (!symbolPath) return ref; + // Attempt to decide package name from path if it contains "node_modules" let startIndex = symbolPath.lastIndexOf("node_modules/"); - if (startIndex === -1) return ref; - startIndex += "node_modules/".length; - let stopIndex = symbolPath.indexOf("/", startIndex); - // Scoped package, e.g. `@types/node` - if (symbolPath[startIndex] === "@") { - stopIndex = symbolPath.indexOf("/", stopIndex + 1); + if (startIndex !== -1) { + startIndex += "node_modules/".length; + let stopIndex = symbolPath.indexOf("/", startIndex); + // Scoped package, e.g. `@types/node` + if (symbolPath[startIndex] === "@") { + stopIndex = symbolPath.indexOf("/", stopIndex + 1); + } + const packageName = symbolPath.substring(startIndex, stopIndex); + ref.package = packageName; + return ref; } - const packageName = symbolPath.substring(startIndex, stopIndex); - ref.package = packageName; - + // Otherwise, look for a "package.json" file in a parent path + ref.package = findPackageForPath(symbolPath); return ref; } @@ -911,19 +930,51 @@ export class ReferenceType extends Type { override toObject(serializer: Serializer): JSONOutput.ReferenceType { const result: JSONOutput.ReferenceType = { type: this.type, - id: this.reflection?.id, + target: + typeof this._target === "number" + ? this._target + : this._target.toObject(serializer), typeArguments: serializer.toObjectsOptional(this.typeArguments), name: this.name, + package: this.package, externalUrl: this.externalUrl, }; - if (this.package) { + if (this.name !== this.qualifiedName) { result.qualifiedName = this.qualifiedName; - result.package = this.package; } return result; } + + override fromObject(de: Deserializer, obj: JSONOutput.ReferenceType): void { + this.typeArguments = de.reviveMany(obj.typeArguments, (t) => + de.constructType(t) + ); + if (typeof obj.target === "number" && obj.target !== -1) { + de.defer((project) => { + const target = project.getReflectionById( + de.oldIdToNewId[obj.target as number] ?? -1 + ); + if (target) { + this._project = project; + this._target = target.id; + } else { + de.logger.warn( + `Serialized project contained a reference to ${obj.target} (${this.qualifiedName}), which was not a part of the project.` + ); + } + }); + } else if (obj.target === -1) { + this._target = -1; + } else { + this._project = de.project!; + this._target = new ReflectionSymbolId(obj.target); + } + + this.qualifiedName = obj.qualifiedName ?? obj.name; + this.package = obj.package; + } } /** @@ -939,11 +990,8 @@ export class ReferenceType extends Type { export class ReflectionType extends Type { override readonly type = "reflection"; - declaration: DeclarationReflection; - - constructor(declaration: DeclarationReflection) { + constructor(public declaration: DeclarationReflection) { super(); - this.declaration = declaration; } // This really ought to do better, but I'm putting off investing effort here until @@ -1006,7 +1054,7 @@ export class RestType extends Type { * ``` */ export class TemplateLiteralType extends Type { - override readonly type = "template-literal"; + override readonly type = "templateLiteral"; constructor(public head: string, public tail: [SomeType, string][]) { super(); @@ -1091,7 +1139,7 @@ export class TupleType extends Type { * ``` */ export class NamedTupleMember extends Type { - override readonly type = "named-tuple-member"; + override readonly type = "namedTupleMember"; constructor( public name: string, diff --git a/src/lib/output/components.ts b/src/lib/output/components.ts index 2d3f4241a..a3638a175 100644 --- a/src/lib/output/components.ts +++ b/src/lib/output/components.ts @@ -1,9 +1,9 @@ import * as Path from "path"; import { Component, AbstractComponent } from "../utils/component"; -import { +import type { ProjectReflection, - DeclarationReflection, + Reflection, } from "../models/reflections/index"; import type { Renderer } from "./renderer"; import { RendererEvent, PageEvent } from "./events"; @@ -24,7 +24,7 @@ export abstract class ContextAwareRendererComponent extends RendererComponent { /** * The reflection that is currently processed. */ - protected reflection?: DeclarationReflection; + protected page?: PageEvent; /** * The url of the document that is being currently generated. @@ -84,11 +84,8 @@ export abstract class ContextAwareRendererComponent extends RendererComponent { * * @param page An event object describing the current render operation. */ - protected onBeginPage(page: PageEvent) { + protected onBeginPage(page: PageEvent) { this.location = page.url; - this.reflection = - page.model instanceof DeclarationReflection - ? page.model - : undefined; + this.page = page; } } diff --git a/src/lib/output/events.ts b/src/lib/output/events.ts index 4ce1e3342..7a20b9d90 100644 --- a/src/lib/output/events.ts +++ b/src/lib/output/events.ts @@ -3,7 +3,7 @@ import * as Path from "path"; import { Event } from "../utils/events"; import type { ProjectReflection } from "../models/reflections/project"; import type { RenderTemplate, UrlMapping } from "./models/UrlMapping"; -import type { DeclarationReflection } from "../models"; +import type { DeclarationReflection, ReflectionKind } from "../models"; /** * An event emitted by the {@link Renderer} class at the very beginning and @@ -61,14 +61,12 @@ export class RendererEvent extends Event { */ public createPageEvent( mapping: UrlMapping - ): PageEvent { - const event = new PageEvent(PageEvent.BEGIN); + ): [RenderTemplate>, PageEvent] { + const event = new PageEvent(PageEvent.BEGIN, mapping.model); event.project = this.project; event.url = mapping.url; - event.model = mapping.model; - event.template = mapping.template; event.filename = Path.join(this.outputDirectory, mapping.url); - return event; + return [mapping.template, event]; } } @@ -79,7 +77,7 @@ export class RendererEvent extends Event { * @see {@link Renderer.EVENT_BEGIN_PAGE} * @see {@link Renderer.EVENT_END_PAGE} */ -export class PageEvent extends Event { +export class PageEvent extends Event { /** * The project the renderer is currently processing. */ @@ -98,12 +96,7 @@ export class PageEvent extends Event { /** * The model that should be rendered on this page. */ - model!: Model; - - /** - * The template that should be used to render this page. - */ - template!: RenderTemplate; + readonly model: Model; /** * The final html content of this page. @@ -112,6 +105,18 @@ export class PageEvent extends Event { */ contents?: string; + /** + * Links to content within this page that should be rendered in the page navigation. + * This is built when rendering the document content. + */ + pageHeadings: Array<{ + link: string; + text: string; + level?: number; + kind?: ReflectionKind; + classes?: string; + }> = []; + /** * Triggered before a document will be rendered. * @event @@ -123,12 +128,17 @@ export class PageEvent extends Event { * @event */ static readonly END = "endPage"; + + constructor(name: string, model: Model) { + super(name); + this.model = model; + } } /** * An event emitted when markdown is being parsed. Allows other plugins to manipulate the result. * - * @see {@link PARSE} + * @see {@link MarkdownEvent.PARSE} */ export class MarkdownEvent extends Event { /** @@ -141,14 +151,25 @@ export class MarkdownEvent extends Event { */ parsedText: string; + /** + * The page that this markdown is being parsed for. + */ + readonly page: PageEvent; + /** * Triggered on the renderer when this plugin parses a markdown string. * @event */ static readonly PARSE = "parseMarkdown"; - constructor(name: string, originalText: string, parsedText: string) { + constructor( + name: string, + page: PageEvent, + originalText: string, + parsedText: string + ) { super(name); + this.page = page; this.originalText = originalText; this.parsedText = parsedText; } diff --git a/src/lib/output/plugins/JavascriptIndexPlugin.ts b/src/lib/output/plugins/JavascriptIndexPlugin.ts index e4466eb82..6967c0c13 100644 --- a/src/lib/output/plugins/JavascriptIndexPlugin.ts +++ b/src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -5,9 +5,7 @@ import { Comment, DeclarationReflection, ProjectReflection, - ReflectionKind, } from "../../models"; -import { GroupPlugin } from "../../converter/plugins"; import { Component, RendererComponent } from "../components"; import { IndexEvent, RendererEvent } from "../events"; import { BindOption, writeFileSync } from "../../utils"; @@ -55,7 +53,6 @@ export class JavascriptIndexPlugin extends RendererComponent { } const rows: SearchDocument[] = []; - const kinds: { [K in ReflectionKind]?: string } = {}; const initialSearchResults = Object.values( event.project.reflections @@ -104,17 +101,11 @@ export class JavascriptIndexPlugin extends RendererComponent { parent = undefined; } - if (!kinds[reflection.kind]) { - kinds[reflection.kind] = GroupPlugin.getKindSingular( - reflection.kind - ); - } - const row: SearchDocument = { kind: reflection.kind, name: reflection.name, url: reflection.url, - classes: reflection.cssClasses, + classes: this.owner.theme.getReflectionClasses(reflection), }; if (parent) { @@ -142,7 +133,6 @@ export class JavascriptIndexPlugin extends RendererComponent { ); const jsonData = JSON.stringify({ - kinds, rows, index, }); diff --git a/src/lib/output/renderer.ts b/src/lib/output/renderer.ts index d9196fbc0..e6e0066f1 100644 --- a/src/lib/output/renderer.ts +++ b/src/lib/output/renderer.ts @@ -13,7 +13,7 @@ import type { Application } from "../application"; import type { Theme } from "./theme"; import { RendererEvent, PageEvent, IndexEvent } from "./events"; import type { ProjectReflection } from "../models/reflections/project"; -import type { UrlMapping } from "./models/UrlMapping"; +import type { RenderTemplate, UrlMapping } from "./models/UrlMapping"; import { writeFileSync } from "../utils/fs"; import { DefaultTheme } from "./themes/default/DefaultTheme"; import { RendererComponent } from "./components"; @@ -63,14 +63,24 @@ export interface RendererHooks { "content.end": [DefaultThemeRenderContext]; /** - * Applied immediately before calling `context.navigation`. + * Applied immediately before calling `context.sidebar`. */ - "navigation.begin": [DefaultThemeRenderContext]; + "sidebar.begin": [DefaultThemeRenderContext]; /** - * Applied immediately after calling `context.navigation`. + * Applied immediately after calling `context.sidebar`. */ - "navigation.end": [DefaultThemeRenderContext]; + "sidebar.end": [DefaultThemeRenderContext]; + + /** + * Applied immediately before calling `context.pageSidebar`. + */ + "pageSidebar.begin": [DefaultThemeRenderContext]; + + /** + * Applied immediately after calling `context.pageSidebar`. + */ + "pageSidebar.end": [DefaultThemeRenderContext]; } /** @@ -126,6 +136,28 @@ export class Renderer extends ChildableComponent< /** @event */ static readonly EVENT_PREPARE_INDEX = IndexEvent.PREPARE_INDEX; + /** + * A list of async jobs which must be completed *before* rendering output. + * They will be called after {@link RendererEvent.BEGIN} has fired, but before any files have been written. + * + * This may be used by plugins to register work that must be done to prepare output files. For example: asynchronously + * transform markdown to HTML. + * + * Note: This array is cleared after calling the contained functions on each {@link Renderer.render} call. + */ + preRenderAsyncJobs: Array<(output: RendererEvent) => Promise> = []; + + /** + * A list of async jobs which must be completed after rendering output files but before generation is considered successful. + * These functions will be called after all documents have been written to the filesystem. + * + * This may be used by plugins to register work that must be done to finalize output files. For example: asynchronously + * generating an image referenced in a render hook. + * + * Note: This array is cleared after calling the contained functions on each {@link Renderer.render} call. + */ + postRenderAsyncJobs: Array<(output: RendererEvent) => Promise> = []; + /** * The theme that is used to render the documentation. */ @@ -157,6 +189,10 @@ export class Renderer extends ChildableComponent< @BindOption("githubPages") githubPages!: boolean; + /** @internal */ + @BindOption("cacheBust") + cacheBust!: boolean; + /** @internal */ @BindOption("lightHighlightTheme") lightTheme!: ShikiTheme; @@ -165,6 +201,8 @@ export class Renderer extends ChildableComponent< @BindOption("darkHighlightTheme") darkTheme!: ShikiTheme; + renderStartTime = -1; + /** * Define a new theme that can be used to render output. * This API will likely be changing at some point, to allow more easily overriding parts of the theme without @@ -179,33 +217,6 @@ export class Renderer extends ChildableComponent< this.themes.set(name, theme); } - /** - * Adds a new resolver that the theme can use to try to figure out how to link to a symbol - * declared by a third-party library which is not included in the documentation. - * @param packageName the npm package name that this resolver can handle to limit which files it will be tried on. - * If the resolver will create links for Node builtin types, it should be set to `@types/node`. - * Links for builtin types live in the default lib files under `typescript`. - * @param resolver a function that will be called to create links for a given symbol name in the registered path. - * If the provided name is not contained within the docs, should return `undefined`. - * @since 0.22.0 - * @deprecated - * Deprecated since v0.23.14, use {@link Converter.addUnknownSymbolResolver | Converter.addUnknownSymbolResolver} instead. - * This signature will be removed in 0.24 or possibly 0.25. - */ - addUnknownSymbolResolver( - packageName: string, - resolver: (name: string) => string | undefined - ) { - this.owner.converter.addUnknownSymbolResolver((ref) => { - const path = ref.symbolReference?.path - ?.map((path) => path.path) - .join("."); - if (ref.moduleSource === packageName && path) { - return resolver(path); - } - }); - } - /** * Render the given project reflection to the specified output directory. * @@ -217,10 +228,12 @@ export class Renderer extends ChildableComponent< outputDirectory: string ): Promise { const momento = this.hooks.saveMomento(); - const start = Date.now(); + this.renderStartTime = Date.now(); await loadHighlighter(this.lightTheme, this.darkTheme); this.application.logger.verbose( - `Renderer: Loading highlighter took ${Date.now() - start}ms` + `Renderer: Loading highlighter took ${ + Date.now() - this.renderStartTime + }ms` ); if ( !this.prepareTheme() || @@ -238,13 +251,24 @@ export class Renderer extends ChildableComponent< this.trigger(output); + await Promise.all(this.preRenderAsyncJobs.map((job) => job(output))); + this.preRenderAsyncJobs = []; + if (!output.isDefaultPrevented) { + this.application.logger.verbose( + `There are ${output.urls.length} pages to write.` + ); output.urls.forEach((mapping: UrlMapping) => { clearSeenIconCache(); - this.renderDocument(output.createPageEvent(mapping)); + this.renderDocument(...output.createPageEvent(mapping)); validateStateIsClean(mapping.url); }); + await Promise.all( + this.postRenderAsyncJobs.map((job) => job(output)) + ); + this.postRenderAsyncJobs = []; + this.trigger(RendererEvent.END, output); } @@ -258,7 +282,10 @@ export class Renderer extends ChildableComponent< * @param page An event describing the current page. * @return TRUE if the page has been saved to disc, otherwise FALSE. */ - private renderDocument(page: PageEvent): boolean { + private renderDocument( + template: RenderTemplate>, + page: PageEvent + ) { const momento = this.hooks.saveMomento(); this.trigger(PageEvent.BEGIN, page); if (page.isDefaultPrevented) { @@ -267,7 +294,7 @@ export class Renderer extends ChildableComponent< } if (page.model instanceof Reflection) { - page.contents = this.theme!.render(page as PageEvent); + page.contents = this.theme!.render(page, template); } else { throw new Error("Should be unreachable"); } @@ -283,10 +310,7 @@ export class Renderer extends ChildableComponent< writeFileSync(page.filename, page.contents); } catch (error) { this.application.logger.error(`Could not write ${page.filename}`); - return false; } - - return true; } /** diff --git a/src/lib/output/theme.ts b/src/lib/output/theme.ts index 5f5aaac06..09daa6b8a 100644 --- a/src/lib/output/theme.ts +++ b/src/lib/output/theme.ts @@ -1,6 +1,6 @@ import type { Renderer } from "./renderer"; import type { ProjectReflection } from "../models/reflections/project"; -import type { UrlMapping } from "./models/UrlMapping"; +import type { RenderTemplate, UrlMapping } from "./models/UrlMapping"; import { RendererComponent } from "./components"; import { Component } from "../utils/component"; import type { PageEvent } from "./events"; @@ -46,5 +46,8 @@ export abstract class Theme extends RendererComponent { /** * Renders the provided page to a string, which will be written to disk by the {@link Renderer} */ - abstract render(page: PageEvent): string; + abstract render( + page: PageEvent, + template: RenderTemplate> + ): string; } diff --git a/src/lib/output/themes/MarkedPlugin.ts b/src/lib/output/themes/MarkedPlugin.ts index a27bffbf6..272a7031e 100644 --- a/src/lib/output/themes/MarkedPlugin.ts +++ b/src/lib/output/themes/MarkedPlugin.ts @@ -3,23 +3,11 @@ import * as Path from "path"; import * as Marked from "marked"; import { Component, ContextAwareRendererComponent } from "../components"; -import { RendererEvent, MarkdownEvent } from "../events"; -import { BindOption, readFile, copySync } from "../../utils"; +import { RendererEvent, MarkdownEvent, PageEvent } from "../events"; +import { BindOption, readFile, copySync, isFile } from "../../utils"; import { highlight, isSupportedLanguage } from "../../utils/highlighter"; import type { Theme } from "shiki"; -const customMarkedRenderer = new Marked.Renderer(); - -customMarkedRenderer.heading = (text, level, _, slugger) => { - const slug = slugger.slug(text); - - return ` - - ${text} - -`; -}; - /** * Implements markdown and relativeURL helpers for templates. * @internal @@ -100,11 +88,11 @@ output file : * @param text The markdown string that should be parsed. * @returns The resulting html string. */ - public parseMarkdown(text: string) { + public parseMarkdown(text: string, page: PageEvent) { if (this.includes) { text = text.replace(this.includePattern, (_match, path) => { path = Path.join(this.includes!, path.trim()); - if (fs.existsSync(path) && fs.statSync(path).isFile()) { + if (isFile(path)) { const contents = readFile(path); return contents; } else { @@ -122,10 +110,7 @@ output file : (match: string, path: string) => { const fileName = Path.join(this.mediaDirectory!, path); - if ( - fs.existsSync(fileName) && - fs.statSync(fileName).isFile() - ) { + if (isFile(fileName)) { return this.getRelativeUrl("media") + "/" + path; } else { this.application.logger.warn( @@ -137,7 +122,7 @@ output file : ); } - const event = new MarkdownEvent(MarkdownEvent.PARSE, text, text); + const event = new MarkdownEvent(MarkdownEvent.PARSE, page, text, text); this.owner.trigger(event); return event.parsedText; @@ -198,7 +183,22 @@ output file : // Set some default values if they are not specified via the TypeDoc option markedOptions.highlight ??= (text, lang) => this.getHighlighted(text, lang); - markedOptions.renderer ??= customMarkedRenderer; + + if (!markedOptions.renderer) { + markedOptions.renderer = new Marked.Renderer(); + + markedOptions.renderer.heading = (text, level, _, slugger) => { + const slug = slugger.slug(text); + // Prefix the slug with an extra `$` to prevent conflicts with TypeDoc's anchors. + this.page!.pageHeadings.push({ + link: `#$${slug}`, + text, + level, + }); + return `${text}`; + }; + } + markedOptions.mangle ??= false; // See https://github.com/TypeStrong/typedoc/issues/1395 return markedOptions; diff --git a/src/lib/output/themes/default/DefaultTheme.tsx b/src/lib/output/themes/default/DefaultTheme.tsx index 6e51ef24a..fce0d719c 100644 --- a/src/lib/output/themes/default/DefaultTheme.tsx +++ b/src/lib/output/themes/default/DefaultTheme.tsx @@ -9,7 +9,7 @@ import { SignatureReflection, } from "../../../models"; import { RenderTemplate, UrlMapping } from "../../models/UrlMapping"; -import { PageEvent, RendererEvent } from "../../events"; +import type { PageEvent } from "../../events"; import type { MarkedPlugin } from "../../plugins"; import { DefaultThemeRenderContext } from "./DefaultThemeRenderContext"; import { JSX } from "../../../utils"; @@ -44,12 +44,8 @@ export class DefaultTheme extends Theme { /** @internal */ markedPlugin: MarkedPlugin; - private _renderContext?: DefaultThemeRenderContext; - getRenderContext(_pageEvent: PageEvent) { - if (!this._renderContext) { - this._renderContext = new DefaultThemeRenderContext(this, this.application.options); - } - return this._renderContext; + getRenderContext(pageEvent: PageEvent) { + return new DefaultThemeRenderContext(this, pageEvent, this.application.options); } reflectionTemplate = (pageEvent: PageEvent) => { @@ -58,10 +54,15 @@ export class DefaultTheme extends Theme { indexTemplate = (pageEvent: PageEvent) => { return this.getRenderContext(pageEvent).indexTemplate(pageEvent); }; - defaultLayoutTemplate = (pageEvent: PageEvent) => { - return this.getRenderContext(pageEvent).defaultLayout(pageEvent); + defaultLayoutTemplate = (pageEvent: PageEvent, template: RenderTemplate>) => { + return this.getRenderContext(pageEvent).defaultLayout(template, pageEvent); }; + getReflectionClasses(reflection: DeclarationReflection) { + const filters = this.application.options.getValue("visibilityFilters") as Record; + return getReflectionClasses(reflection, filters); + } + /** * Mappings of reflections kinds to templates used by this theme. */ @@ -114,7 +115,6 @@ export class DefaultTheme extends Theme { constructor(renderer: Renderer) { super(renderer); this.markedPlugin = renderer.getComponent("marked") as MarkedPlugin; - this.listenTo(renderer, RendererEvent.BEGIN, this.onRendererBegin, 1024); } /** @@ -130,6 +130,11 @@ export class DefaultTheme extends Theme { if (false == hasReadme(this.application.options.getValue("readme"))) { project.url = "index.html"; urls.push(new UrlMapping("index.html", project, this.reflectionTemplate)); + } else if (project.children?.every((child) => child.kindOf(ReflectionKind.Module))) { + // If there are no non-module children, then there's no point in having a modules page since there + // will be nothing on it besides the navigation, so redirect the module page to the readme page + project.url = "index.html"; + urls.push(new UrlMapping("index.html", project, this.indexTemplate)); } else { project.url = "modules.html"; urls.push(new UrlMapping("modules.html", project, this.reflectionTemplate)); @@ -145,20 +150,6 @@ export class DefaultTheme extends Theme { return urls; } - /** - * Triggered before the renderer starts rendering a project. - * - * @param event An event object describing the current render operation. - */ - private onRendererBegin(event: RendererEvent) { - const filters = this.application.options.getValue("visibilityFilters") as Record; - for (const reflection of Object.values(event.project.reflections)) { - if (reflection instanceof DeclarationReflection) { - DefaultTheme.applyReflectionClasses(reflection, filters); - } - } - } - /** * Return a url for the given reflection. * @@ -220,8 +211,8 @@ export class DefaultTheme extends Theme { return urls; } - render(page: PageEvent): string { - const templateOutput = this.defaultLayoutTemplate(page); + render(page: PageEvent, template: RenderTemplate>): string { + const templateOutput = this.defaultLayoutTemplate(page, template); return "" + JSX.renderElement(templateOutput); } @@ -249,67 +240,57 @@ export class DefaultTheme extends Theme { return true; }); } +} - /** - * Generate the css classes for the given reflection and apply them to the - * {@link DeclarationReflection.cssClasses} property. - * - * @param reflection The reflection whose cssClasses property should be generated. - */ - static applyReflectionClasses(reflection: DeclarationReflection, filters: Record) { - const classes: string[] = []; +function hasReadme(readme: string) { + return !readme.endsWith("none"); +} - classes.push(DefaultTheme.toStyleClass("tsd-kind-" + ReflectionKind[reflection.kind])); +function toStyleClass(str: string) { + return str.replace(/(\w)([A-Z])/g, (_m, m1, m2) => m1 + "-" + m2).toLowerCase(); +} - if (reflection.parent && reflection.parent instanceof DeclarationReflection) { - classes.push(DefaultTheme.toStyleClass(`tsd-parent-kind-${ReflectionKind[reflection.parent.kind]}`)); - } +function getReflectionClasses(reflection: DeclarationReflection, filters: Record) { + const classes: string[] = []; - // Filter classes should match up with the settings function in - // partials/navigation.tsx. - for (const key of Object.keys(filters)) { - if (key === "inherited") { - if (reflection.inheritedFrom) { - classes.push("tsd-is-inherited"); - } - } else if (key === "protected") { - if (reflection.flags.isProtected) { - classes.push("tsd-is-protected"); - } - } else if (key === "private") { - if (reflection.flags.isPrivate) { - classes.push("tsd-is-private"); - } - } else if (key === "external") { - if (reflection.flags.isExternal) { - classes.push("tsd-is-external"); - } - } else if (key.startsWith("@")) { - if (key === "@deprecated") { - if (reflection.isDeprecated()) { - classes.push(DefaultTheme.toStyleClass(`tsd-is-${key.substring(1)}`)); - } - } else if ( - reflection.comment?.hasModifier(key as `@${string}`) || - reflection.comment?.getTag(key as `@${string}`) - ) { - classes.push(DefaultTheme.toStyleClass(`tsd-is-${key.substring(1)}`)); - } - } - } + classes.push(toStyleClass("tsd-kind-" + ReflectionKind[reflection.kind])); - reflection.cssClasses = classes.join(" "); + if (reflection.parent && reflection.parent instanceof DeclarationReflection) { + classes.push(toStyleClass(`tsd-parent-kind-${ReflectionKind[reflection.parent.kind]}`)); } - /** - * Transform a space separated string into a string suitable to be used as a - * css class, e.g. "constructor method" > "constructor-method". - */ - static toStyleClass(str: string) { - return str.replace(/(\w)([A-Z])/g, (_m, m1, m2) => m1 + "-" + m2).toLowerCase(); + // Filter classes should match up with the settings function in + // partials/navigation.tsx. + for (const key of Object.keys(filters)) { + if (key === "inherited") { + if (reflection.inheritedFrom) { + classes.push("tsd-is-inherited"); + } + } else if (key === "protected") { + if (reflection.flags.isProtected) { + classes.push("tsd-is-protected"); + } + } else if (key === "private") { + if (reflection.flags.isPrivate) { + classes.push("tsd-is-private"); + } + } else if (key === "external") { + if (reflection.flags.isExternal) { + classes.push("tsd-is-external"); + } + } else if (key.startsWith("@")) { + if (key === "@deprecated") { + if (reflection.isDeprecated()) { + classes.push(toStyleClass(`tsd-is-${key.substring(1)}`)); + } + } else if ( + reflection.comment?.hasModifier(key as `@${string}`) || + reflection.comment?.getTag(key as `@${string}`) + ) { + classes.push(toStyleClass(`tsd-is-${key.substring(1)}`)); + } + } } -} -function hasReadme(readme: string) { - return !readme.endsWith("none"); + return classes.join(" "); } diff --git a/src/lib/output/themes/default/DefaultThemeRenderContext.ts b/src/lib/output/themes/default/DefaultThemeRenderContext.ts index 208255a92..aafc8e792 100644 --- a/src/lib/output/themes/default/DefaultThemeRenderContext.ts +++ b/src/lib/output/themes/default/DefaultThemeRenderContext.ts @@ -1,7 +1,8 @@ -import type { RendererHooks } from "../.."; +import type { PageEvent, RendererHooks } from "../.."; import { Comment, CommentDisplayPart, + DeclarationReflection, ReferenceType, Reflection, } from "../../../models"; @@ -27,9 +28,10 @@ import { memberSources } from "./partials/member.sources"; import { members } from "./partials/members"; import { membersGroup } from "./partials/members.group"; import { + sidebar, + pageSidebar, navigation, - primaryNavigation, - secondaryNavigation, + pageNavigation, settings, sidebarLinks, } from "./partials/navigation"; @@ -48,7 +50,11 @@ function bind(fn: (f: F, ...a: L) => R, first: F) { export class DefaultThemeRenderContext { options: Options; - constructor(private theme: DefaultTheme, options: Options) { + constructor( + private theme: DefaultTheme, + public page: PageEvent, + options: Options + ) { this.options = options; } @@ -58,21 +64,28 @@ export class DefaultThemeRenderContext { this.theme.owner.hooks.emit(name, this); /** Avoid this in favor of urlTo if possible */ - relativeURL = (url: string | undefined) => { - return url ? this.theme.markedPlugin.getRelativeUrl(url) : url; + relativeURL = (url: string, cacheBust = false) => { + const result = this.theme.markedPlugin.getRelativeUrl(url); + if (cacheBust && this.theme.owner.cacheBust) { + return result + `?cache=${this.theme.owner.renderStartTime}`; + } + return result; }; - urlTo = (reflection: Reflection) => this.relativeURL(reflection.url)!; + urlTo = (reflection: Reflection) => { + return reflection.url ? this.relativeURL(reflection.url) : ""; + }; markdown = ( md: readonly CommentDisplayPart[] | NeverIfInternal ) => { if (md instanceof Array) { return this.theme.markedPlugin.parseMarkdown( - Comment.displayPartsToMarkdown(md, this.urlTo) + Comment.displayPartsToMarkdown(md, this.urlTo), + this.page ); } - return md ? this.theme.markedPlugin.parseMarkdown(md) : ""; + return md ? this.theme.markedPlugin.parseMarkdown(md, this.page) : ""; }; /** @@ -84,6 +97,9 @@ export class DefaultThemeRenderContext { return (type as ReferenceType).externalUrl; }; + getReflectionClasses = (refl: DeclarationReflection) => + this.theme.getReflectionClasses(refl); + reflectionTemplate = bind(reflectionTemplate, this); indexTemplate = bind(indexTemplate, this); defaultLayout = bind(defaultLayout, this); @@ -105,11 +121,12 @@ export class DefaultThemeRenderContext { memberSources = bind(memberSources, this); members = bind(members, this); membersGroup = bind(membersGroup, this); - navigation = bind(navigation, this); + sidebar = bind(sidebar, this); + pageSidebar = bind(pageSidebar, this); sidebarLinks = bind(sidebarLinks, this); settings = bind(settings, this); - primaryNavigation = bind(primaryNavigation, this); - secondaryNavigation = bind(secondaryNavigation, this); + navigation = bind(navigation, this); + pageNavigation = bind(pageNavigation, this); parameter = bind(parameter, this); toolbar = bind(toolbar, this); type = bind(type, this); diff --git a/src/lib/output/themes/default/assets/bootstrap.ts b/src/lib/output/themes/default/assets/bootstrap.ts index 6226ace64..568f4439c 100644 --- a/src/lib/output/themes/default/assets/bootstrap.ts +++ b/src/lib/output/themes/default/assets/bootstrap.ts @@ -1,5 +1,4 @@ import { Application, registerComponent } from "./typedoc/Application"; -import { MenuHighlight } from "./typedoc/components/MenuHighlight"; import { initSearch } from "./typedoc/components/Search"; import { Toggle } from "./typedoc/components/Toggle"; import { Filter } from "./typedoc/components/Filter"; @@ -8,7 +7,6 @@ import { initTheme } from "./typedoc/Theme"; initSearch(); -registerComponent(MenuHighlight, ".menu-highlight"); registerComponent(Toggle, "a[data-toggle]"); registerComponent(Accordion, ".tsd-index-accordion"); registerComponent(Filter, ".tsd-filter-item input[type=checkbox]"); @@ -18,6 +16,6 @@ if (themeChoice) { initTheme(themeChoice as HTMLOptionElement); } -const app: Application = new Application(); +const app = new Application(); Object.defineProperty(window, "app", { value: app }); diff --git a/src/lib/output/themes/default/assets/typedoc/Application.ts b/src/lib/output/themes/default/assets/typedoc/Application.ts index 176cb8baf..1e2b04338 100644 --- a/src/lib/output/themes/default/assets/typedoc/Application.ts +++ b/src/lib/output/themes/default/assets/typedoc/Application.ts @@ -37,6 +37,7 @@ export class Application { */ constructor() { this.createComponents(document.body); + this.ensureActivePageVisible(); this.ensureFocusedElementVisible(); window.addEventListener("hashchange", () => this.ensureFocusedElementVisible() @@ -61,6 +62,30 @@ export class Application { this.ensureFocusedElementVisible(); } + private ensureActivePageVisible() { + const pageLink = document.querySelector(".tsd-navigation .current"); + let iter = pageLink?.parentElement; + while (iter && !iter.classList.contains(".tsd-navigation")) { + // Expand parent namespaces if collapsed, don't expand current namespace + if ( + iter instanceof HTMLDetailsElement && + pageLink?.parentElement?.parentElement !== iter + ) { + iter.open = true; + } + iter = iter.parentElement; + } + + if (pageLink) { + const top = + pageLink.getBoundingClientRect().top - + document.documentElement.clientHeight / 4; + // If we are showing three columns, this will scroll the site menu down to + // show the page we just loaded in the navigation. + document.querySelector(".site-menu")!.scrollTop = top; + } + } + /** * Ensures that if a user was linked to a reflection which is hidden because of filter * settings, that reflection is still shown. @@ -72,6 +97,8 @@ export class Application { this.alwaysVisibleMember = null; } + if (!location.hash) return; + const reflAnchor = document.getElementById(location.hash.substring(1)); if (!reflAnchor) return; diff --git a/src/lib/output/themes/default/assets/typedoc/components/Accordion.ts b/src/lib/output/themes/default/assets/typedoc/components/Accordion.ts index e9ba76b96..e5b43e8d1 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Accordion.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Accordion.ts @@ -22,189 +22,26 @@ export class Accordion extends Component { */ private readonly key: string; - /** - * The ongoing animation, if there is one. - */ - private animation?: Animation; - - /** - * The accordion's height when collapsed. - */ - private collapsedHeight!: string; - - /** - * The accordion's height when expanded. - */ - private expandedHeight!: string; - constructor(options: IComponentOptions) { super(options); - this.calculateHeights(); this.summary = this.el.querySelector(".tsd-accordion-summary")!; this.icon = this.summary.querySelector("svg")!; - this.key = `tsd-accordion-${this.summary - .textContent!.replace(/\s+/g, "-") - .toLowerCase()}`; - - this.setLocalStorage(this.fromLocalStorage(), true); - this.summary.addEventListener("click", (e: MouseEvent) => - this.toggleVisibility(e) - ); - this.icon.style.transform = this.getIconRotation(); - } - - /** - * The transform that should be applied to the chevron based on the accordion's state. - */ - private getIconRotation(open = this.el.open) { - return `rotate(${open ? 0 : -90}deg)`; - } - - /** - * Calculates the accordion's expanded and collapsed heights. - * - * @returns The accordion's expanded and collapsed heights. - */ - private calculateHeights() { - const isOpen = this.el.open, - // Off-screen real quick for a flash of visibility. - { position, left } = this.el.style; - this.el.style.position = "fixed"; - this.el.style.left = "-9999px"; - // Height when open. - this.el.open = true; - this.expandedHeight = this.el.offsetHeight + "px"; - // Height when closed. - this.el.open = false; - this.collapsedHeight = this.el.offsetHeight + "px"; - // Back to normal. - this.el.open = isOpen; - this.el.style.height = isOpen - ? this.expandedHeight - : this.collapsedHeight; - this.el.style.position = position; - this.el.style.left = left; - } - - /** - * Triggered on accordion click. - * - * @param event The emitted mouse event. - */ - private toggleVisibility(event: MouseEvent) { - event.preventDefault(); - this.el.style.overflow = "hidden"; - - if (!this.el.open) { - this.expand(); - } else this.collapse(); - } + this.key = `tsd-accordion-${ + this.summary.dataset.key ?? + this.summary.textContent!.trim().replace(/\s+/g, "-").toLowerCase() + }`; - /** - * Expand the accordion. - */ - private expand(animate = true) { - this.el.open = true; - this.animate(this.collapsedHeight, this.expandedHeight, { - opening: true, - duration: animate ? 300 : 0, - }); - } - - /** - * Collapse the accordion. - */ - private collapse(animate = true) { - this.animate(this.expandedHeight, this.collapsedHeight, { - opening: false, - duration: animate ? 300 : 0, - }); - } - - /** - * Animate the accordion between open/close state. - * - * @param startHeight Height to begin at. - * @param endHeight Height to end at. - * @param isOpening Whether the accordion is opening or closing. - * @param duration The duration of the animation. - */ - private animate( - startHeight: string, - endHeight: string, - { opening, duration = 300 }: { opening: boolean; duration?: number } - ) { - if (this.animation) return; - const animationOptions = { duration, easing: "ease" }; - this.animation = this.el.animate( - { - height: [startHeight, endHeight], - }, - animationOptions - ); - this.icon - .animate( - { - transform: [ - this.icon.style.transform || - this.getIconRotation(!opening), - this.getIconRotation(opening), - ], - }, - animationOptions - ) - .addEventListener("finish", () => { - this.icon.style.transform = this.getIconRotation(opening); - }); - - this.animation.addEventListener("finish", () => - this.animationEnd(opening) - ); - } + const stored = storage.getItem(this.key); + this.el.open = stored ? stored === "true" : this.el.open; - /** - * Reset values upon animation end. - * - * @param isOpen Whether the accordion is now open. - */ - private animationEnd(isOpen: boolean) { - this.el.open = isOpen; - this.animation = undefined; - this.el.style.height = "auto"; - this.el.style.overflow = "visible"; - - this.setLocalStorage(isOpen); - } - - /** - * Retrieve value from storage. - */ - private fromLocalStorage(): boolean { - const fromLocalStorage = storage.getItem(this.key); - return fromLocalStorage ? fromLocalStorage === "true" : this.el.open; - } + this.el.addEventListener("toggle", () => this.update()); - /** - * Persist accordion state to local storage. - * - * @param value Value to set. - * @param force Whether to trigger value change even if the value is identical to the previous state. - */ - private setLocalStorage(value: boolean, force: boolean = false): void { - if (this.fromLocalStorage() === value && !force) return; - storage.setItem(this.key, value.toString()); - this.el.open = value; - this.handleValueChange(force); + this.update(); } - /** - * Synchronize DOM based on stored value. - * - * @param force Whether to force an animation. - */ - private handleValueChange(force: boolean = false): void { - if (this.fromLocalStorage() === this.el.open && !force) return; - this.fromLocalStorage() ? this.expand(false) : this.collapse(false); + private update() { + this.icon.style.transform = `rotate(${this.el.open ? 0 : -90}deg)`; + storage.setItem(this.key, this.el.open.toString()); } } diff --git a/src/lib/output/themes/default/assets/typedoc/components/MenuHighlight.ts b/src/lib/output/themes/default/assets/typedoc/components/MenuHighlight.ts deleted file mode 100644 index 558a43848..000000000 --- a/src/lib/output/themes/default/assets/typedoc/components/MenuHighlight.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Component, IComponentOptions } from "../Component"; -import { Viewport } from "../services/Viewport"; - -/** - * Stored element and position data of a single anchor. - */ -interface IAnchorInfo { - /** - * The anchor element. - */ - anchor: HTMLElement; - - /** - * The link element in the navigation representing this anchor. - */ - link: HTMLElement; - - /** - * The vertical offset of the anchor on the page. - */ - position: number; -} - -/** - * Manages the sticky state of the navigation and moves the highlight - * to the current navigation item. - */ -export class MenuHighlight extends Component { - /** - * List of all discovered anchors. - */ - private anchors: IAnchorInfo[] = []; - - /** - * Index of the currently highlighted anchor. - */ - private index: number = -1; - - /** - * Create a new MenuHighlight instance. - * - * @param options Backbone view constructor options. - */ - constructor(options: IComponentOptions) { - super(options); - - Viewport.instance.addEventListener("resize", () => this.onResize()); - Viewport.instance.addEventListener<{ scrollTop: number }>( - "scroll", - (e) => this.onScroll(e) - ); - - this.createAnchors(); - } - - /** - * Find all anchors on the current page. - */ - private createAnchors() { - let base = window.location.href; - if (base.indexOf("#") != -1) { - base = base.substring(0, base.indexOf("#")); - } - - this.el.querySelectorAll("a").forEach((el) => { - const href = el.href; - if (href.indexOf("#") == -1) return; - if (href.substring(0, base.length) != base) return; - - const hash = href.substring(href.indexOf("#") + 1); - const anchor = document.querySelector( - "a.tsd-anchor[name=" + hash + "]" - ); - const link = el.parentNode; - if (!anchor || !link) return; - - this.anchors.push({ - link: link as HTMLElement, - anchor: anchor, - position: 0, - }); - }); - - this.onResize(); - } - - /** - * Triggered after the viewport was resized. - */ - private onResize() { - let anchor: IAnchorInfo; - for ( - let index = 0, count = this.anchors.length; - index < count; - index++ - ) { - anchor = this.anchors[index]; - const rect = anchor.anchor.getBoundingClientRect(); - anchor.position = rect.top + document.body.scrollTop; - } - - this.anchors.sort((a, b) => { - return a.position - b.position; - }); - - const event = new CustomEvent("scroll", { - detail: { - scrollTop: Viewport.instance.scrollTop, - }, - }); - this.onScroll(event); - } - - /** - * Triggered after the viewport was scrolled. - * - * @param event The custom event with the current vertical scroll position. - */ - private onScroll(event: CustomEvent<{ scrollTop: number }>) { - const scrollTop = event.detail.scrollTop + 5; - const anchors = this.anchors; - const count = anchors.length - 1; - let index = this.index; - - while (index > -1 && anchors[index].position > scrollTop) { - index -= 1; - } - - while (index < count && anchors[index + 1].position < scrollTop) { - index += 1; - } - - if (this.index != index) { - if (this.index > -1) - this.anchors[this.index].link.classList.remove("focus"); - this.index = index; - if (this.index > -1) - this.anchors[this.index].link.classList.add("focus"); - } - } -} diff --git a/src/lib/output/themes/default/assets/typedoc/components/Search.ts b/src/lib/output/themes/default/assets/typedoc/components/Search.ts index 0fcc1a6e5..7f508d462 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Search.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -16,7 +16,6 @@ interface SearchDocument { } interface IData { - kinds: { [kind: number]: string }; rows: SearchDocument[]; index: object; } diff --git a/src/lib/output/themes/default/assets/typedoc/components/Toggle.ts b/src/lib/output/themes/default/assets/typedoc/components/Toggle.ts index 07f222f10..04d055ad8 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Toggle.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Toggle.ts @@ -49,7 +49,7 @@ export class Toggle extends Component { if (this.active) { if ( (e.target as HTMLElement).closest( - ".col-menu, .tsd-filter-group" + ".col-sidebar, .tsd-filter-group" ) ) { return; @@ -62,7 +62,7 @@ export class Toggle extends Component { onDocumentPointerUp(e: Event) { if (hasPointerMoved) return; if (this.active) { - if ((e.target as HTMLElement).closest(".col-menu")) { + if ((e.target as HTMLElement).closest(".col-sidebar")) { const link = (e.target as HTMLElement).closest("a"); if (link) { let href = window.location.href; diff --git a/src/lib/output/themes/default/assets/typedoc/services/Viewport.ts b/src/lib/output/themes/default/assets/typedoc/services/Viewport.ts deleted file mode 100644 index 0360340c3..000000000 --- a/src/lib/output/themes/default/assets/typedoc/services/Viewport.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { EventTarget } from "../EventTarget"; -import { throttle } from "../utils/throttle"; - -/** - * A global service that monitors the window size and scroll position. - */ -export class Viewport extends EventTarget { - public static readonly instance = new Viewport(); - - /** - * The current scroll position. - */ - scrollTop: number = 0; - - /** - * The previous scrollTop. - */ - lastY: number = 0; - - /** - * The width of the window. - */ - width: number = 0; - - /** - * The height of the window. - */ - height: number = 0; - - /** - * The toolbar (contains the search input). - */ - toolbar: HTMLDivElement; - - /** - * Boolean indicating whether the toolbar is shown. - */ - showToolbar: boolean = true; - - /** - * The side nav that contains members of the current page. - */ - navigation: HTMLElement; - - searchInput: HTMLInputElement | null; - - /** - * Create new Viewport instance. - */ - constructor() { - super(); - - this.toolbar = ( - document.querySelector(".tsd-page-toolbar") - ); - this.navigation = document.querySelector(".col-menu"); - - window.addEventListener( - "scroll", - throttle(() => this.onScroll(), 10) - ); - window.addEventListener( - "resize", - throttle(() => this.onResize(), 10) - ); - - this.searchInput = - document.querySelector("#tsd-search input"); - - if (this.searchInput) { - this.searchInput.addEventListener("focus", () => { - this.hideShowToolbar(); - }); - } - - this.onResize(); - this.onScroll(); - } - - /** - * Trigger a resize event. - */ - triggerResize() { - const event = new CustomEvent("resize", { - detail: { - width: this.width, - height: this.height, - }, - }); - - this.dispatchEvent(event); - } - - /** - * Triggered when the size of the window has changed. - */ - onResize() { - this.width = window.innerWidth || 0; - this.height = window.innerHeight || 0; - - const event = new CustomEvent("resize", { - detail: { - width: this.width, - height: this.height, - }, - }); - - this.dispatchEvent(event); - } - - /** - * Triggered when the user scrolled the viewport. - */ - onScroll() { - this.scrollTop = window.scrollY || 0; - - const event = new CustomEvent("scroll", { - detail: { - scrollTop: this.scrollTop, - }, - }); - - this.dispatchEvent(event); - this.hideShowToolbar(); - } - - /** - * Handle hiding/showing of the toolbar. - */ - hideShowToolbar() { - const isShown = this.showToolbar; - this.showToolbar = - this.lastY >= this.scrollTop || - this.scrollTop <= 0 || - (!!this.searchInput && this.searchInput === document.activeElement); - if (isShown !== this.showToolbar) { - this.toolbar.classList.toggle("tsd-page-toolbar--hide"); - this.navigation?.classList.toggle("col-menu--hide"); - } - this.lastY = this.scrollTop; - } -} diff --git a/src/lib/output/themes/default/layouts/default.tsx b/src/lib/output/themes/default/layouts/default.tsx index 5f33d4c63..f1ba985c8 100644 --- a/src/lib/output/themes/default/layouts/default.tsx +++ b/src/lib/output/themes/default/layouts/default.tsx @@ -1,28 +1,35 @@ +import type { RenderTemplate } from "../../.."; import type { Reflection } from "../../../../models"; import { JSX, Raw } from "../../../../utils"; import type { PageEvent } from "../../../events"; +import { getDisplayName } from "../../lib"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -export const defaultLayout = (context: DefaultThemeRenderContext, props: PageEvent) => ( +export const defaultLayout = ( + context: DefaultThemeRenderContext, + template: RenderTemplate>, + props: PageEvent +) => ( {context.hook("head.begin")} - {props.model.name === props.project.name - ? props.project.name - : `${props.model.name} | ${props.project.name}`} + {props.model.isProject() + ? getDisplayName(props.model) + : `${getDisplayName(props.model)} | ${getDisplayName(props.project)}`} - - + + {context.options.getValue("customCss") && ( - + )} - + + {context.hook("head.end")} @@ -33,23 +40,29 @@ export const defaultLayout = (context: DefaultThemeRenderContext, props: PageEve {context.toolbar(props)}
-
+
{context.hook("content.begin")} {context.header(props)} - {props.template(props)} + {template(props)} {context.hook("content.end")}
- {context.footer()}
- {context.analytics()} {context.hook("body.end")} diff --git a/src/lib/output/themes/default/partials/anchor-icon.tsx b/src/lib/output/themes/default/partials/anchor-icon.tsx index 75cf431e9..0b376ce52 100644 --- a/src/lib/output/themes/default/partials/anchor-icon.tsx +++ b/src/lib/output/themes/default/partials/anchor-icon.tsx @@ -1,8 +1,12 @@ import { JSX } from "../../../../utils"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -export const anchorIcon = (context: DefaultThemeRenderContext, anchor: string | undefined) => ( - - {context.icons.anchor()} - -); +export function anchorIcon(context: DefaultThemeRenderContext, anchor: string | undefined) { + if (!anchor) return <>; + + return ( + + {context.icons.anchor()} + + ); +} diff --git a/src/lib/output/themes/default/partials/footer.tsx b/src/lib/output/themes/default/partials/footer.tsx index aa16ae5dc..d1d1613b2 100644 --- a/src/lib/output/themes/default/partials/footer.tsx +++ b/src/lib/output/themes/default/partials/footer.tsx @@ -5,7 +5,7 @@ export function footer(context: DefaultThemeRenderContext) { const hideGenerator = context.options.getValue("hideGenerator"); if (!hideGenerator) return ( -
+

{"Generated using "} diff --git a/src/lib/output/themes/default/partials/header.tsx b/src/lib/output/themes/default/partials/header.tsx index 904e44691..e5fa0761c 100644 --- a/src/lib/output/themes/default/partials/header.tsx +++ b/src/lib/output/themes/default/partials/header.tsx @@ -1,8 +1,8 @@ -import { hasTypeParameters, join, renderFlags } from "../../lib"; +import { getDisplayName, hasTypeParameters, join, renderFlags } from "../../lib"; import { JSX } from "../../../../utils"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; import type { PageEvent } from "../../../events"; -import { DeclarationReflection, Reflection, ReflectionKind } from "../../../../models"; +import { Reflection, ReflectionKind } from "../../../../models"; export const header = (context: DefaultThemeRenderContext, props: PageEvent) => { const HeadingLevel = props.model.isProject() ? "h2" : "h1"; @@ -10,11 +10,8 @@ export const header = (context: DefaultThemeRenderContext, props: PageEvent {!!props.model.parent &&

} - {props.model.kind !== ReflectionKind.Project && `${props.model.kindString ?? ""} `} - {props.model.name} - {props.model instanceof DeclarationReflection && - props.model.version !== undefined && - ` - v${props.model.version}`} + {props.model.kind !== ReflectionKind.Project && `${ReflectionKind.singularString(props.model.kind)} `} + {getDisplayName(props.model)} {hasTypeParameters(props.model) && ( <> {"<"} diff --git a/src/lib/output/themes/default/partials/index.tsx b/src/lib/output/themes/default/partials/index.tsx index d7fcb4ef7..dc72cee95 100644 --- a/src/lib/output/themes/default/partials/index.tsx +++ b/src/lib/output/themes/default/partials/index.tsx @@ -3,7 +3,11 @@ import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; import { JSX, Raw } from "../../../../utils"; import { ContainerReflection, DeclarationReflection, ReflectionCategory, ReflectionKind } from "../../../../models"; -function renderCategory({ urlTo, icons }: DefaultThemeRenderContext, item: ReflectionCategory, prependName = "") { +function renderCategory( + { urlTo, icons, getReflectionClasses }: DefaultThemeRenderContext, + item: ReflectionCategory, + prependName = "" +) { return (

{prependName ? `${prependName} - ${item.title}` : item.title}

@@ -14,7 +18,7 @@ function renderCategory({ urlTo, icons }: DefaultThemeRenderContext, item: Refle href={urlTo(item)} class={classNames( { "tsd-index-link": true, deprecated: item.isDeprecated() }, - item.cssClasses + getReflectionClasses(item) )} > {icons[item.kind]()} diff --git a/src/lib/output/themes/default/partials/member.getterSetter.tsx b/src/lib/output/themes/default/partials/member.getterSetter.tsx index 48f93d9e9..e1305b61e 100644 --- a/src/lib/output/themes/default/partials/member.getterSetter.tsx +++ b/src/lib/output/themes/default/partials/member.getterSetter.tsx @@ -1,10 +1,18 @@ -import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -import { JSX } from "../../../../utils"; import type { DeclarationReflection } from "../../../../models"; +import { JSX } from "../../../../utils"; +import { classNames } from "../../lib"; +import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; export const memberGetterSetter = (context: DefaultThemeRenderContext, props: DeclarationReflection) => ( <> -
    +
      {!!props.getSignature && ( <>
    • diff --git a/src/lib/output/themes/default/partials/member.signatures.tsx b/src/lib/output/themes/default/partials/member.signatures.tsx index 1069781f3..4fb78bf23 100644 --- a/src/lib/output/themes/default/partials/member.signatures.tsx +++ b/src/lib/output/themes/default/partials/member.signatures.tsx @@ -2,10 +2,11 @@ import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; import { JSX } from "../../../../utils"; import type { DeclarationReflection } from "../../../../models"; import { anchorIcon } from "./anchor-icon"; +import { classNames } from "../../lib"; export const memberSignatures = (context: DefaultThemeRenderContext, props: DeclarationReflection) => ( <> -
        +
          {props.signatures?.map((item) => ( <>