docs: Enable direct links to class/interface methods for @link (#57615)

PR Close #57615
This commit is contained in:
Matthieu Riegler 2024-08-31 04:31:27 +02:00 committed by Jessica Janiuk
parent bf5f2e73d3
commit dbdd7875dd
6 changed files with 140 additions and 23 deletions

View file

@ -7,6 +7,8 @@ import {
createCompilerHost,
DocEntry,
EntryCollection,
InterfaceEntry,
ClassEntry,
} from '@angular/compiler-cli';
import ts from 'typescript';
@ -81,10 +83,28 @@ function main() {
// Exported symbols from the current package
...apiDoc.entries.map((entry) => [entry.name, moduleName]),
// Also doing it for every member of classes/interfaces
...apiDoc.entries.flatMap((entry) => [
[entry.name, moduleName],
...getEntriesFromMembers(entry).map((member) => [member, moduleName]),
]),
],
} as EntryCollection);
writeFileSync(outputFilenameExecRootRelativePath, output, {encoding: 'utf8'});
}
function getEntriesFromMembers(entry: DocEntry): string[] {
if (!hasMembers(entry)) {
return [];
}
return entry.members.map((member) => `${entry.name}.${member.name}`);
}
function hasMembers(entry: DocEntry): entry is InterfaceEntry | ClassEntry {
return 'members' in entry;
}
main();

View file

