Skip to content

Commit

Permalink
Add support for externalSymbolLinkMappings
Browse files Browse the repository at this point in the history
  • Loading branch information
Gerrit0 committed Sep 2, 2022
1 parent 3872463 commit d27a719
Show file tree
Hide file tree
Showing 9 changed files with 138 additions and 7 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### Features

- Added support for defining one-off external link mappings with `externalSymbolLinkMappings` see
[the documentation](https://typedoc.org/guides/options/#externalsymbollinkmappings) for usage examples and caveats, #2030.
- External link resolvers defined with `addUnknownSymbolResolver` will now be checked when resolving `@link` tags, #2030.
Note: To support this, resolution will now happen during conversion, and as such, `Renderer.addUnknownSymbolResolver` has been
soft deprecated in favor of `Converter.addUnknownSymbolResolver`. Plugins should update to use the method on `Converter`.
Expand Down
35 changes: 34 additions & 1 deletion internal-docs/third-party-symbols.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
# Third Party Symbols

TypeDoc 0.22 added support for linking to third party sites by associating a symbol name with npm packages.
Plugins can add support for linking to third party sites by calling `app.renderer.addUnknownSymbolResolver`.

Since TypeDoc 0.23.13, some mappings can be defined without a plugin by setting `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
keys if not specified, so if meanings may be used, a url must be listed multiple times.

Global external symbols are supported, but may have surprising behavior. TypeDoc assumes that if a symbol was
referenced from a package, it was exported from that package. This will be true for most native TypeScript packages,
but packages which rely on `@types` will be linked according to that `@types` package for that package name.

Furthermore, types which are defined in the TypeScript lib files (including `Array`, `Promise`, ...) will be
detected as belonging to the `typescript` package rather than the `global` package. In order to support both
`{@link !Promise}` and references to the type within source code, both `global` and `typescript` need to be set.

```jsonc
// typedoc.json
{
"externalSymbolLinkMappings": {
"global": {
// Handle {@link !Promise}
"Promise": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise"
},
"typescript": {
// Handle type X = Promise<number>
"Promise": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise"
}
}
}
```

Plugins can add support for linking to third party sites by calling `app.converter.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.
Expand All @@ -19,7 +50,9 @@ const knownSymbols = {
export function load(app: Application) {
app.converter.addUnknownSymbolResolver((ref: DeclarationReference) => {
if (
// TS defined symbols
ref.moduleSource !== "@types/react" &&
// User {@link} tags
ref.moduleSource !== "react"
) {
return;
Expand Down
15 changes: 10 additions & 5 deletions src/lib/converter/comments/linkResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,20 @@ function resolveLinkTag(
const declRef = parseDeclarationReference(part.text, pos, end);

let target: Reflection | string | undefined;
let defaultDisplayText: string;
if (declRef) {
// Got one, great! Try to resolve the link
target = resolveDeclarationReference(reflection, declRef[0]);
pos = declRef[1];

// If we didn't find a link, it might be a @link tag to an external symbol, check that next.
if (!target) {
if (target) {
defaultDisplayText = target.name;
} else {
// If we didn't find a link, it might be a @link tag to an external symbol, check that next.
target = attemptExternalResolve(declRef[0]);
if (target) {
defaultDisplayText = part.text.substring(0, pos);
}
}
}

Expand All @@ -153,6 +159,7 @@ function resolveLinkTag(
target =
wsIndex === -1 ? part.text : part.text.substring(0, wsIndex);
pos = target.length;
defaultDisplayText = target;
}
}

Expand All @@ -178,9 +185,7 @@ function resolveLinkTag(
}

part.target = target;
part.text =
part.text.substring(pos).trim() ||
(typeof target === "string" ? target : target.name);
part.text = part.text.substring(pos).trim() || defaultDisplayText!;

return part;
}
Expand Down
34 changes: 34 additions & 0 deletions src/lib/converter/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export class Converter extends ChildableComponent<
@BindOption("validation")
validation!: ValidationOptions;

/** @internal */
@BindOption("externalSymbolLinkMappings")
externalSymbolLinkMappings!: Record<string, Record<string, string>>;

private _config?: CommentParserConfig;
private _externalSymbolResolvers: Array<
(ref: DeclarationReference) => string | undefined
Expand Down Expand Up @@ -159,6 +163,36 @@ export class Converter extends ChildableComponent<
*/
static readonly EVENT_RESOLVE_END = ConverterEvents.RESOLVE_END;

constructor(owner: Application) {
super(owner);

this.addUnknownSymbolResolver((ref) => {
// Require global links, matching local ones will likely hide mistakes where the
// user meant to link to a local type.
if (ref.resolutionStart !== "global" || !ref.symbolReference) {
return;
}

const modLinks =
this.externalSymbolLinkMappings[ref.moduleSource ?? "global"];
if (typeof modLinks !== "object") {
return;
}

let name = "";
if (ref.symbolReference.path) {
name += ref.symbolReference.path.map((p) => p.path).join(".");
}
if (ref.symbolReference.meaning) {
name += ":" + ref.symbolReference.meaning;
}

if (typeof modLinks[name] === "string") {
return modLinks[name];
}
});
}

/**
* Compile the given source files and create a project reflection for them.
*/
Expand Down
3 changes: 3 additions & 0 deletions src/lib/utils/options/declaration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export interface TypeDocOptionMap {
excludeInternal: boolean;
excludePrivate: boolean;
excludeProtected: boolean;
externalSymbolLinkMappings: ManuallyValidatedOption<
Record<string, Record<string, string>>
>;
media: string;
includes: string;

Expand Down
26 changes: 26 additions & 0 deletions src/lib/utils/options/sources/typedoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,32 @@ export function addTypeDocOptions(options: Pick<Options, "addDeclaration">) {
help: "Ignore protected variables and methods.",
type: ParameterType.Boolean,
});
options.addDeclaration({
name: "externalSymbolLinkMappings",
help: "Define custom links for symbols not included in the documentation.",
type: ParameterType.Mixed,
defaultValue: {},
validate(value) {
const error =
"externalSymbolLinkMappings must be a Record<package name, Record<symbol name, link>>";

if (!Validation.validate({}, value)) {
throw new Error(error);
}

for (const mappings of Object.values(value)) {
if (!Validation.validate({}, mappings)) {
throw new Error(error);
}

for (const link of Object.values(mappings)) {
if (typeof link !== "string") {
throw new Error(error);
}
}
}
},
});
options.addDeclaration({
name: "media",
help: "Specify the location with media files that should be copied to the output directory.",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/validation/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function validateExports(
for (const { type, owner } of discoverAllReferenceTypes(project, true)) {
// If we don't have a symbol, then this was an intentionally broken reference.
const symbol = type.getSymbol();
if (!type.reflection && symbol) {
if (!type.reflection && !type.externalUrl && symbol) {
if (
(symbol.flags & ts.SymbolFlags.TypeParameter) === 0 &&
!intentional.has(symbol, type.qualifiedName) &&
Expand Down
23 changes: 23 additions & 0 deletions src/test/behaviorTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,29 @@ export const behaviorTests: {
equal(Comment.combineDisplayParts(foo.comment?.summary), "export foo");
},

_externalSymbols(app) {
app.options.setValue("externalSymbolLinkMappings", {
global: {
Promise: "/promise",
},
typescript: {
Promise: "/promise2",
},
});
},
externalSymbols(project) {
const p = query(project, "P");
equal(p.comment?.summary?.[1], {
kind: "inline-tag",
tag: "@link",
target: "/promise",
text: "!Promise",
});

equal(p.type?.type, "reference" as const);
equal(p.type.externalUrl, "/promise2");
},

groupTag(project) {
const A = query(project, "A");
const B = query(project, "B");
Expand Down
5 changes: 5 additions & 0 deletions src/test/converter2/behavior/externalSymbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Testing custom external link resolution
* {@link !Promise}
*/
export type P = Promise<string>;

0 comments on commit d27a719

Please sign in to comment.