Skip to content

Commit eca4451

Browse files
committed
Validate anchors in relative links
1 parent 45959de commit eca4451

File tree

25 files changed

+420
-219
lines changed

25 files changed

+420
-219
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ title: Changelog
88

99
- Relaxed requirements for file names and generated url fragments. This may
1010
result in a different file name structure, #2714.
11+
- Anchors to document headings and reflections within a HTML generated pages
12+
have changed. They can be partially restored to the previous format by
13+
setting `--sluggerConfiguration.lowercase false`. This change was made to
14+
more closely match the default behavior of GitHub's markdown rendering and
15+
VSCode's autocomplete when creating a relative link to an external markdown
16+
file.
1117
- Removed the `hideParameterTypesInTitle` option, this was originally added as
1218
a workaround for many signatures overflowing the available horizontal space
1319
in rendered pages. TypeDoc now has logic to wrap types/signatures smartly,
@@ -26,6 +32,8 @@ title: Changelog
2632

2733
- TypeDoc will now discover entry points from `package.json` exports if they
2834
are not provided manually, #1937.
35+
- Relative links to markdown files may now include `#anchor` links to
36+
reference a heading within them.
2937
- Improved support for `@param` comments with nested object types, #2555.
3038
- Improved support for `@param` comments which reference a type
3139
alias/interface. Important properties on the referenced type can now be
@@ -94,7 +102,6 @@ title: Changelog
94102

95103
TODO:
96104

97-
- Validate anchors within relative linked paths?
98105
- Figure out automation for beta releases
99106

100107
# Unreleased

site/options/output.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,38 @@ categories/groups.
354354
- `@showCategories`
355355
- `@hideCategories`
356356

357+
## headings
358+
359+
```json
360+
// typedoc.json
361+
{
362+
"headings": {
363+
"readme": true,
364+
"document": false
365+
}
366+
}
367+
```
368+
369+
Defines whether a heading describing the reflection should be included on the rendered page.
370+
371+
## sluggerConfiguration
372+
373+
```json
374+
// typedoc.json
375+
{
376+
"sluggerConfiguration": {
377+
"lowercase": true
378+
}
379+
}
380+
```
381+
382+
Determines how anchors within a page are created. This option exists primarily
383+
for backwards compatibility. It may be removed in a future release. TypeDoc 0.26
384+
did not lowercase headings within a page which is inconsistent with how GitHub
385+
pages sites commonly generate headings and does not play well with VSCode's
386+
autocomplete to anchors within external markdown files. In 0.27, this option
387+
defaults to `true`.
388+
357389
## navigationLeaves
358390

