Skip to content

Commit 560b527

Browse files
committed
Add support for Markdown alerts
I ended up building a custom markdown-it plugin rather than using the obsidian plugin to match GitHub's implementation more closely and avoid adding a dependency.
1 parent 3040b1c commit 560b527

File tree

7 files changed

+245
-7
lines changed

7 files changed

+245
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ title: Changelog
88
- Relaxed requirements for file names and generated url fragments. This may result in a different file name structure, #2714.
99
- Added a new `outputs` option which is an array of outputs. This can be used to render the documentation multiple times
1010
with different rendering options or output types, #2597.
11+
- Added support for rendering alerts (or callouts) in markdown.
1112
- Fixed an issue where properties were not properly marked optional in some cases. This primarily affected destructured parameters.
1213
- Constructor signatures now use the parent class name as their name (e.g. `X`, not `new X`)
1314
- Removed the `hideParameterTypesInTitle` option, this was originally added as a workaround for many signatures overflowing
@@ -46,7 +47,6 @@ title: Changelog
4647

4748
TODO:
4849

49-
- https://github.com/ebullient/markdown-it-obsidian-callouts plugin
5050
- Validate anchors within relative linked paths?
5151
- Figure out automation for beta releases
5252

example/src/documents/markdown.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,36 @@ A Random Shakespeare Quote
5656
## An Image
5757

5858
<img src="../../media/typescript-logo.svg" width="120" />
59+
60+
## Alerts
61+
62+
GitHub supports [alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
63+
to highlight important content. TypeDoc also recognizes alerts and will style them similarly to GitHub.
64+
65+
To use an alert, include a blockquote in any markdown content which starts with an alert tag:
66+
67+
- `[!NOTE]`
68+
- `[!TIP]`
69+
- `[!IMPORTANT]`
70+
- `[!WARNING]`
71+
- `[!CAUTION]`
72+
73+
```md
74+
> [!NOTE]
75+
> Useful information that users should know, even when skimming content.
76+
```
77+
78+
> [!NOTE]
79+
> Useful information that users should know, even when skimming content.
80+
81+
> [!TIP]
82+
> Helpful advice for doing things better or more easily.
83+
84+
> [!IMPORTANT]
85+
> Key information users need to know to achieve their goal.
86+
87+
> [!WARNING]
88+
> Urgent info that needs immediate user attention to avoid problems.
89+
90+
> [!CAUTION]
91+
> Advises about risks or negative outcomes of certain actions.

example/typedoc.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
"json",
2626
"jsonc",
2727
"python",
28-
"yaml"
28+
"yaml",
29+
"markdown"
2930
],
3031
"markdownItOptions": {
3132
"html": true
32-
}
33+
},
34+
"suppressCommentWarningsInDeclarationFiles": true
3335
}

