diff --git a/.config/typedoc.json b/.config/typedoc.json index bff3e89f3..00b57a15c 100644 --- a/.config/typedoc.json +++ b/.config/typedoc.json @@ -15,6 +15,8 @@ ], "name": "TypeDoc API", + // Don't document the debug entry point + "entryPoints": ["../src/index.ts"], "outputs": [ { "name": "html", @@ -36,6 +38,7 @@ "categorizeByGroup": false, "categoryOrder": ["Reflections", "Types", "Comments", "*"], "groupOrder": ["Common", "Namespaces", "*"], + "hostedBaseUrl": "https://typedoc.org/example/", "navigationLinks": { "Docs": "https://typedoc.org", "Example": "https://typedoc.org/example/index.html", diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 708307afb..24cef424d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -22,6 +22,8 @@ labels: bug Note: Turn off skipErrorChecks before reporting a crash. Bug reports for crashes with that option on are out of scope. +Note: Please try to reproduce the issue WITHOUT any plugins enabled before reporting a bug. + If possible, please create a *minimal* repo reproducing your problem. If it is more than a single small file, please submit a pull request to https://github.com/TypeStrong/typedoc-repros diff --git a/.github/workflows/build-site.yml b/.github/workflows/build-site.yml index 502e1a80d..bd3ec6e86 100644 --- a/.github/workflows/build-site.yml +++ b/.github/workflows/build-site.yml @@ -1,5 +1,9 @@ name: Build Site -on: [push, pull_request] +on: + push: + pull_request: + schedule: + - cron: "0 5 * * *" jobs: build: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 556181779..7e5a27f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,62 @@ title: Changelog ## Unreleased +## v0.27.5 (2024-12-14) + +### Bug Fixes + +- Possibly Breaking: TypeDoc will no longer render anchors within the page for + deeply nested properties. This only affects links to properties of + properties of types, which did not have a clickable link exposed so are + unlikely to have been linked to. Furthermore, these links were not always + created by TypeDoc, only being created if all parent properties contained + comments, #2808. +- TypeDoc will now warn if a property which does not have a URL within the + rendered document and the parent property/page will be linked to instead, + #2808. These warnings can be disabled with the `validation.rewrittenLink` + option. +- Fix restoration of groups/categories including documents, #2801. +- Fixed missed relative paths within markdown link references in documents. +- Improved handling of incomplete inline code blocks within markdown. +- Direct `https://` links under the `hostedBaseUrl` option's URL will no + longer be treated as external, #2809. + +### Thanks! + +- @SacDeNoeuds + +## v0.27.4 (2024-12-09) + +### Features + +- API: Introduced new `Converter.EVENT_CREATE_PROJECT` event which fires when a project is created by the converter, #2800. + +### Bug Fixes + +- Switch from gzip to deflate for compressing assets to make output consistent across different operating systems, #2796. +- `@include` and `@includeCode` now work for comments on the entry point for projects with a single entry point, #2800. +- Cascaded modifier tags will no longer be copied into type literals, #2802. +- `@summary` now works to describe functions within modules, #2803. +- Corrected navigation showing module link as current when not on module page, #2805. + +## v0.27.3 (2024-12-04) + +### Features + +- Added support for PNG favicons, #2790. +- Improved support for hosting TypeDoc with strict Content Security Policy rules, #2794. + +### Bug Fixes + +- Add special handling for import types with type errors discarded with ts-expect-error, #2792. +- Fixed low contrast in default colors for properties/accessors in light mode, #2795. +- The `highlightLanguages` option now permits Shiki aliases to be specified rather than just the language ID, #2798. + +### Thanks! + +- @mikalai-snap +- @mistic100 + ## v0.27.2 (2024-11-29) ### Bug Fixes diff --git a/README.md b/README.md index a242a510b..4822f7697 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ Documentation generator for TypeScript projects. -[![CI](https://github.com/TypeStrong/typedoc/workflows/CI/badge.svg)](https://github.com/TypeStrong/typedoc/actions) -[![NPM Version](https://img.shields.io/npm/v/typedoc?color=33cd56&logo=npm)](https://www.npmjs.com/package/typedoc) - ## Documentation For more detailed documentation, the changelog, and TypeDoc documentation rendered with TypeDoc, see https://typedoc.org. diff --git a/eslint.config.mjs b/eslint.config.mjs index e745d7b4a..85634bdb9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -140,6 +140,7 @@ export default tslint.config( "eslint.config.mjs", "src/test/renderer/specs", "site/typedoc-plugin-redirect.js", + "site/site-plugin.js", "dist", "docs", "docs2", diff --git a/example/typedoc.json b/example/typedoc.json index 4d87a3a2c..1aaa2d34f 100644 --- a/example/typedoc.json +++ b/example/typedoc.json @@ -11,6 +11,7 @@ "searchGroupBoosts": { "Classes": 1.5 }, + "hostedBaseUrl": "https://typedoc.org/example/", "navigationLinks": { "Docs": "https://typedoc.org", "API": "https://typedoc.org/api/index.html", diff --git a/package-lock.json b/package-lock.json index ad245971c..fc6208551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "typedoc", - "version": "0.27.2", + "version": "0.27.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "typedoc", - "version": "0.27.2", + "version": "0.27.5", "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", diff --git a/package.json b/package.json index 8c48096b7..c482d304e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "typedoc", "description": "Create api documentation for TypeScript projects.", - "version": "0.27.2", + "version": "0.27.5", "homepage": "https://typedoc.org", "type": "module", "exports": { ".": "./dist/index.js", "./tsdoc.json": "./tsdoc.json", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./debug": "./dist/lib/debug/index.js" }, "types": "./dist/index.d.ts", "bin": { diff --git a/scripts/capture_screenshots.mjs b/scripts/capture_screenshots.mjs index 3e01a5da7..07f213368 100644 --- a/scripts/capture_screenshots.mjs +++ b/scripts/capture_screenshots.mjs @@ -93,6 +93,9 @@ export async function captureScreenshots( headless, theme, ) { + await fs.promises.rm(outputDirectory, { force: true, recursive: true }); + await fs.promises.mkdir(outputDirectory, { recursive: true }); + const browser = await puppeteer.launch({ args: platform() === "win32" diff --git a/site/declaration-references.md b/site/declaration-references.md index 494418400..2e5668f3a 100644 --- a/site/declaration-references.md +++ b/site/declaration-references.md @@ -4,9 +4,10 @@ title: Declaration References # Declaration References -Note: If [--useTsLinkResolution](options/comments.md#usetslinkresolution) is turned on (the default) this page likely -**does not apply** for your links. Declaration references are used only if that option is off or TypeScript -fails to resolve a link. +> [!note] +> If [--useTsLinkResolution](options/comments.md#usetslinkresolution) is turned on (the default) this page likely +> **does not apply** for your links. Declaration references are used only if that option is off or TypeScript +> fails to resolve a link. Some tags like [`{@link}`](tags/link.md) and [`{@inheritDoc}`](tags/inheritDoc.md) can refer to other members of the documentation. These tags use declaration references to name another declaration. diff --git a/site/development/plugins.md b/site/development/plugins.md index 093872137..02a14d5b5 100644 --- a/site/development/plugins.md +++ b/site/development/plugins.md @@ -13,7 +13,8 @@ to. Plugins should assume that they may be loaded multiple times for different applications, and that a single load of an application class may be used to convert multiple projects. -Plugins may be either ESM or CommonJS. +Plugins may be either ESM or CommonJS, but TypeDoc ships with ESM, so they should +generally published as ESM to avoid `require(esm)` experimental warnings. ```js // @ts-check diff --git a/site/development/themes.md b/site/development/themes.md index 8f6d02333..ec32ffc19 100644 --- a/site/development/themes.md +++ b/site/development/themes.md @@ -102,4 +102,34 @@ export function load(app: Application) { } ``` +## Registering your own custom elements/attributes + +Custom JSX elements can be defined by merging with TypeDoc's `IntrinsicElements` +interface. TypeScript will pick up properties of this interface as valid element +names. + +```ts +import { Application, JSX } from "typedoc"; + +declare module "typedoc" { + // JSX.JSX is intentional due to TypeScript's strange JSX type discovery rules + namespace JSX.JSX { + interface IntrinsicElements { + "custom-button": IntrinsicAttributes & { + target: string; + }; + } + + // Generally shouldn't be necessary, TypeDoc contains an interface + // with all attributes documented on MDN. Properties added here will + // be permitted on all JSX elements. + interface IntrinsicAttributes { + customGlobalAttribute?: string; + } + } +} + +export function load(app: Application) {} +``` + [RendererHooks]: https://typedoc.org/api/interfaces/RendererHooks.html diff --git a/site/options.md b/site/options.md index 584ec0b30..3a8413cc4 100644 --- a/site/options.md +++ b/site/options.md @@ -13,148 +13,43 @@ children: Any command line arguments that are passed without a flag will be parsed as entry points. Any options passed on the command line will override options set in a configuration file. - - ## Configuration Options Options which control what files TypeDoc reads. -- [options](options/configuration.md#options) -- [tsconfig](options/configuration.md#tsconfig) -- [compilerOptions](options/configuration.md#compileroptions) -- [plugin](options/configuration.md#plugin) +{@listOptions options/configuration.md} ## Input Options Options which control how input is converted into a project that can be rendered to HTML or JSON. -- [entryPoints](options/input.md#entrypoints) -- [entryPointStrategy](options/input.md#entrypointstrategy) -- [packageOptions](options/input.md#packageoptions) -- [alwaysCreateEntryPointModule](options/input.md#alwayscreateentrypointmodule) -- [projectDocuments](options/input.md#projectdocuments) -- [exclude](options/input.md#exclude) -- [externalPattern](options/input.md#externalpattern) -- [excludeExternals](options/input.md#excludeexternals) -- [excludeNotDocumented](options/input.md#excludenotdocumented) -- [excludeNotDocumentedKinds](options/input.md#excludenotdocumentedkinds) -- [excludeInternal](options/input.md#excludeinternal) -- [excludePrivate](options/input.md#excludeprivate) -- [excludeProtected](options/input.md#excludeprotected) -- [excludeReferences](options/input.md#excludereferences) -- [excludeCategories](options/input.md#excludecategories) -- [maxTypeConversionDepth](options/input.md#maxtypeconversiondepth) -- [name](options/input.md#name) -- [includeVersion](options/input.md#includeversion) -- [disableSources](options/input.md#disablesources) -- [sourceLinkTemplate](options/input.md#sourcelinktemplate) -- [gitRevision](options/input.md#gitrevision) -- [gitRemote](options/input.md#gitremote) -- [disableGit](options/input.md#disablegit) -- [readme](options/input.md#readme) -- [includeHierarchySummary](options/input.md#includehierarchysummary) +{@listOptions options/input.md} ## Output Options Options which control TypeDoc's HTML output. -- [outputs](options/output.md#outputs) -- [out](options/output.md#out) -- [html](options/output.md#html) -- [json](options/output.md#json) -- [pretty](options/output.md#pretty) -- [emit](options/output.md#emit) -- [theme](options/output.md#theme) -- [lightHighlightTheme](options/output.md#lighthighlighttheme) -- [darkHighlightTheme](options/output.md#darkhighlighttheme) -- [highlightLanguages](options/output.md#highlightlanguages) -- [typePrintWidth](options/output.md#typeprintwidth) -- [customCss](options/output.md#customcss) -- [customJs](options/output.md#customjs) -- [customFooterHtml](options/output.md#customfooterhtml) -- [customFooterHtmlDisableWrapper](options/output.md#customfooterhtmldisablewrapper) -- [markdownItOptions](options/output.md#markdownitoptions) -- [markdownItLoader](options/output.md#markdownitloader) -- [basePath](options/output.md#basepath) -- [cname](options/output.md#cname) -- [favicon](options/output.md#favicon) -- [sourceLinkExternal](options/output.md#sourcelinkexternal) -- [markdownLinkExternal](options/output.md#markdownlinkexternal) -- [lang](options/output.md#lang) -- [locales](options/output.md#locales) -- [githubPages](options/output.md#githubpages) -- [cacheBust](options/output.md#cachebust) -- [hideGenerator](options/output.md#hidegenerator) -- [searchInComments](options/output.md#searchincomments) -- [searchInDocuments](options/output.md#searchindocuments) -- [cleanOutputDir](options/output.md#cleanoutputdir) -- [titleLink](options/output.md#titlelink) -- [navigationLinks](options/output.md#navigationlinks) -- [sidebarLinks](options/output.md#sidebarlinks) -- [navigation](options/output.md#navigation) -- [headings](options/output.md#headings) -- [sluggerConfiguration](options/output.md#sluggerconfiguration) -- [navigationLeaves](options/output.md#navigationleaves) -- [visibilityFilters](options/output.md#visibilityfilters) -- [searchCategoryBoosts](options/output.md#searchcategoryboosts) -- [searchGroupBoosts](options/output.md#searchgroupboosts) -- [hostedBaseUrl](options/output.md#hostedbaseurl) -- [useHostedBaseUrlForAbsoluteLinks](options/output.md#usehostedbaseurlforabsolutelinks) -- [useFirstParagraphOfCommentAsSummary](options/output.md#usefirstparagraphofcommentassummary) +{@listOptions options/output.md} ## Comment Options Options which control how TypeDoc parses comments. -- [commentStyle](options/comments.md#commentstyle) -- [useTsLinkResolution](options/comments.md#usetslinkresolution) -- [preserveLinkText](options/comments.md#preservelinktext) -- [jsDocCompatibility](options/comments.md#jsdoccompatibility) -- [suppressCommentWarningsInDeclarationFiles](options/comments.md#suppresscommentwarningsindeclarationfiles) -- [blockTags](options/comments.md#blocktags) -- [inlineTags](options/comments.md#inlinetags) -- [modifierTags](options/comments.md#modifiertags) -- [cascadedModifierTags](options/comments.md#cascadedmodifiertags) -- [excludeTags](options/comments.md#excludetags) -- [notRenderedTags](options/comments.md#notrenderedtags) -- [externalSymbolLinkMappings](options/comments.md#externalsymbollinkmappings) +{@listOptions options/comments.md} ## Organization Options Controls how TypeDoc organizes content within a converted project. -- [groupReferencesByType](options/organization.md#groupreferencesbytype) -- [categorizeByGroup](options/organization.md#categorizebygroup) -- [defaultCategory](options/organization.md#defaultcategory) -- [categoryOrder](options/organization.md#categoryorder) -- [groupOrder](options/organization.md#grouporder) -- [sort](options/organization.md#sort) -- [sortEntryPoints](options/organization.md#sortentrypoints) -- [kindSortOrder](options/organization.md#kindsortorder) +{@listOptions options/organization.md} ## Validation Options Configures the validation performed by TypeDoc on a converted project. -- [validation](options/validation.md#validation) -- [treatWarningsAsErrors](options/validation.md#treatwarningsaserrors) -- [treatValidationWarningsAsErrors](options/validation.md#treatvalidationwarningsaserrors) -- [intentionallyNotExported](options/validation.md#intentionallynotexported) -- [requiredToBeDocumented](options/validation.md#requiredtobedocumented) +{@listOptions options/validation.md} ## Other Options -- [watch](options/other.md#watch) -- [preserveWatchOutput](options/other.md#preservewatchoutput) -- [help](options/other.md#help) -- [version](options/other.md#version) -- [showConfig](options/other.md#showconfig) -- [logLevel](options/other.md#loglevel) -- [skipErrorChecking](options/other.md#skiperrorchecking) +{@listOptions options/other.md} diff --git a/site/options/input.md b/site/options/input.md index 156e8b157..8f1ec5550 100644 --- a/site/options/input.md +++ b/site/options/input.md @@ -90,7 +90,7 @@ Expects all entry points to be `.json` files generated with a previous run of Ty Options to set be set within each package when entryPointStrategy is set to packages. Unlike most options in TypeDoc, paths within this object are interpreted relative to the package directory. This option has no effect if -[entryPointStrategy](#entrypointstrategy) is not set to `packages. +[entryPointStrategy](#entrypointstrategy) is not set to `packages`. ## alwaysCreateEntryPointModule diff --git a/site/options/output.md b/site/options/output.md index 626a8b8ba..4e280f7e1 100644 --- a/site/options/output.md +++ b/site/options/output.md @@ -75,9 +75,8 @@ output produced by running TypeDoc on itself can be seen at [TypeDoc API](https: This option is an output shortcut. If specified, the [outputs](#outputs) option will be overwritten by this option and any other specified output shortcuts. -This entire site is generated using TypeDoc's [external -document](../external-documents.md) support to include markdown documents -alongside the API documentation. +This entire site is generated using TypeDoc's [external document](../external-documents.md) +support to include markdown documents alongside the API documentation. ## json @@ -262,7 +261,7 @@ Create a CNAME file in the output directory with the specified text. $ typedoc --favicon favicon.ico ``` -Specify a `favicon.ico` or `favicon.svg` file to reference as the site favicon. +Specify a `.ico`, `.png` or `.svg` file to reference as the site favicon. ## sourceLinkExternal diff --git a/site/options/validation.md b/site/options/validation.md index c2cfd2731..00fc6810f 100644 --- a/site/options/validation.md +++ b/site/options/validation.md @@ -20,13 +20,32 @@ typedoc.json (defaults): "validation": { "notExported": true, "invalidLink": true, + "rewrittenLink": true, "notDocumented": false, "unusedMergeModuleWith": true } } ``` -Specifies validation steps TypeDoc should perform on your generated documentation. +Specifies validation steps TypeDoc should perform on your generated +documentation. Most validation occurs before rendering, but `rewrittenLink` is +done during HTML rendering as links have not been generated before rendering +begins. + +- **notExported** - Produce warnings if a type is referenced by the + documentation but the type isn't exported and therefore included in the + documentation. +- **invalidLink** - Produce warnings for `@link` tags which cannot be resolved. +- **rewrittenLink** - Produce warnings for `@link` tags which are resolved, + but whose target does not have a unique URL in the documentation. TypeDoc + will rewrite these links to point to the first parent with a URL. +- **notDocumented** - Produce warnings for reflections which do not have a + documentation comment. This is also controlled by the + [requiredToBeDocumented](#requiredtobedocumented) option. +- **unusedMergeModuleWith** - Produce warnings for + [`@mergeModuleWith`](../tags/mergeModuleWith.md) tags which are not + resolved. This option should generally be disabled if generating JSON which + will be combined with another document later. ## treatWarningsAsErrors diff --git a/site/site-plugin.js b/site/site-plugin.js new file mode 100644 index 000000000..006e98e97 --- /dev/null +++ b/site/site-plugin.js @@ -0,0 +1,89 @@ +// @ts-check +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Application, Converter, OptionDefaults } from "typedoc"; +/** @import { CommentDisplayPart, FileRegistry, TranslatedString} from "typedoc" */ + +/** @param {Application} app */ +export function load(app) { + app.on(Application.EVENT_BOOTSTRAP_END, () => { + app.options.setValue("inlineTags", [ + ...OptionDefaults.inlineTags, + "@listOptions", + ]); + }); + + app.converter.on(Converter.EVENT_CREATE_DOCUMENT, (_ctx, doc) => { + // Known we have this as documents always have a file path + const fileName = /** @type {string} */ ( + doc.project.files.getReflectionPath(doc) + ); + + replaceListOptions(fileName, doc.content, doc.project.files); + }); + + /** + * + * @param {string} sourceFile + * @param {CommentDisplayPart[]} parts + * @param {FileRegistry} files + */ + function replaceListOptions(sourceFile, parts, files) { + for (let i = 0; i < parts.length; ++i) { + const part = parts[i]; + if (part.kind === "inline-tag" && part.tag === "@listOptions") { + parts.splice( + i, + 1, + ...buildListOptions(sourceFile, part.text.trim(), files), + ); + } + } + } + + /** + * @param {string} sourceFile + * @param {string} userPath + * @param {FileRegistry} files + * @returns {CommentDisplayPart[]} + */ + function buildListOptions(sourceFile, userPath, files) { + const file = join(dirname(sourceFile), userPath.trim()); + + /** @type {string[]} */ + const headings = []; + const content = readFileSync(file, "utf-8"); + for (const line of content.split("\n")) { + if (line.startsWith("## ")) { + headings.push(line.substring(3).trim()); + } + } + + /** @type {CommentDisplayPart[]} */ + const result = []; + + for (const heading of headings) { + result.push({ kind: "text", text: `- [${heading}](` }); + const text = userPath + "#" + heading.toLowerCase(); + const relPath = files.register(sourceFile, text); + if (!relPath) { + app.logger.warn( + /** @type {TranslatedString} */ ( + `@listOptions specified a file "${text}" which does not exist` + ), + ); + return []; + } + + result.push({ + kind: "relative-link", + target: relPath?.target, + targetAnchor: relPath?.anchor, + text, + }); + result.push({ kind: "text", text: ")\n" }); + } + + return result; + } +} diff --git a/site/tags/summary.md b/site/tags/summary.md index ea42f12c7..9ab84111a 100644 --- a/site/tags/summary.md +++ b/site/tags/summary.md @@ -16,6 +16,9 @@ If an `@summary` tag is not specified and `--useFirstParagraphOfCommentAsSummary specified, TypeDoc will use the first paragraph of the comment as the short summary to include on the modules page. +For overloaded functions, the `@summary` tag may be placed on the comment for the +first signature or on the comment for the function implementation. + ## Example ```ts diff --git a/site/typedoc.config.jsonc b/site/typedoc.config.jsonc index cf339a0f9..f684eef29 100644 --- a/site/typedoc.config.jsonc +++ b/site/typedoc.config.jsonc @@ -7,7 +7,7 @@ "alwaysCreateEntryPointModule": true, "out": "../docs-site", "name": "TypeDoc", - "plugin": ["./typedoc-plugin-redirect.js"], + "plugin": ["./typedoc-plugin-redirect.js", "./site-plugin.js"], "readme": "index.md", "projectDocuments": [ @@ -47,9 +47,12 @@ "notExported": false, }, + "hostedBaseUrl": "https://typedoc.org/", "redirects": { + "guides/": "documents/Overview.html", "guides/overview/": "documents/Overview.html", "guides/installation/": "index.html", + "guides/options/": "documents/Options.html", "options/": "documents/Options.html", "options/configuration/": "documents/Options.Configuration.html", "options/input/": "documents/Options.Input.html", @@ -58,13 +61,13 @@ "options/organization/": "documents/Options.Organization.html", "options/validation/": "documents/Options.Validation.html", "options/other/": "documents/Options.Other.html", - // cspell: words doccoments - "guides/doccoments/": "documents/Doc_Comments.html", + // cspell: words doccomments + "guides/doccomments/": "documents/Doc_Comments.html", "guides/documents/": "documents/External_Documents.html", "guides/themes/": "documents/Themes.html", "guides/plugins/": "documents/Plugins.html", "guides/declaration-references/": "documents/Declaration_References.html", - "guides/development": "documents/Development.html", + "guides/development/": "documents/Development.html", "guides/changelog/": "documents/Changelog.html", // Tags @@ -86,11 +89,11 @@ "tags/hidden/": "documents/Tags._hidden.html", "tags/hideconstructor/": "documents/Tags._hideconstructor.html", "tags/ignore/": "documents/Tags._ignore.html", - "tags/inheritDoc/": "documents/Tags._inheritDoc.html", + "tags/inheritDoc/": "documents/Tags.__inheritDoc_.html", "tags/interface/": "documents/Tags._interface.html", "tags/internal/": "documents/Tags._internal.html", - "tags/label/": "documents/Tags._label.html", - "tags/link/": "documents/Tags._link.html", + "tags/label/": "documents/Tags.__label_.html", + "tags/link/": "documents/Tags.__link_.html", "tags/module/": "documents/Tags._module.html", "tags/namespace/": "documents/Tags._namespace.html", "tags/overload/": "documents/Tags._overload.html", @@ -105,7 +108,7 @@ "tags/readonly/": "documents/Tags._readonly.html", "tags/remarks/": "documents/Tags._remarks.html", "tags/returns/": "documents/Tags._returns.html", - "tags/satisfies/": "documents/Tags._satisfies.html", + "tags/satisfies/": "documents/Tags.TypeScript_Tags.html", "tags/sealed/": "documents/Tags._sealed.html", "tags/see/": "documents/Tags._see.html", "tags/template/": "documents/Tags._template.html", diff --git a/src/index.ts b/src/index.ts index 677e5ce92..a0aa1a3c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ /** * @module TypeDoc API + * + * In addition to the members documented here, TypeDoc exports a `typedoc/debug` + * entry point which exports some functions which may be useful during plugin + * development or debugging. Exports from that entry point are **not stable** + * and may change or be removed at any time. */ export { Application, type ApplicationEvents } from "./lib/application.js"; diff --git a/src/lib/converter/comments/blockLexer.ts b/src/lib/converter/comments/blockLexer.ts index 4c7b0a095..63f13d655 100644 --- a/src/lib/converter/comments/blockLexer.ts +++ b/src/lib/converter/comments/blockLexer.ts @@ -147,18 +147,31 @@ function* lexBlockComment2( case "`": { // Markdown's code rules are a royal pain. This could be one of several things. - // 1. Inline code: <1-n ticks> - // 2. Code block: <3 ticks>\n\n<3 ticks>\n + // 1. Inline code: <1-n ticks> + // 2. Code block: <3+ ticks>\n\n<3 ticks>\n // 3. Unmatched tick(s), not code, but part of some text. // We don't quite handle #2 correctly yet. PR welcome! braceStartsType = false; let tickCount = 1; - let lookahead = pos; + + let lookahead = pos - 1; + let atNewline = true; + while (lookahead > 0 && file[lookahead] !== "\n") { + if (/\S/.test(file[lookahead])) { + if (!commentHasStars || file[lookahead] !== "*") { + atNewline = false; + break; + } + } + --lookahead; + } + lookahead = pos; while (lookahead + 1 < end && file[lookahead + 1] === "`") { tickCount++; lookahead++; } + const isCodeBlock = atNewline && tickCount >= 3; let lookaheadStart = pos; const codeText: string[] = []; @@ -169,12 +182,17 @@ function* lexBlockComment2( codeText.push( file.substring(lookaheadStart, lookahead), ); - yield { - kind: TokenSyntaxKind.Code, - text: codeText.join(""), - pos, - }; - pos = lookahead; + const codeTextStr = codeText.join(""); + if (isCodeBlock || !/\n\s*\n/.test(codeTextStr)) { + yield { + kind: TokenSyntaxKind.Code, + text: codeTextStr, + pos, + }; + pos = lookahead; + } else { + yield makeToken(TokenSyntaxKind.Text, tickCount); + } break; } else if (file[lookahead] === "`") { while (lookahead < end && file[lookahead] === "`") { @@ -216,7 +234,7 @@ function* lexBlockComment2( if (lookahead >= end && pos !== lookahead) { if ( - tickCount === 3 && + isCodeBlock && file.substring(pos, end).includes("\n") ) { codeText.push(file.substring(lookaheadStart, end)); diff --git a/src/lib/converter/comments/declarationReferenceResolver.ts b/src/lib/converter/comments/declarationReferenceResolver.ts index 3e3a3c3a3..b36337c7b 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, + type DocumentReflection, type ProjectReflection, ReferenceReflection, type Reflection, @@ -225,10 +226,18 @@ function resolveSymbolReferencePart( let high: Reflection[] = []; let low: Reflection[] = []; - if ( - !(refl instanceof ContainerReflection) || - !refl.childrenIncludingDocuments - ) { + let children: + | ReadonlyArray + | undefined; + + if (refl instanceof ContainerReflection) { + children = refl.childrenIncludingDocuments; + } + if (!children && refl.isDeclaration() && refl.type?.type === "reflection") { + children = refl.type.declaration.childrenIncludingDocuments; + } + + if (!children) { return { high, low }; } @@ -238,12 +247,12 @@ function resolveSymbolReferencePart( // so that resolution doesn't behave very poorly with projects using JSDoc style resolution. // Also is more consistent with how TypeScript resolves link tags. case ".": - high = refl.childrenIncludingDocuments.filter( + high = children.filter( (r) => r.name === path.path && (r.kindOf(ReflectionKind.SomeExport) || r.flags.isStatic), ); - low = refl.childrenIncludingDocuments.filter( + low = children.filter( (r) => r.name === path.path && (!r.kindOf(ReflectionKind.SomeExport) || !r.flags.isStatic), @@ -254,7 +263,7 @@ function resolveSymbolReferencePart( // enum members, type literal properties case "#": high = - refl.children?.filter((r) => { + children?.filter((r) => { return ( r.name === path.path && r.kindOf(ReflectionKind.SomeMember) && @@ -269,7 +278,7 @@ function resolveSymbolReferencePart( if ( refl.kindOf(ReflectionKind.SomeModule | ReflectionKind.Project) ) { - high = refl.children?.filter((r) => r.name === path.path) || []; + high = children?.filter((r) => r.name === path.path) || []; } break; } diff --git a/src/lib/converter/comments/lineLexer.ts b/src/lib/converter/comments/lineLexer.ts index feb1dfbc8..ec838956f 100644 --- a/src/lib/converter/comments/lineLexer.ts +++ b/src/lib/converter/comments/lineLexer.ts @@ -87,12 +87,23 @@ function* lexLineComments2( // We don't quite handle #2 correctly yet. PR welcome! braceStartsType = false; let tickCount = 1; - let lookahead = pos; + + let lookahead = pos - 1; + let atNewline = true; + while (lookahead > 0 && file[lookahead] !== "\n") { + if (/\S/.test(file[lookahead])) { + atNewline = false; + break; + } + --lookahead; + } + lookahead = pos; while (lookahead + 1 < end && file[lookahead + 1] === "`") { tickCount++; lookahead++; } + const isCodeBlock = atNewline && tickCount >= 3; let lookaheadStart = pos; const codeText: string[] = []; @@ -103,12 +114,17 @@ function* lexLineComments2( codeText.push( file.substring(lookaheadStart, lookahead), ); - yield { - kind: TokenSyntaxKind.Code, - text: codeText.join(""), - pos, - }; - pos = lookahead; + const codeTextStr = codeText.join(""); + if (isCodeBlock || !/\n\s*\n/.test(codeTextStr)) { + yield { + kind: TokenSyntaxKind.Code, + text: codeTextStr, + pos, + }; + pos = lookahead; + } else { + yield makeToken(TokenSyntaxKind.Text, tickCount); + } break; } else if (file[lookahead] === "`") { while (lookahead < end && file[lookahead] === "`") { diff --git a/src/lib/converter/comments/linkResolver.ts b/src/lib/converter/comments/linkResolver.ts index 855241e49..e2e082b34 100644 --- a/src/lib/converter/comments/linkResolver.ts +++ b/src/lib/converter/comments/linkResolver.ts @@ -5,6 +5,7 @@ import { DeclarationReflection, type InlineTagDisplayPart, Reflection, + ReflectionKind, ReflectionSymbolId, } from "../../models/index.js"; import { @@ -131,12 +132,20 @@ function resolveLinkTag( // Might already know where it should go if useTsLinkResolution is turned on if (part.target instanceof ReflectionSymbolId) { - const tsTarget = reflection.project.getReflectionFromSymbolId( + const tsTargets = reflection.project.getReflectionsFromSymbolId( part.target, ); - if (tsTarget) { - target = tsTarget; + if (tsTargets.length) { + // Find the target most likely to have a real url in the generated documentation + target = + tsTargets.find((r) => r.kindOf(ReflectionKind.SomeExport)) || + tsTargets.find( + (r) => + r.kindOf(ReflectionKind.SomeMember) && + r.parent?.kindOf(ReflectionKind.SomeExport), + ) || + tsTargets[0]; pos = end; defaultDisplayText = part.tsLinkText || diff --git a/src/lib/converter/comments/rawLexer.ts b/src/lib/converter/comments/rawLexer.ts index 1332de480..e8bc8d6bc 100644 --- a/src/lib/converter/comments/rawLexer.ts +++ b/src/lib/converter/comments/rawLexer.ts @@ -53,7 +53,6 @@ function* lexCommentString2( end--; } - let lineStart = true; let expectingTag = false; for (;;) { @@ -61,14 +60,9 @@ function* lexCommentString2( return; } - if (lineStart) { - lineStart = false; - } - switch (file[pos]) { case "\n": yield makeToken(TokenSyntaxKind.NewLine, 1); - lineStart = true; expectingTag = false; break; @@ -84,17 +78,28 @@ function* lexCommentString2( case "`": { // Markdown's code rules are a royal pain. This could be one of several things. - // 1. Inline code: <1-n ticks> - // 2. Code block: <3 ticks>\n\n<3 ticks>\n + // 1. Inline code: <1-n ticks> + // 2. Code block: <3+ ticks>\n\n<3 ticks>\n // 3. Unmatched tick(s), not code, but part of some text. // We don't quite handle #2 correctly yet. PR welcome! let tickCount = 1; - let lookahead = pos; + + let lookahead = pos - 1; + let atNewline = true; + while (lookahead > 0 && file[lookahead] !== "\n") { + if (/\S/.test(file[lookahead])) { + atNewline = false; + break; + } + --lookahead; + } + lookahead = pos; while (lookahead + 1 < end && file[lookahead + 1] === "`") { tickCount++; lookahead++; } + const isCodeBlock = atNewline && tickCount >= 3; let lookaheadStart = pos; const codeText: string[] = []; @@ -105,13 +110,19 @@ function* lexCommentString2( codeText.push( file.substring(lookaheadStart, lookahead), ); - yield { - kind: TokenSyntaxKind.Code, - text: codeText.join(""), - pos, - }; - expectingTag = false; - pos = lookahead; + const codeTextStr = codeText.join(""); + if (isCodeBlock || !/\n\s*\n/.test(codeTextStr)) { + yield { + kind: TokenSyntaxKind.Code, + text: codeTextStr, + pos, + }; + expectingTag = false; + pos = lookahead; + } else { + yield makeToken(TokenSyntaxKind.Text, tickCount); + expectingTag = false; + } break; } else if (file[lookahead] === "`") { while (lookahead < end && file[lookahead] === "`") { @@ -136,7 +147,7 @@ function* lexCommentString2( if (lookahead >= end && pos !== lookahead) { if ( - tickCount === 3 && + isCodeBlock && file.substring(pos, end).includes("\n") ) { codeText.push(file.substring(lookaheadStart, end)); diff --git a/src/lib/converter/comments/textParser.ts b/src/lib/converter/comments/textParser.ts index c379e24bd..eee4d535d 100644 --- a/src/lib/converter/comments/textParser.ts +++ b/src/lib/converter/comments/textParser.ts @@ -139,6 +139,7 @@ export function textContent( continue; } + data.atNewLine = token.text[data.pos] === "\n"; ++data.pos; } diff --git a/src/lib/converter/converter-events.ts b/src/lib/converter/converter-events.ts index df81cbf78..2636684b2 100644 --- a/src/lib/converter/converter-events.ts +++ b/src/lib/converter/converter-events.ts @@ -1,6 +1,7 @@ export const ConverterEvents = { BEGIN: "begin", END: "end", + CREATE_PROJECT: "createProject", CREATE_DECLARATION: "createDeclaration", CREATE_DOCUMENT: "createDocument", CREATE_SIGNATURE: "createSignature", diff --git a/src/lib/converter/converter.ts b/src/lib/converter/converter.ts index 7b783899b..8d56499b6 100644 --- a/src/lib/converter/converter.ts +++ b/src/lib/converter/converter.ts @@ -67,6 +67,7 @@ import { MergeModuleWithPlugin } from "./plugins/MergeModuleWithPlugin.js"; export interface ConverterEvents { begin: [Context]; end: [Context]; + createProject: [Context, ProjectReflection]; createDeclaration: [Context, DeclarationReflection]; createDocument: [undefined, DocumentReflection]; createSignature: [ @@ -175,6 +176,13 @@ export class Converter extends AbstractComponent { * Factory events */ + /** + * Triggered when the converter has created a project reflection. + * The listener will be given {@link Context} and a {@link Models.ProjectReflection}. + * @event + */ + static readonly EVENT_CREATE_PROJECT = ConverterEvents.CREATE_PROJECT; + /** * Triggered when the converter has created a declaration reflection. * The listener will be given {@link Context} and a {@link Models.DeclarationReflection}. @@ -183,6 +191,14 @@ export class Converter extends AbstractComponent { static readonly EVENT_CREATE_DECLARATION = ConverterEvents.CREATE_DECLARATION; + /** + * Triggered when the converter has created a document reflection. + * The listener will be given `undefined` (for consistency with the + * other create events) and a {@link Models.DocumentReflection}. + * @event + */ + static readonly EVENT_CREATE_DOCUMENT = ConverterEvents.CREATE_DOCUMENT; + /** * Triggered when the converter has created a signature reflection. * The listener will be given {@link Context}, {@link Models.SignatureReflection} | {@link Models.ProjectReflection} the declaration, @@ -459,6 +475,14 @@ export class Converter extends AbstractComponent { : !!(context.scope as ProjectReflection).documents; } + if (createModuleReflections) { + this.trigger( + ConverterEvents.CREATE_PROJECT, + context, + context.project, + ); + } + entries.forEach((e) => { context.setActiveProgram(e.entryPoint.program); e.context = this.convertExports( @@ -499,6 +523,11 @@ export class Converter extends AbstractComponent { ? context.getComment(symbol, context.project.kind) : context.getFileComment(node); this.processDocumentTags(context.project, context.project); + this.trigger( + ConverterEvents.CREATE_PROJECT, + context, + context.project, + ); moduleContext = context; } else { const reflection = context.createDeclarationReflection( diff --git a/src/lib/converter/plugins/CommentPlugin.ts b/src/lib/converter/plugins/CommentPlugin.ts index 9539fa4ac..d65fb9a7c 100644 --- a/src/lib/converter/plugins/CommentPlugin.ts +++ b/src/lib/converter/plugins/CommentPlugin.ts @@ -453,7 +453,7 @@ export class CommentPlugin extends ConverterComponent { // Any cascaded tags will show up twice, once on this and once on our signatures // This is completely redundant, so remove them from the wrapping function. - if (sigs.length) { + if (sigs.length && reflection.type?.type !== "reflection") { for (const mod of this.cascadedModifierTags) { reflection.comment.modifierTags.delete(mod); } @@ -527,7 +527,9 @@ export class CommentPlugin extends ConverterComponent { private cascadeModifiers(reflection: Reflection) { const parentComment = reflection.parent?.comment; - if (!parentComment) return; + if (!parentComment || reflection.kindOf(ReflectionKind.TypeLiteral)) { + return; + } const childMods = reflection.comment?.modifierTags ?? new Set(); diff --git a/src/lib/converter/plugins/IncludePlugin.ts b/src/lib/converter/plugins/IncludePlugin.ts index 0b3b2413d..c7e43a19b 100644 --- a/src/lib/converter/plugins/IncludePlugin.ts +++ b/src/lib/converter/plugins/IncludePlugin.ts @@ -19,6 +19,7 @@ export class IncludePlugin extends ConverterComponent { constructor(owner: Converter) { super(owner); const onCreate = this.onCreate.bind(this); + owner.on(ConverterEvents.CREATE_PROJECT, onCreate); owner.on(ConverterEvents.CREATE_DOCUMENT, onCreate); owner.on(ConverterEvents.CREATE_DECLARATION, onCreate); owner.on(ConverterEvents.CREATE_PARAMETER, onCreate); diff --git a/src/lib/converter/types.ts b/src/lib/converter/types.ts index 72b4ddeef..3b9b91592 100644 --- a/src/lib/converter/types.ts +++ b/src/lib/converter/types.ts @@ -421,7 +421,13 @@ const importType: TypeConverter = { kind: [ts.SyntaxKind.ImportType], convert(context, node) { const name = node.qualifier?.getText() ?? "__module"; - const symbol = context.expectSymbolAtLocation(node.qualifier || node); + const symbol = context.getSymbolAtLocation(node.qualifier || node); + // #2792, we should always have a symbol here unless there is a compiler + // error ignored with ts-expect-error or ts-ignore. + if (!symbol) { + return new IntrinsicType("any"); + } + return ReferenceType.createSymbolReference( context.resolveAliasedSymbol(symbol), context, diff --git a/src/lib/debug/debugReflectionLifetimes.ts b/src/lib/debug/debugReflectionLifetimes.ts new file mode 100644 index 000000000..6784efc8d --- /dev/null +++ b/src/lib/debug/debugReflectionLifetimes.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ +import type { Application } from "../application.js"; +import { ConverterEvents } from "../converter/converter-events.js"; +import type { Reflection } from "../models/index.js"; + +export function debugReflectionLifetimes(app: Application) { + app.converter.on(ConverterEvents.CREATE_PROJECT, logCreate); + app.converter.on(ConverterEvents.CREATE_SIGNATURE, logCreate); + app.converter.on(ConverterEvents.CREATE_TYPE_PARAMETER, logCreate); + app.converter.on(ConverterEvents.CREATE_DECLARATION, logCreate); + app.converter.on(ConverterEvents.CREATE_DOCUMENT, logCreate); + app.converter.on(ConverterEvents.CREATE_PARAMETER, logCreate); + + app.converter.on(ConverterEvents.CREATE_PROJECT, (_context, project) => { + const oldRemove = project["_removeReflection"]; + project["_removeReflection"] = function (reflection) { + console.log("Remove", reflection.id, reflection.getFullName()); + return oldRemove.call(this, reflection); + }; + }); +} + +function logCreate(_context: unknown, refl: Reflection) { + console.log("Create", refl.variant, refl.id, refl.getFullName()); +} diff --git a/src/lib/debug/debugRendererUrls.ts b/src/lib/debug/debugRendererUrls.ts new file mode 100644 index 000000000..067782c73 --- /dev/null +++ b/src/lib/debug/debugRendererUrls.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-console */ +import { join } from "node:path"; +import type { Application } from "../application.js"; +import { + Reflection, + ReflectionKind, + type SomeReflection, +} from "../models/index.js"; +import type { SerializerComponent } from "../serialization/components.js"; +import type { JSONOutput } from "../serialization/index.js"; + +const serializer: SerializerComponent = { + priority: 0, + supports(x) { + return x instanceof Reflection; + }, + toObject(item, obj: any) { + obj.url = item.url; + obj.hasOwnDocument = item.hasOwnDocument; + // obj.anchor = item.anchor; + delete obj.sources; + delete obj.groups; + delete obj.categories; + delete obj.readme; + delete obj.content; + obj.kind = ReflectionKind[obj.kind]; + delete obj.flags; + delete obj.defaultValue; + delete obj.symbolIdMap; + delete obj.files; + delete obj.packageName; + delete obj.variant; + delete obj.extendedTypes; + delete obj.inheritedFrom; + if (!["reflection", "reference"].includes(obj.type?.type)) { + delete obj.type; + } + + if (obj.comment) { + obj.comment.summary = obj.comment.summary.filter( + (part: JSONOutput.CommentDisplayPart) => + part.kind === "inline-tag", + ); + obj.comment.blockTags = obj.comment.blockTags?.filter( + (tag: JSONOutput.CommentTag) => { + tag.content = tag.content.filter( + (part) => part.kind === "inline-tag", + ); + return tag.content.length; + }, + ); + + if ( + !obj.comment.summary.length && + !obj.comment.blockTags?.length && + !obj.comment.modifierTags + ) { + delete obj.comment; + } + } + + return obj; + }, +}; + +export function debugRendererUrls( + app: Application, + { json = false, logs = false } = { logs: true }, +) { + app.renderer.postRenderAsyncJobs.push(async (evt) => { + if (json) { + app.serializer.addSerializer(serializer); + await app.generateJson( + evt.project, + join(evt.outputDirectory, "url_debug.json"), + ); + app.serializer.removeSerializer(serializer); + } + + if (logs) { + for (const id in evt.project.reflections) { + const refl = evt.project.reflections[id]; + console.log( + refl.id, + refl.getFullName(), + refl.url, + refl.hasOwnDocument, + ); + } + } + }); +} diff --git a/src/lib/debug/index.ts b/src/lib/debug/index.ts new file mode 100644 index 000000000..d8e3ba407 --- /dev/null +++ b/src/lib/debug/index.ts @@ -0,0 +1,2 @@ +export { debugRendererUrls } from "./debugRendererUrls.js"; +export { debugReflectionLifetimes } from "./debugReflectionLifetimes.js"; diff --git a/src/lib/internationalization/internationalization.ts b/src/lib/internationalization/internationalization.ts index 283101ca0..6c2b441c6 100644 --- a/src/lib/internationalization/internationalization.ts +++ b/src/lib/internationalization/internationalization.ts @@ -108,13 +108,13 @@ export class Internationalization { * Get the translation of the specified key, replacing placeholders * with the arguments specified. */ - translate( + translate( key: T, ...args: TranslatableStrings[T] ): TranslatedString { return ( this.allTranslations.get(this.application?.lang ?? "en").get(key) ?? - translatable[key] + translatable[key as keyof typeof translatable] ).replace(/\{(\d+)\}/g, (_, index) => { return args[+index] ?? "(no placeholder)"; }) as TranslatedString; diff --git a/src/lib/internationalization/locales/en.cts b/src/lib/internationalization/locales/en.cts index ff9981940..8b38479a6 100644 --- a/src/lib/internationalization/locales/en.cts +++ b/src/lib/internationalization/locales/en.cts @@ -95,6 +95,7 @@ export = { "The following symbols were marked as intentionally not exported, but were either not referenced in the documentation, or were exported:\n\t{0}", reflection_0_has_unused_mergeModuleWith_tag: "{0} has a @mergeModuleWith tag which could not be resolved", + reflection_0_links_to_1_with_text_2_but_resolved_to_3: `"{0}" links to "{1}" with text "{2}" which exists but does not have a link in the documentation, will link to "{3}" instead.`, // conversion plugins not_all_search_category_boosts_used_0: `Not all categories specified in searchCategoryBoosts were used in the documentation. The unused categories were:\n\t{0}`, @@ -268,8 +269,7 @@ export = { "Path to the readme file that should be displayed on the index page. Pass `none` to disable the index page and start the documentation on the globals page", help_cname: "Set the CNAME file text, it's useful for custom domains on GitHub Pages", - help_favicon: - "Path to a favicon.ico or favicon.svg to include as the site icon", + help_favicon: "Path to favicon to include as the site icon", help_sourceLinkExternal: "Specifies that source links should be treated as external links to be opened in a new tab", help_markdownLinkExternal: @@ -388,7 +388,8 @@ export = { "hostedBaseUrl must start with http:// or https://", useHostedBaseUrlForAbsoluteLinks_requires_hostedBaseUrl: "The useHostedBaseUrlForAbsoluteLinks option requires that hostedBaseUrl be set", - favicon_must_be_ico_or_svg: "Favicon file must be either a .ico or .svg", + favicon_must_have_one_of_the_following_extensions_0: + "Favicon must have on of the following extensions: {0}", option_0_must_be_an_object: "The '{0}' option must be a non-array object", option_0_must_be_a_function: "The '{0}' option must be a function", option_0_must_be_object_with_urls: `{0} must be an object with string labels as keys and URL values`, diff --git a/src/lib/internationalization/locales/zh.cts b/src/lib/internationalization/locales/zh.cts index e2ab44cdb..5770ac4a3 100644 --- a/src/lib/internationalization/locales/zh.cts +++ b/src/lib/internationalization/locales/zh.cts @@ -279,7 +279,7 @@ export = localeUtils.buildIncompleteTranslation({ help_readme: "应显示在索引页上的自述文件路径。传递“none”以禁用索引页并在全局页上启动文档", help_cname: "设置 CNAME 文件文本,这对于 GitHub Pages 上的自定义域很有用", - help_favicon: "作为站点图标包含的 favicon.ico 或 favicon.svg 的路径", + help_favicon: "作为站点图标包含的 favicon 的路径", help_sourceLinkExternal: "指定哪些源代码链接应被视为外部链接,并在新选项卡中打开", help_markdownLinkExternal: @@ -371,7 +371,6 @@ export = localeUtils.buildIncompleteTranslation({ "hostingBaseUrl 必须以 http:// 或 https:// 开头", useHostedBaseUrlForAbsoluteLinks_requires_hostedBaseUrl: "useHostedBaseUrlForAbsoluteLinks 选项要求设置 hostingBaseUrl", - favicon_must_be_ico_or_svg: "Favicon 文件必须是一个 .ico 或 .svg 文件", option_0_must_be_an_object: "“{0}”选项必须是非数组对象", option_0_must_be_a_function: "‘{0}’ 选项必须是一个函数", option_0_must_be_object_with_urls: diff --git a/src/lib/models/ReflectionCategory.ts b/src/lib/models/ReflectionCategory.ts index 774b2e6d0..2ac955c77 100644 --- a/src/lib/models/ReflectionCategory.ts +++ b/src/lib/models/ReflectionCategory.ts @@ -69,7 +69,7 @@ export class ReflectionCategory { const child = project.getReflectionById( de.oldIdToNewId[childId] ?? -1, ); - if (child?.isDeclaration()) { + if (child?.isDeclaration() || child?.isDocument()) { this.children.push(child); } } diff --git a/src/lib/models/ReflectionGroup.ts b/src/lib/models/ReflectionGroup.ts index f056a845b..791a842e2 100644 --- a/src/lib/models/ReflectionGroup.ts +++ b/src/lib/models/ReflectionGroup.ts @@ -89,7 +89,7 @@ export class ReflectionGroup { const child = project.getReflectionById( de.oldIdToNewId[childId] ?? -1, ); - if (child?.isDeclaration()) { + if (child?.isDeclaration() || child?.isDocument()) { this.children.push(child); } } diff --git a/src/lib/models/reflections/project.ts b/src/lib/models/reflections/project.ts index ea35ff21d..c5e9d5de9 100644 --- a/src/lib/models/reflections/project.ts +++ b/src/lib/models/reflections/project.ts @@ -391,6 +391,7 @@ export class ProjectReflection extends ContainerReflection { /** @internal */ registerSymbolId(reflection: Reflection, id: ReflectionSymbolId) { + this.removedSymbolIds.delete(id); this.reflectionIdToSymbolIdMap.set(reflection.id, id); const previous = this.symbolToReflectionIdMap.get(id); diff --git a/src/lib/models/types.ts b/src/lib/models/types.ts index e91a3c26e..2524356ba 100644 --- a/src/lib/models/types.ts +++ b/src/lib/models/types.ts @@ -833,7 +833,13 @@ export class ReferenceType extends Type { const resolved = resolvePotential.find((refl) => refl.kindOf(kind)) || resolvePotential.find((refl) => refl.kindOf(~kind))!; - this._target = resolved.id; + + // Do not mark the type as resolved at this point so that if it + // points to a member which is removed (e.g. by typedoc-plugin-zod) + // and then replaced it still ends up pointing at the right reflection. + // We will lock type reference resolution when serializing to JSON. + // this._target = resolved.id; + return resolved; } @@ -1002,6 +1008,8 @@ export class ReferenceType extends Type { target = this._target; } else if (this._project?.symbolIdHasBeenRemoved(this._target)) { target = -1; + } else if (this.reflection) { + target = this.reflection.id; } else { target = this._target.toObject(serializer); } @@ -1023,7 +1031,7 @@ export class ReferenceType extends Type { result.refersToTypeParameter = true; } - if (typeof this._target !== "number" && this.preferValues) { + if (typeof target !== "number" && this.preferValues) { result.preferValues = true; } diff --git a/src/lib/output/components.ts b/src/lib/output/components.ts index e0e410a91..59f184009 100644 --- a/src/lib/output/components.ts +++ b/src/lib/output/components.ts @@ -41,7 +41,7 @@ export abstract class ContextAwareRendererComponent extends RendererComponent { */ protected urlPrefix = /^(http|ftp)s?:\/\//; - private get hostedBaseUrl() { + protected get hostedBaseUrl() { const url = this.application.options.getValue("hostedBaseUrl"); return !url || url.endsWith("/") ? url : url + "/"; } diff --git a/src/lib/output/plugins/AssetsPlugin.ts b/src/lib/output/plugins/AssetsPlugin.ts index 55b17f41f..ea55907d6 100644 --- a/src/lib/output/plugins/AssetsPlugin.ts +++ b/src/lib/output/plugins/AssetsPlugin.ts @@ -43,13 +43,11 @@ export class AssetsPlugin extends RendererComponent { private onRenderBegin(event: RendererEvent) { const dest = join(event.outputDirectory, "assets"); - switch (extname(this.favicon)) { - case ".ico": - copySync(this.favicon, join(dest, "favicon.ico")); - break; - case ".svg": - copySync(this.favicon, join(dest, "favicon.svg")); - break; + if ([".ico", ".png", ".svg"].includes(extname(this.favicon))) { + copySync( + this.favicon, + join(dest, "favicon" + extname(this.favicon)), + ); } if (this.customCss) { diff --git a/src/lib/output/plugins/HierarchyPlugin.ts b/src/lib/output/plugins/HierarchyPlugin.ts index 15b207d3a..c5d97e1f4 100644 --- a/src/lib/output/plugins/HierarchyPlugin.ts +++ b/src/lib/output/plugins/HierarchyPlugin.ts @@ -3,8 +3,6 @@ import { RendererComponent } from "../components.js"; import { RendererEvent } from "../events.js"; import { writeFile } from "../../utils/index.js"; import { DefaultTheme } from "../themes/default/DefaultTheme.js"; -import { gzip } from "zlib"; -import { promisify } from "util"; import type { Renderer } from "../index.js"; import { @@ -13,8 +11,7 @@ import { getUniquePath, } from "../themes/lib.js"; import type { DeclarationReflection } from "../../models/index.js"; - -const gzipP = promisify(gzip); +import { compressJson } from "../../utils/compress.js"; interface JsonHierarchyElement { name: string; @@ -104,13 +101,9 @@ export class HierarchyPlugin extends RendererComponent { "hierarchy.js", ); - const gz = await gzipP(Buffer.from(JSON.stringify(hierarchy))); - await writeFile( hierarchyJs, - `window.hierarchyData = "data:application/octet-stream;base64,${gz.toString( - "base64", - )}"`, + `window.hierarchyData = "${await compressJson(hierarchy)}"`, ); } } diff --git a/src/lib/output/plugins/JavascriptIndexPlugin.ts b/src/lib/output/plugins/JavascriptIndexPlugin.ts index e9204b455..085ae5a25 100644 --- a/src/lib/output/plugins/JavascriptIndexPlugin.ts +++ b/src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -11,13 +11,10 @@ import { RendererComponent } from "../components.js"; import { IndexEvent, RendererEvent } from "../events.js"; import { Option, writeFile } from "../../utils/index.js"; import { DefaultTheme } from "../themes/default/DefaultTheme.js"; -import { gzip } from "zlib"; -import { promisify } from "util"; import type { Renderer } from "../index.js"; import { GroupPlugin } from "../../converter/plugins/GroupPlugin.js"; import { CategoryPlugin } from "../../converter/plugins/CategoryPlugin.js"; - -const gzipP = promisify(gzip); +import { compressJson } from "../../utils/compress.js"; /** * Keep this in sync with the interface in src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -144,17 +141,13 @@ export class JavascriptIndexPlugin extends RendererComponent { "search.js", ); - const jsonData = JSON.stringify({ + const data = { rows, index, - }); - const data = await gzipP(Buffer.from(jsonData)); - + }; await writeFile( jsonFileName, - `window.searchData = "data:application/octet-stream;base64,${data.toString( - "base64", - )}";`, + `window.searchData = "${await compressJson(data)}";`, ); if ( diff --git a/src/lib/output/plugins/NavigationPlugin.ts b/src/lib/output/plugins/NavigationPlugin.ts index 6b0470bef..3ced18091 100644 --- a/src/lib/output/plugins/NavigationPlugin.ts +++ b/src/lib/output/plugins/NavigationPlugin.ts @@ -3,11 +3,8 @@ import { RendererComponent } from "../components.js"; import { RendererEvent } from "../events.js"; import { writeFile } from "../../utils/index.js"; import { DefaultTheme } from "../themes/default/DefaultTheme.js"; -import { gzip } from "zlib"; -import { promisify } from "util"; import type { Renderer } from "../index.js"; - -const gzipP = promisify(gzip); +import { compressJson } from "../../utils/compress.js"; export class NavigationPlugin extends RendererComponent { constructor(owner: Renderer) { @@ -34,13 +31,10 @@ export class NavigationPlugin extends RendererComponent { const nav = (this.owner.theme as DefaultTheme).getNavigation( event.project, ); - const gz = await gzipP(Buffer.from(JSON.stringify(nav))); await writeFile( navigationJs, - `window.navigationData = "data:application/octet-stream;base64,${gz.toString( - "base64", - )}"`, + `window.navigationData = "${await compressJson(nav)}"`, ); } } diff --git a/src/lib/output/router.ts b/src/lib/output/router.ts index 69d51f8b2..82288cb99 100644 --- a/src/lib/output/router.ts +++ b/src/lib/output/router.ts @@ -1,5 +1,6 @@ import type { Application } from "../application.js"; import { + type DeclarationReflection, ReflectionKind, TraverseProperty, type ProjectReflection, @@ -268,12 +269,31 @@ export class DefaultRouter implements Router { return; } + // We support linking to reflections for types directly contained within an export + // but not any deeper. This is because TypeDoc may or may not render the type details + // for a property depending on whether or not it is deemed useful, and defining a link + // which might not be used may result in a link being generated which isn't valid. #2808. + // This should be kept in sync with the renderingChildIsUseful function. + if ( + reflection.kindOf(ReflectionKind.TypeLiteral) && + (!reflection.parent?.kindOf(ReflectionKind.SomeExport) || + (reflection.parent as DeclarationReflection).type?.type !== + "reflection") + ) { + return; + } + let refl: Reflection | undefined = reflection; const parts = [refl.name]; while (refl.parent && refl.parent !== pageReflection) { refl = refl.parent; - // Avoid duplicate names for signatures - if (parts[0] !== refl.name) { + // Avoid duplicate names for signatures and useless __type in anchors + if ( + !refl.kindOf( + ReflectionKind.TypeLiteral | + ReflectionKind.FunctionOrMethod, + ) + ) { parts.unshift(refl.name); } } diff --git a/src/lib/output/themes/MarkedPlugin.tsx b/src/lib/output/themes/MarkedPlugin.tsx index ca58fe69e..44498a5a0 100644 --- a/src/lib/output/themes/MarkedPlugin.tsx +++ b/src/lib/output/themes/MarkedPlugin.tsx @@ -4,7 +4,7 @@ import type md from "markdown-it" with { "resolution-mode": "require" }; import { ContextAwareRendererComponent } from "../components.js"; import { MarkdownEvent, RendererEvent, type PageEvent } from "../events.js"; -import { Option, renderElement, assertNever } from "../../utils/index.js"; +import { Option, renderElement, assertNever, type ValidationOptions } from "../../utils/index.js"; import { highlight, isLoadedLanguage, isSupportedLanguage } from "../../utils/highlighter.js"; import type { BundledTheme } from "@gerrit0/mini-shiki"; import { escapeHtml } from "../../utils/html.js"; @@ -35,6 +35,9 @@ export class MarkedPlugin extends ContextAwareRendererComponent { @Option("markdownLinkExternal") accessor markdownLinkExternal!: boolean; + @Option("validation") + accessor validation!: ValidationOptions; + private parser?: MarkdownIt; private renderedRelativeLinks: { @@ -141,21 +144,46 @@ export class MarkedPlugin extends ContextAwareRendererComponent { url = part.target; } else if ("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. + // tried and failed during the resolution step. Warnings related to those broken links + // have already been emitted. url = context.urlTo(part.target); kindClass = ReflectionKind.classString(part.target.kind); + + // If we don't have a URL the user probably linked to some deeply nested property + // which doesn't get an assigned URL. We'll walk upwards until we find a reflection + // which has a URL and link to that instead. + if (!url) { + // Walk upwards to find something we can link to. + let target = part.target.parent!; + url = context.urlTo(target); + while (!url && target.parent) { + target = target.parent; + // We know we'll always end up with a URL here eventually as the + // project always has a URL. + url = context.urlTo(target)!; + } + + if (this.validation.rewrittenLink) { + this.application.logger.warn( + this.application.i18n.reflection_0_links_to_1_with_text_2_but_resolved_to_3( + page.model.getFriendlyFullName(), + part.target.getFriendlyFullName(), + part.text, + target.getFriendlyFullName(), + ), + ); + } + } } if (useHtml) { const text = part.tag === "@linkcode" ? `${part.text}` : part.text; result.push( - url - ? `${text}` - : part.text, + `${text}`, ); } else { const text = part.tag === "@linkcode" ? "`" + part.text + "`" : part.text; - result.push(url ? `[${text}](${url})` : text); + result.push(`[${text}](${url})`); } } else { result.push(part.text); @@ -304,7 +332,11 @@ export class MarkedPlugin extends ContextAwareRendererComponent { // will be relative links. This will likely have to change with // the introduction of support for customized routers whenever // that becomes a real thing. - if (this.markdownLinkExternal && /https?:\/\//i.test(href)) { + if ( + this.markdownLinkExternal && + /https?:\/\//i.test(href) && + !(href + "/").startsWith(this.hostedBaseUrl) + ) { token.attrSet("target", "_blank"); const classes = token.attrGet("class")?.split(" ") || []; classes.push("external"); diff --git a/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts b/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts index 45ca360ee..73b1fa3ef 100644 --- a/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts +++ b/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts @@ -1,3 +1,5 @@ +import { decompressJson } from "./utils/decompress"; + declare global { interface Window { // Base64 encoded data url, gzipped, JSON encoded JsonHierarchy @@ -106,14 +108,8 @@ async function buildHierarchyToggle() { ); if (!container || !window.hierarchyData) return; - const res = await fetch(window.hierarchyData); - const data = await res.arrayBuffer(); - const json = new Blob([data]) - .stream() - .pipeThrough(new DecompressionStream("gzip")); - const baseReflId = +container.dataset.refl!; - const hierarchy: JsonHierarchy = await new Response(json).json(); + const hierarchy: JsonHierarchy = await decompressJson(window.hierarchyData); const collapsedHierarchy = container.querySelector("ul")!; const expandedHierarchy = document.createElement("ul"); diff --git a/src/lib/output/themes/default/assets/typedoc/Navigation.ts b/src/lib/output/themes/default/assets/typedoc/Navigation.ts index 2582fbf4d..4368abc98 100644 --- a/src/lib/output/themes/default/assets/typedoc/Navigation.ts +++ b/src/lib/output/themes/default/assets/typedoc/Navigation.ts @@ -1,3 +1,5 @@ +import { decompressJson } from "./utils/decompress"; + export interface NavigationElement { text: string; path?: string; @@ -27,12 +29,9 @@ async function buildNav() { const container = document.getElementById("tsd-nav-container"); if (!container || !window.navigationData) return; - const res = await fetch(window.navigationData); - const data = await res.arrayBuffer(); - const json = new Blob([data]) - .stream() - .pipeThrough(new DecompressionStream("gzip")); - const nav: NavigationElement[] = await new Response(json).json(); + const nav: NavigationElement[] = await decompressJson( + window.navigationData, + ); BASE_URL = document.documentElement.dataset.base!; if (!BASE_URL.endsWith("/")) BASE_URL += "/"; 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 ec4608cd5..10f05ec2e 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Search.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -1,5 +1,6 @@ import { debounce } from "../utils/debounce.js"; import { Index } from "lunr"; +import { decompressJson } from "../utils/decompress.js"; /** * Keep this in sync with the interface in src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -35,11 +36,7 @@ interface SearchState { async function updateIndex(state: SearchState, searchEl: HTMLElement) { if (!window.searchData) return; - const res = await fetch(window.searchData); - const json = new Blob([await res.arrayBuffer()]) - .stream() - .pipeThrough(new DecompressionStream("gzip")); - const data: IData = await new Response(json).json(); + const data: IData = await decompressJson(window.searchData); state.data = data; state.index = Index.load(data.index); diff --git a/src/lib/output/themes/default/assets/typedoc/utils/decompress.ts b/src/lib/output/themes/default/assets/typedoc/utils/decompress.ts new file mode 100644 index 000000000..2b799b771 --- /dev/null +++ b/src/lib/output/themes/default/assets/typedoc/utils/decompress.ts @@ -0,0 +1,15 @@ +/** + * Decompresses Base64-encoded deflate compressed data and parses it into a JSON object. + * + * @param base64 - The Base64-encoded string representing the deflate-compressed JSON string. + * @returns A promise that resolves to the parsed JSON object. + */ +export async function decompressJson(base64: string) { + const binaryData = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const blob = new Blob([binaryData]); + const decompressedStream = blob + .stream() + .pipeThrough(new DecompressionStream("deflate")); + const decompressedText = await new Response(decompressedStream).text(); + return JSON.parse(decompressedText); +} diff --git a/src/lib/output/themes/default/layouts/default.tsx b/src/lib/output/themes/default/layouts/default.tsx index 7f7614850..febd7dd59 100644 --- a/src/lib/output/themes/default/layouts/default.tsx +++ b/src/lib/output/themes/default/layouts/default.tsx @@ -13,6 +13,8 @@ function favicon(context: DefaultThemeRenderContext) { switch (extname(fav)) { case ".ico": return ; + case ".png": + return ; case ".svg": return ; default: diff --git a/src/lib/output/themes/default/partials/comment.tsx b/src/lib/output/themes/default/partials/comment.tsx index 141a3de12..8afba110b 100644 --- a/src/lib/output/themes/default/partials/comment.tsx +++ b/src/lib/output/themes/default/partials/comment.tsx @@ -29,6 +29,10 @@ export function commentShortSummary(context: DefaultThemeRenderContext, props: R shortSummary = props.comment?.getShortSummary(context.options.getValue("useFirstParagraphOfCommentAsSummary")); } + if (!shortSummary?.length && props.isDeclaration() && props.signatures?.length) { + return commentShortSummary(context, props.signatures[0]); + } + if (!shortSummary?.some((part) => part.text)) return; return context.displayParts(shortSummary); diff --git a/src/lib/output/themes/default/partials/navigation.tsx b/src/lib/output/themes/default/partials/navigation.tsx index 6c3e19c1d..54e64b3f5 100644 --- a/src/lib/output/themes/default/partials/navigation.tsx +++ b/src/lib/output/themes/default/partials/navigation.tsx @@ -125,7 +125,10 @@ export function settings(context: DefaultThemeRenderContext) { export const navigation = function navigation(context: DefaultThemeRenderContext, props: PageEvent) { return (