359391
```json

site/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ support more versions of TypeScript.
3232

3333
TypeDoc's CLI can be used through your terminal or npm scripts. Any arguments
3434
passed to TypeDoc which are not flags are parsed as entry points. TypeDoc will
35-
also read configuration from several files. See [Configuration](./options/configuration.md)
35+
also read configuration from several files. See [Configuration](./options/configuration.md#compileroptions)
3636
for details on where options are read from.
3737

3838
<details>

src/lib/converter/comments/textParser.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import type {
99
TranslationProxy,
1010
TranslatedString,
1111
} from "../../internationalization/index.js";
12-
import type { CommentDisplayPart } from "../../models/index.js";
12+
import type {
13+
CommentDisplayPart,
14+
RelativeLinkDisplayPart,
15+
} from "../../models/index.js";
1316
import type { FileRegistry } from "../../models/FileRegistry.js";
1417
import { HtmlAttributeParser, ParserState } from "../../utils/html.js";
1518
import { type Token, TokenSyntaxKind } from "./lexer.js";
@@ -32,6 +35,7 @@ interface RelativeLink {
3235
end: number;
3336
/** May be undefined if the registry can't find this file */
3437
target: number | undefined;
38+
targetAnchor: string | undefined;
3539
}
3640

3741
/**
@@ -92,11 +96,13 @@ export function textContent(
9296
kind: "text",
9397
text: token.text.slice(lastPartEnd, ref.pos),
9498
});
95-
outContent.push({
99+
const link: RelativeLinkDisplayPart = {
96100
kind: "relative-link",
97101
text: token.text.slice(ref.pos, ref.end),
98102
target: ref.target,
99-
});
103+
targetAnchor: ref.targetAnchor,
104+
};
105+
outContent.push(link);
100106
lastPartEnd = ref.end;
101107
data.pos = ref.end;
102108
if (!ref.target) {
@@ -190,10 +196,15 @@ function checkMarkdownLink(
190196
// Only make a relative-link display part if it's actually a relative link.
191197
// Discard protocol:// links, unix style absolute paths, and windows style absolute paths.
192198
if (isRelativePath(link.str)) {
199+
const { target, anchor } = files.register(
200+
sourcePath,
201+
link.str,
202+
) || { target: undefined, anchor: undefined };
193203
return {
194204
pos: labelEnd + 2,
195205
end: link.pos,
196-
target: files.register(sourcePath, link.str),
206+
target,
207+
targetAnchor: anchor,
197208
};
198209
}
199210

@@ -241,10 +252,15 @@ function checkReference(data: TextParserData): RelativeLink | undefined {
241252

242253
if (link.ok) {
243254
if (isRelativePath(link.str)) {
255+
const { target, anchor } = files.register(
256+
sourcePath,
257+
link.str,
258+
) || { target: undefined, anchor: undefined };
244259
return {
245260
pos: lookahead,
246261
end: link.pos,
247-
target: files.register(sourcePath, link.str),
262+
target,
263+
targetAnchor: anchor,
248264
};
249265
}
250266

@@ -286,13 +302,15 @@ function checkAttribute(
286302

287303
if (isRelativePath(parser.currentAttributeValue)) {
288304
data.pos = parser.pos;
305+
const { target, anchor } = data.files.register(
306+
data.sourcePath,
307+
parser.currentAttributeValue,
308+
) || { target: undefined, anchor: undefined };
289309
return {
290310
pos: parser.currentAttributeValueStart,
291311
end: parser.currentAttributeValueEnd,
292-
target: data.files.register(
293-
data.sourcePath,
294-
parser.currentAttributeValue,
295-
),
312+
target,
313+
targetAnchor: anchor,
296314
};
297315
}
298316
return;

src/lib/internationalization/locales/en.cts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export = {
131131
could_not_empty_output_directory_0: `Could not empty the output directory {0}`,
132132
could_not_create_output_directory_0: `Could not create the output directory {0}`,
133133
theme_0_is_not_defined_available_are_1: `The theme '{0}' is not defined. The available themes are: {1}`,
134-
custom_theme_does_not_define_getSlugger: `Custom theme does not define a getSlugger(reflection) method, but tries to render markdown`,
134+
reflection_0_links_to_1_but_anchor_does_not_exist_try_2: `{0} links to {1}, but the anchor does not exist. You may have meant:\n\t{2}`,
135135

136136
// entry points
137137
no_entry_points_provided:
@@ -295,6 +295,8 @@ export = {
295295
help_navigationLeaves:
296296
"Branches of the navigation tree which should not be expanded",
297297
help_headings: "Determines which optional headings are rendered",
298+
help_sluggerConfiguration:
299+
"Determines how anchors within rendered HTML are determined.",
298300
help_navigation: "Determines how the navigation sidebar is organized",
299301
help_visibilityFilters:
300302
"Specify the default visibility for builtin filters and additional filters according to modifier tags",

src/lib/internationalization/locales/jp.cts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,6 @@ export = localeUtils.buildIncompleteTranslation({
142142
"出力ディレクトリ {0} を作成できませんでした",
143143
theme_0_is_not_defined_available_are_1:
144144
"テーマ '{0}' は定義されていません。使用可能なテーマは次のとおりです: {1}",
145-
custom_theme_does_not_define_getSlugger:
146-
"カスタムテーマはgetSlugger(reflection)メソッドを定義していませんが、マークダウンをレンダリングしようとします",
147145
// no_entry_points_provided:
148146
unable_to_find_any_entry_points:
149147
"エントリ ポイントが見つかりません。以前の警告を参照してください",

src/lib/internationalization/locales/zh.cts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,6 @@ export = localeUtils.buildIncompleteTranslation({
146146
could_not_empty_output_directory_0: "无法清空输出目录 {0}",
147147
could_not_create_output_directory_0: "无法创建输出目录 {0}",
148148
theme_0_is_not_defined_available_are_1: "主题“{0}”未定义。可用主题为:{1}",
149-
custom_theme_does_not_define_getSlugger:
150-
"自定义主题没有定义 getSlugger(reflection) 方法,但尝试渲染 markdown",
151149

152150
no_entry_points_provided: "没有提供入口点,这可能是配置错误",
153151
unable_to_find_any_entry_points: "无法找到任何入口点。请参阅先前的警告",

src/lib/models/FileRegistry.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,44 @@ export class FileRegistry {
1919
protected names = new Map<number, string>();
2020
protected nameUsage = new Map<string, number>();
2121

22-
registerAbsolute(absolute: string) {
22+
registerAbsolute(absolute: string): {
23+
target: number;
24+
anchor: string | undefined;
25+
} {
26+
const anchorIndex = absolute.indexOf("#");
27+
let anchor: string | undefined = undefined;
28+
if (anchorIndex !== -1) {
29+
anchor = absolute.substring(anchorIndex + 1);
30+
absolute = absolute.substring(0, anchorIndex);
31+
}
2332
absolute = normalizePath(absolute).replace(/#.*/, "");
2433
const existing = this.pathToMedia.get(absolute);
2534
if (existing) {
26-
return existing;
35+
return { target: existing, anchor };
2736
}
2837

2938
this.mediaToPath.set(this.nextId, absolute);
3039
this.pathToMedia.set(absolute, this.nextId);
3140

32-
return this.nextId++;
41+
return { target: this.nextId++, anchor };
3342
}
3443

3544
/** Called by {@link ProjectReflection.registerReflection} @internal*/
3645
registerReflection(absolute: string, reflection: Reflection) {
3746
absolute = normalizePath(absolute);
38-
const id = this.registerAbsolute(absolute);
47+
const { target } = this.registerAbsolute(absolute);
3948
this.reflectionToPath.set(reflection.id, absolute);
40-
this.mediaToReflection.set(id, reflection.id);
49+
this.mediaToReflection.set(target, reflection.id);
4150
}
4251

4352
getReflectionPath(reflection: Reflection): string | undefined {
4453
return this.reflectionToPath.get(reflection.id);
4554
}
4655

47-
register(sourcePath: string, relativePath: string): number | undefined {
56+
register(
57+
sourcePath: string,
58+
relativePath: string,
59+
): { target: number; anchor: string | undefined } | undefined {
4860
return this.registerAbsolute(
4961
resolve(dirname(sourcePath), relativePath),
5062
);
@@ -131,7 +143,8 @@ export class FileRegistry {
131143
fromObject(de: Deserializer, obj: JSONFileRegistry): void {
132144
for (const [key, val] of Object.entries(obj.entries)) {
133145
const absolute = normalizePath(resolve(de.projectRoot, val));
134-
de.oldFileIdToNewFileId[+key] = this.registerAbsolute(absolute);
146+
de.oldFileIdToNewFileId[+key] =
147+
this.registerAbsolute(absolute).target;
135148
}
136149

137150
de.defer((project) => {
@@ -154,12 +167,10 @@ export class ValidatingFileRegistry extends FileRegistry {
154167
override register(
155168
sourcePath: string,
156169
relativePath: string,
157-
): number | undefined {
158-
const absolute = resolve(dirname(sourcePath), relativePath).replace(
159-
/#.*/,
160-
"",
161-
);
162-
if (!isFile(absolute)) {
170+
): { target: number; anchor: string | undefined } | undefined {
171+
const absolute = resolve(dirname(sourcePath), relativePath);
172+
const absoluteWithoutAnchor = absolute.replace(/#.*/, "");
173+
if (!isFile(absoluteWithoutAnchor)) {
163174
return;
164175
}
165176
return this.registerAbsolute(absolute);
@@ -178,7 +189,8 @@ export class ValidatingFileRegistry extends FileRegistry {
178189
continue;
179190
}
180191

181-
de.oldFileIdToNewFileId[+key] = this.registerAbsolute(absolute);
192+
de.oldFileIdToNewFileId[+key] =
193+
this.registerAbsolute(absolute).target;
182194
}
183195

184196
de.defer((project) => {

src/lib/models/comments/comment.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export interface RelativeLinkDisplayPart {
6868
* This may be `undefined` if the relative path does not exist.
6969
*/
7070
target: number | undefined;
71+
/**
72+
* Anchor within the target page, validated after rendering if possible
73+
*/
74+
targetAnchor: string | undefined;
7175
}
7276

7377
/**
@@ -230,7 +234,7 @@ export class Comment {
230234
case "relative-link": {
231235
return {
232236
...part,
233-
};
237+
} satisfies JSONOutput.CommentDisplayPart;
234238
}
235239
}
236240
});
@@ -293,11 +297,16 @@ export class Comment {
293297
kind: "relative-link",
294298
text: part.text,
295299
target: null!,
300+
targetAnchor: part.targetAnchor,
296301
} satisfies RelativeLinkDisplayPart;
297302
files.push([part.target, part2]);
298303
return part2;
299304
}
300-
return { ...part, target: undefined };
305+
return {
306+
...part,
307+
target: undefined,
308+
targetAnchor: part.targetAnchor,
309+
};
301310
}
302311
}
303312
});

src/lib/models/reflections/abstract.ts

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import type {
1717
TranslatedString,
1818
} from "../../internationalization/index.js";
1919
import type { ParameterReflection } from "./parameter.js";
20-
import { createNormalizedUrl } from "../../utils/html.js";
2120
import type { ReferenceReflection } from "./reference.js";
2221

2322
/**
@@ -315,13 +314,6 @@ export abstract class Reflection {
315314
*/
316315
hasOwnDocument?: boolean;
317316

318-
/**
319-
* Url safe alias for this reflection.
320-
*/
321-
private _alias?: string;
322-
323-
private _aliases?: Map<string, number>;
324-
325317
constructor(name: string, kind: ReflectionKind, parent?: Reflection) {
326318
this.id = REFLECTION_ID++ as ReflectionId;
327319
this.parent = parent;
@@ -392,45 +384,6 @@ export abstract class Reflection {
392384
this.flags.setFlag(flag, value);
393385
}
394386

395-
/**
396-
* Return an url safe alias for this reflection.
397-
*/
398-
getAlias(): string {
399-
this._alias ||= this.getUniqueAliasInPage(
400-
createNormalizedUrl(this.name) || `reflection-${this.id}`,
401-
);
402-
403-
return this._alias;
404-
}
405-
406-
// This really ought not live here, it ought to live in the html renderer, but moving that
407-
// is more work than I want right now, it can wait for 0.27 when trying to split models into
408-
// a bundleable structure.
409-
getUniqueAliasInPage(heading: string) {
410-
// NTFS/ExFAT use uppercase, so we will too. It probably won't matter
411-
// in this case since names will generally be valid identifiers, but to be safe...
412-
const upperAlias = heading.toUpperCase();
413-
414-
let target = this as Reflection;
415-
while (target.parent && !target.hasOwnDocument) {
416-
target = target.parent;
417-
}
418-
419-
target._aliases ||= new Map();
420-
421-
let suffix = "";
422-
if (!target._aliases.has(upperAlias)) {
423-
target._aliases.set(upperAlias, 1);
424-
} else {
425-
const count = target._aliases.get(upperAlias)!;
426-
suffix = "-" + count.toString();
427-
target._aliases.set(upperAlias, count + 1);
428-
}
429-
430-
heading += suffix;
431-
return heading;
432-
}
433-
434387
/**
435388
* Has this reflection a visible comment?
436389
*

0 commit comments

Comments
 (0)