@ -7,7 +7,7 @@ import {configureMarkedGlobally} from './marked/configuration';
import {getRenderable} from './processing';
import {renderEntry} from './rendering';
import {initHighlighter} from './shiki/shiki';
import {setSymbols} from './symbol-context';
import {setCurrentSymbol, setSymbols} from './symbol-context';
/** The JSON data file format for extracted API reference info. */
interface EntryCollection {
@ -110,9 +110,10 @@ async function main() {
// Setting the symbols are a global context for the rendering templates of this entry
setSymbols(collection.symbols);
const renderableEntries = extractedEntries.map((entry) =>
getRenderable(entry, collection.moduleName),
);
const renderableEntries = extractedEntries.map((entry) => {
setCurrentSymbol(entry.name);
return getRenderable(entry, collection.moduleName);
});
const htmlOutputs = renderableEntries.map(renderEntry);

View file

@ -27,9 +27,11 @@ import {EnumReference} from './templates/enum-reference';
import {FunctionReference} from './templates/function-reference';
import {InitializerApiFunction} from './templates/initializer-api-function';
import {TypeAliasReference} from './templates/type-alias-reference';
import {setCurrentSymbol} from './symbol-context';
/** Given a doc entry, get the transformed version of the entry for rendering. */
export function renderEntry(renderable: DocEntryRenderable | CliCommandRenderable): string {
setCurrentSymbol(renderable.name);
if (isCliEntry(renderable)) {
return render(CliCommandReference(renderable));
}

View file

@ -5,6 +5,9 @@
let symbols = new Map<string, string>();
// This is used to store the currently processed symbol (usually a class or an interface)
let currentSymbol: string | undefined;
export function setSymbols(newSymbols: Map<string, string>): void {
symbols = newSymbols;
}
@ -12,7 +15,23 @@ export function setSymbols(newSymbols: Map<string, string>): void {
/**
* Returns the module name of a symbol.
* eg: 'ApplicationRef' => 'core', 'FormControl' => 'forms'
* Also supports class.member, 'NgZone.runOutsideAngular => 'core'
*/
export function getModuleName(symbol: string): string | undefined {
return symbols.get(symbol)?.replace('@angular/', '');
const moduleName = symbols.get(symbol);
return moduleName?.replace('@angular/', '');
}
export function setCurrentSymbol(symbol: string): void {
currentSymbol = symbol;
}
export function getCurrentSymbol(): string | undefined {
return currentSymbol;
}
export function logUnknownSymbol(link: string, symbol: string): void {
console.warn(
`WARNING: {@link ${link}} is invalid, ${symbol} or ${currentSymbol}.${symbol} is unknown in this context`,
);
}

View file

@ -1,10 +1,21 @@
import {setSymbols} from '../../symbol-context';
import {setCurrentSymbol, setSymbols} from '../../symbol-context';
import {addHtmlAdditionalLinks} from '../../transforms/jsdoc-transforms';
// @ts-ignore This compiles fine, but Webstorm doesn't like the ESM import in a CJS context.
describe('jsdoc transforms', () => {
it('should transform links', () => {
setSymbols(new Map([['Route', 'test']]));
setCurrentSymbol('Router');
setSymbols(
new Map([
['Route', 'test'],
['Router', 'test'],
['Router.someMethod', 'test'],
['Router.someMethodWithParenthesis', 'test'],
['FormGroup', 'test'],
['FormGroup.someMethod', 'test'],
]),
);
const entry = addHtmlAdditionalLinks({
jsdocTags: [
{
@ -23,6 +34,30 @@ describe('jsdoc transforms', () => {
name: 'see',
comment: '{@link Route Something else}',
},
{
name: 'see',
comment: '{@link #someMethod}',
},
{
name: 'see',
comment: '{@link #someMethodWithParenthesis()}',
},
{
name: 'see',
comment: '{@link someMethod()}',
},
{
name: 'see',
comment: '{@link FormGroup.someMethod()}',
},
{
name: 'see',
comment: '{@link https://angular.dev/api/core/ApplicationRef}',
},
{
name: 'see',
comment: '{@link https://angular.dev}',
},
],
moduleName: 'test',
});
@ -48,5 +83,31 @@ describe('jsdoc transforms', () => {
label: 'Something else',
url: '/api/test/Route',
});
expect(entry.additionalLinks[4]).toEqual({
label: 'someMethod',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[5]).toEqual({
label: 'someMethodWithParenthesis()',
url: '/api/test/Router#someMethodWithParenthesis',
});
expect(entry.additionalLinks[6]).toEqual({
label: 'someMethod()',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[7]).toEqual({
label: 'FormGroup.someMethod()',
url: '/api/test/FormGroup#someMethod',
});
expect(entry.additionalLinks[8]).toEqual({
label: 'ApplicationRef',
url: 'https://angular.dev/api/core/ApplicationRef',
});
expect(entry.additionalLinks[9]).toEqual({
label: 'angular.dev',
url: 'https://angular.dev',
});
});
});

View file

@ -31,7 +31,7 @@ import {
import {getLinkToModule} from './url-transforms';
import {addApiLinksToHtml} from './code-transforms';
import {getModuleName} from '../symbol-context';
import {getCurrentSymbol, getModuleName, logUnknownSymbol} from '../symbol-context';
export const JS_DOC_USAGE_NOTES_TAG = 'usageNotes';
export const JS_DOC_SEE_TAG = 'see';
@ -197,27 +197,41 @@ function convertLinks(text: string) {
});
}
function parseAtLink(link: string) {
function parseAtLink(link: string): {label: string; url: string} {
// Because of microsoft/TypeScript/issues/59679
// getTextOfJSDocComment introduces an extra space between the symbol and a trailing ()
link = link.replace(/ \(\)$/, '');
let [rawSymbol, description] = link.split(/\s(.+)/);
let [symbol, subSymbol] = rawSymbol.split(/(?:#|\.)/);
const moduleName = getModuleName(symbol)!;
if (!moduleName) {
logWarning(link, symbol);
if (rawSymbol.startsWith('#')) {
rawSymbol = rawSymbol.substring(1);
} else if (rawSymbol.startsWith('http://') || rawSymbol.startsWith('https://')) {
return {
url: rawSymbol,
label: rawSymbol.split('/').pop()!,
};
}
return {
label: description ?? rawSymbol,
url: getLinkToModule(moduleName, symbol, subSymbol),
};
}
let [symbol, subSymbol] = rawSymbol.replace(/\(\)$/, '').split(/(?:#|\.)/);
function logWarning(link: string, symbol: string) {
// TODO: remove the links that generate this error
// TODO: throw an error when there are no more warning generated
console.warn(`WARNING: {@link ${link}} is invalid, ${symbol} is unknown in this context`);
let moduleName = getModuleName(symbol);
const label = description ?? rawSymbol;
const currentSymbol = getCurrentSymbol();
if (!moduleName) {
// 2nd attemp, try to get the module name in the context of the current symbol
moduleName = getModuleName(`${currentSymbol}.${symbol}`);
if (!moduleName || !currentSymbol) {
// TODO: remove the links that generate this error
// TODO: throw an error when there are no more warning generated
logUnknownSymbol(link, symbol);
return {label, url: '#'};
}
subSymbol = symbol;
symbol = currentSymbol;
}
return {label, url: getLinkToModule(moduleName, symbol, subSymbol)};
}