src/lib/internationalization/locales/en.cts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,13 @@ export = {
391391
option_outputs_must_be_array: `"outputs" option must be an array of { name: string, path: string, options?: TypeDocOptions } values.`,
392392
specified_output_0_has_not_been_defined: `Specified output "{0}" has not been defined.`,
393393

394+
// https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
395+
alert_note: "Note",
396+
alert_tip: "Tip",
397+
alert_important: "Important",
398+
alert_warning: "Warning",
399+
alert_caution: "Caution",
400+
394401
// ReflectionKind singular translations
395402
kind_project: "Project",
396403
kind_module: "Module",

src/lib/output/themes/MarkedPlugin.tsx

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import markdown from "markdown-it";
1+
import MarkdownIt from "markdown-it";
22
// @types/markdown-it is busted, this type isn't exported with ESM.
33
import type md from "markdown-it" with { "resolution-mode": "require" };
44

@@ -12,6 +12,7 @@ import type { DefaultTheme, DefaultThemeRenderContext, Renderer } from "../index
1212
import { Slugger } from "./default/Slugger.js";
1313
import { anchorIcon } from "./default/partials/anchor-icon.js";
1414
import { type Reflection, ReflectionKind, type CommentDisplayPart } from "../../models/index.js";
15+
import type { TranslatedString, TranslationProxy } from "../../internationalization/index.js";
1516

1617
let defaultSlugger: Slugger | undefined;
1718
function getDefaultSlugger(logger: Logger) {
@@ -39,7 +40,7 @@ export class MarkedPlugin extends ContextAwareRendererComponent {
3940
@Option("markdownLinkExternal")
4041
accessor markdownLinkExternal!: boolean;
4142

42-
private parser?: markdown;
43+
private parser?: MarkdownIt;
4344

4445
/**
4546
* This needing to be here really feels hacky... probably some nicer way to do this.
@@ -225,7 +226,7 @@ export class MarkedPlugin extends ContextAwareRendererComponent {
225226
* @returns The options object for the markdown parser.
226227
*/
227228
private setupParser() {
228-
this.parser = markdown({
229+
this.parser = MarkdownIt({
229230
...this.markdownItOptions,
230231
highlight: (code, lang) => {
231232
code = this.getHighlighted(code, lang || "ts");
@@ -239,6 +240,8 @@ export class MarkedPlugin extends ContextAwareRendererComponent {
239240
},
240241
});
241242

243+
githubAlertMarkdownPlugin(this.parser, this.application.i18n);
244+
242245
const loader = this.application.options.getValue("markdownItLoader");
243246
loader(this.parser);
244247

@@ -285,6 +288,13 @@ export class MarkedPlugin extends ContextAwareRendererComponent {
285288
}
286289
return self.renderToken(tokens, idx, options);
287290
};
291+
292+
this.parser.renderer.rules["alert_open"] = (tokens, idx) => {
293+
const icon = this.renderContext.icons[tokens[idx].attrGet("icon") as AlertIconName];
294+
const iconHtml = renderElement(icon());
295+
296+
return `<div class="${tokens[idx].attrGet("class")}"><div class="tsd-alert-title">${iconHtml}<span>${tokens[idx].attrGet("alert")}</span></div>`;
297+
};
288298
}
289299

290300
/**
@@ -303,3 +313,63 @@ function getTokenTextContent(token: md.Token): string {
303313
}
304314
return token.content;
305315
}
316+
317+
const kindNames = ["note", "tip", "important", "warning", "caution"];
318+
const iconNames = ["alertNote", "alertTip", "alertImportant", "alertWarning", "alertCaution"] as const;
319+
type AlertIconName = (typeof iconNames)[number];
320+
const kindTranslations: Array<(i18n: TranslationProxy) => TranslatedString> = [
321+
(i18n) => i18n.alert_note(),
322+
(i18n) => i18n.alert_tip(),
323+
(i18n) => i18n.alert_important(),
324+
(i18n) => i18n.alert_warning(),
325+
(i18n) => i18n.alert_caution(),
326+
];
327+
328+
function githubAlertMarkdownPlugin(md: MarkdownIt, i18n: TranslationProxy) {
329+
md.core.ruler.after("block", "typedoc-github-alert-plugin", (state) => {
330+
let bqStarts: number[] = [];
331+
332+
for (let i = 0; i < state.tokens.length; ++i) {
333+
const token = state.tokens[i];
334+
if (token.type === "blockquote_open") {
335+
bqStarts.push(i);
336+
} else if (token.type === "blockquote_close") {
337+
if (bqStarts.length === 1) {
338+
checkForAlert(state.tokens, bqStarts[0], i, i18n);
339+
}
340+
bqStarts.pop();
341+
}
342+
}
343+
});
344+
}
345+
346+
function checkForAlert(tokens: md.Token[], start: number, end: number, i18n: TranslationProxy) {
347+
let alertKind = -1;
348+
349+
// Search for the first "inline" token. That will be the blockquote text.
350+
for (let i = start; i < end; ++i) {
351+
if (tokens[i].type === "inline") {
352+
// Check for `[!NOTE]`
353+
const kindString = tokens[i].content.match(/^\[!(\w+)\]/);
354+
const kindIndex = kindNames.indexOf(kindString?.[1].toLowerCase() || "");
355+
if (kindIndex !== -1) {
356+
tokens[i].content = tokens[i].content.substring(kindString![0].length);
357+
alertKind = kindIndex;
358+
}
359+
break;
360+
}
361+
}
362+
363+
// If we found an alert, then replace the blockquote_open and blockquote_close tokens with
364+
// alert_open and alert_close tokens that can be rendered specially.
365+
if (alertKind === -1) return;
366+
367+
tokens[start].type = "alert_open";
368+
tokens[start].tag = "div";
369+
tokens[start].attrPush(["class", `tsd-alert tsd-alert-${kindNames[alertKind]}`]);
370+
tokens[start].attrPush(["alert", kindTranslations[alertKind](i18n)]);
371+
tokens[start].attrPush(["icon", iconNames[alertKind]]);
372+
373+
tokens[end].type = "alert_close";
374+
tokens[end].tag = "div";
375+
}

src/lib/output/themes/default/partials/icon.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// The alert icons in this file were taken from https://github.com/primer/octicons
2+
// which is under a MIT license https://github.com/primer/octicons/blob/main/LICENSE
3+
14
import assert from "assert";
25
import { ReflectionKind } from "../../../../models/index.js";
36
import { JSX } from "../../../../utils/index.js";
@@ -56,7 +59,19 @@ export function buildRefIcons<T extends Record<string, () => JSX.Element>>(
5659
}
5760

5861
export const icons: Record<
59-
ReflectionKind | "chevronDown" | "checkbox" | "menu" | "search" | "chevronSmall" | "anchor" | "folder",
62+
| ReflectionKind
63+
| "chevronDown"
64+
| "checkbox"
65+
| "menu"
66+
| "search"
67+
| "chevronSmall"
68+
| "anchor"
69+
| "folder"
70+
| "alertNote"
71+
| "alertTip"
72+
| "alertImportant"
73+
| "alertWarning"
74+
| "alertCaution",
6075
() => JSX.Element
6176
> = {
6277
[ReflectionKind.Accessor]: () => textIcon("A", "var(--color-ts-accessor)", true),
@@ -172,4 +187,44 @@ export const icons: Record<
172187
</g>
173188
</svg>
174189
),
190+
alertNote: () => (
191+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
192+
<path
193+
fill="var(--color-alert-note)"
194+
d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"
195+
/>
196+
</svg>
197+
),
198+
alertTip: () => (
199+
<svg width="16" height="16" viewBox="0 0 16 16">
200+
<path
201+
fill="var(--color-alert-tip)"
202+
d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"
203+
/>
204+
</svg>
205+
),
206+
alertImportant: () => (
207+
<svg width="16" height="16" viewBox="0 0 16 16">
208+
<path
209+
fill="var(--color-alert-important)"
210+
d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
211+
/>
212+
</svg>
213+
),
214+
alertWarning: () => (
215+
<svg width="16" height="16" viewBox="0 0 16 16">
216+
<path
217+
fill="var(--color-alert-warning)"
218+
d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
219+
/>
220+
</svg>
221+
),
222+
alertCaution: () => (
223+
<svg width="16" height="16" viewBox="0 0 16 16">
224+
<path
225+
fill="var(--color-alert-caution)"
226+
d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"
227+
/>
228+
</svg>
229+
),
175230
};

static/style.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
/* reference not included as links will be colored with the kind that it points to */
4646
--light-color-document: #000000;
4747

48+
--light-color-alert-note: #0969d9;
49+
--light-color-alert-tip: #1a7f37;
50+
--light-color-alert-important: #8250df;
51+
--light-color-alert-warning: #9a6700;
52+
--light-color-alert-caution: #cf222e;
53+
4854
--light-external-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='10' height='10'><path fill-opacity='0' stroke='%23000' stroke-width='10' d='m43,35H5v60h60V57M45,5v10l10,10-30,30 20,20 30-30 10,10h10V5z'/></svg>");
4955
--light-color-scheme: light;
5056

@@ -94,6 +100,12 @@
94100
/* reference not included as links will be colored with the kind that it points to */
95101
--dark-color-document: #ffffff;
96102

103+
--dark-color-alert-note: #0969d9;
104+
--dark-color-alert-tip: #1a7f37;
105+
--dark-color-alert-important: #8250df;
106+
--dark-color-alert-warning: #9a6700;
107+
--dark-color-alert-caution: #cf222e;
108+
97109
--dark-external-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='10' height='10'><path fill-opacity='0' stroke='%23fff' stroke-width='10' d='m43,35H5v60h60V57M45,5v10l10,10-30,30 20,20 30-30 10,10h10V5z'/></svg>");
98110
--dark-color-scheme: dark;
99111
}
@@ -145,6 +157,12 @@
145157
--color-ts-type-alias: var(--light-color-ts-type-alias);
146158
--color-document: var(--light-color-document);
147159

160+
--color-alert-note: var(--light-color-alert-note);
161+
--color-alert-tip: var(--light-color-alert-tip);
162+
--color-alert-important: var(--light-color-alert-important);
163+
--color-alert-warning: var(--light-color-alert-warning);
164+
--color-alert-caution: var(--light-color-alert-caution);
165+
148166
--external-icon: var(--light-external-icon);
149167
--color-scheme: var(--light-color-scheme);
150168
}
@@ -197,6 +215,12 @@
197215
--color-ts-type-alias: var(--dark-color-ts-type-alias);
198216
--color-document: var(--dark-color-document);
199217

218+
--color-alert-note: var(--dark-color-alert-note);
219+
--color-alert-tip: var(--dark-color-alert-tip);
220+
--color-alert-important: var(--dark-color-alert-important);
221+
--color-alert-warning: var(--dark-color-alert-warning);
222+
--color-alert-caution: var(--dark-color-alert-caution);
223+
200224
--external-icon: var(--dark-external-icon);
201225
--color-scheme: var(--dark-color-scheme);
202226
}
@@ -255,6 +279,12 @@ body {
255279
--color-ts-type-alias: var(--light-color-ts-type-alias);
256280
--color-document: var(--light-color-document);
257281

282+
--color-note: var(--light-color-note);
283+
--color-tip: var(--light-color-tip);
284+
--color-important: var(--light-color-important);
285+
--color-warning: var(--light-color-warning);
286+
--color-caution: var(--light-color-caution);
287+
258288
--external-icon: var(--light-external-icon);
259289
--color-scheme: var(--light-color-scheme);
260290
}
@@ -304,6 +334,12 @@ body {
304334
--color-ts-type-alias: var(--dark-color-ts-type-alias);
305335
--color-document: var(--dark-color-document);
306336

337+
--color-note: var(--dark-color-note);
338+
--color-tip: var(--dark-color-tip);
339+
--color-important: var(--dark-color-important);
340+
--color-warning: var(--dark-color-warning);
341+
--color-caution: var(--dark-color-caution);
342+
307343
--external-icon: var(--dark-external-icon);
308344
--color-scheme: var(--dark-color-scheme);
309345
}
@@ -487,6 +523,7 @@ pre {
487523
word-wrap: break-word;
488524
padding: 10px;
489525
border: 1px solid var(--color-accent);
526+
margin-bottom: 8px;
490527
}
491528
pre code {
492529
padding: 0;
@@ -549,6 +586,40 @@ blockquote {
549586
background-color: var(--color-background-secondary);
550587
}
551588

589+
.tsd-alert {
590+
padding: 8px 16px;
591+
margin-bottom: 16px;
592+
border-left: 0.25em solid var(--alert-color);
593+
}
594+
.tsd-alert blockquote > :last-child,
595+
.tsd-alert > :last-child {
596+
margin-bottom: 0;
597+
}
598+
.tsd-alert-title {
599+
color: var(--alert-color);
600+
display: inline-flex;
601+
align-items: center;
602+
}
603+
.tsd-alert-title span {
604+
margin-left: 4px;
605+
}
606+
607+
.tsd-alert-note {
608+
--alert-color: var(--color-alert-note);
609+
}
610+
.tsd-alert-tip {
611+
--alert-color: var(--color-alert-tip);
612+
}
613+
.tsd-alert-important {
614+
--alert-color: var(--color-alert-important);
615+
}
616+
.tsd-alert-warning {
617+
--alert-color: var(--color-alert-warning);
618+
}
619+
.tsd-alert-caution {
620+
--alert-color: var(--color-alert-caution);
621+
}
622+
552623
.tsd-breadcrumb {
553624
margin: 0;
554625
padding: 0;

0 commit comments

Comments
 (0)