Skip to content

Commit f54fee0

Browse files
committed
Support hashtags in markdown()
1 parent 7d130cf commit f54fee0

File tree

4 files changed

+107
-6
lines changed

4 files changed

+107
-6
lines changed

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"imports": {
1919
"@fedify/fedify": "jsr:@fedify/fedify@^1.3.4",
20+
"@fedify/markdown-it-hashtag": "jsr:@fedify/markdown-it-hashtag@^0.3.0",
2021
"@fedify/markdown-it-mention": "jsr:@fedify/markdown-it-mention@^0.2.0",
2122
"@hongminhee/x-forwarded-fetch": "jsr:@hongminhee/x-forwarded-fetch@^0.2.0",
2223
"@hono/hono": "jsr:@hono/hono@^4.6.18",

docs/concepts/text.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ The above code will create a text like this:
404404
Markdown
405405
--------
406406

407+
> [!NOTE]
408+
> The `markdown()` function does not support raw HTML syntax.
409+
407410
Sometimes you have a Markdown text and want to render it as a `Text` object.
408411
You can use the `markdown()` function to convert the Markdown text to the `Text`
409412
object. It is a block construct. For example:
@@ -452,7 +455,11 @@ The above code will create a text like this:
452455
> - I can have an _italic_ text.
453456
454457
Besides the standard Markdown syntax, the `markdown()` function also supports
455-
the following mentioning syntax for the fediverse:
458+
mentioning and hashtag syntax for the fediverse.
459+
460+
### Mentions
461+
462+
The following example shows how to mention an account:
456463
457464
~~~~ typescript
458465
markdown(`Hello, @fedify@hollo.social!`)
@@ -483,8 +490,39 @@ The above code will create a text like this:
483490
484491
> Hello, @fedify@hollo.social!
485492
493+
### Hashtags
494+
495+
The following example shows how to include a hashtag:
496+
497+
~~~~ typescript
498+
markdown(`Here's a hashtag: #BotKit`)
499+
~~~~
500+
501+
The above code will create a text like this:
502+
503+
> Here's a hashtag: [#BotKit](https://mastodon.social/tags/botkit).
504+
486505
> [!NOTE]
487-
> The `markdown()` function does not support raw HTML syntax.
506+
> The `markdown()` function does not only format the hashtag but also denotes
507+
> the hashtag so that ActivityPub software can recognize it as a hashtag.
508+
> The hashtag will be searchable in the fediverse (some software may search it
509+
> only from public messages though). If you want to just link to the hashtag
510+
> without denoting it, use the normal link syntax instead:
511+
>
512+
> ~~~~ typescript
513+
> markdown(`Here's a hashtag: [#BotKit](https://mastodon.social/tags/botkit)`)
514+
> ~~~~
515+
516+
If you want `#`-syntax to be treated as a normal text, turn off the syntax
517+
by setting the `hashtags` option to `false`:
518+
519+
~~~~ typescript
520+
markdown(`Here's a hashtag: #BotKit`, { hashtags: false })
521+
~~~~
522+
523+
The above code will create a text like this:
524+
525+
> Here's a hashtag: #BotKit.
488526
489527
490528
Determining if the text mentions an account

src/text.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,8 @@ Deno.test("markdown()", async () => {
559559
- I can have a list.
560560
- I can have a **bold** text.
561561
- I can have an _italic_ text.
562-
- I can mention @fedify@hollo.social.`;
562+
- I can mention @fedify@hollo.social.
563+
- I can tag #Hashtag.`;
563564
const t: Text<"block", void> = markdown(md);
564565
assertEquals(
565566
(await Array.fromAsync(t.getHtml(session))).join(""),
@@ -571,13 +572,17 @@ Deno.test("markdown()", async () => {
571572
'<a translate="no" class="h-card u-url mention" target="_blank" href="https://hollo.social/@fedify">' +
572573
'<span class="at">@</span><span class="user">fedify</span>' +
573574
'<span class="at">@</span><span class="domain">hollo.social</span></a>.</li>\n' +
575+
'<li>I can tag <a class="mention hashtag" rel="tag" target="_blank" href="https://example.com/tags/hashtag">#<span>Hashtag</span></a>.</li>\n' +
574576
"</ul>\n",
575577
);
576578
const tags = await Array.fromAsync(t.getTags(session));
577-
assertEquals(tags.length, 1);
579+
assertEquals(tags.length, 2);
578580
assertInstanceOf(tags[0], Mention);
579581
assertEquals(tags[0].name, "@fedify@hollo.social");
580582
assertEquals(tags[0].href, new URL("https://hollo.social/@fedify"));
583+
assertInstanceOf(tags[1], Hashtag);
584+
assertEquals(tags[1].name, "#hashtag");
585+
assertEquals(tags[1].href, new URL("https://example.com/tags/hashtag"));
581586
const cache = t.getCachedObjects();
582587
assertEquals(cache.length, 1);
583588
assertInstanceOf(cache[0], Person);

src/text.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type Object,
2323
} from "@fedify/fedify";
2424
import { Hashtag } from "@fedify/fedify/vocab";
25+
import { hashtag as hashtagPlugin } from "@fedify/markdown-it-hashtag";
2526
import {
2627
mention as mentionPlugin,
2728
toFullHandle,
@@ -718,6 +719,12 @@ export interface MarkdownTextOptions {
718719
*/
719720
readonly mentions?: boolean;
720721

722+
/**
723+
* Whether to render hashtags in the Markdown text.
724+
* @default {true}
725+
*/
726+
readonly hashtags?: boolean;
727+
721728
/**
722729
* Whether to automatically linkify URLs in the Markdown text.
723730
* @default {true}
@@ -727,6 +734,8 @@ export interface MarkdownTextOptions {
727734

728735
interface MarkdownEnv {
729736
mentions: string[];
737+
hashtags: string[];
738+
origin: string;
730739
actors?: Record<string, string | null>;
731740
}
732741

@@ -739,6 +748,7 @@ export class MarkdownText<TContextData> implements Text<"block", TContextData> {
739748
readonly #content: string;
740749
readonly #markdownIt: typeof MarkdownIt;
741750
readonly #mentions?: string[];
751+
readonly #hashtags?: string[];
742752
#actors?: Record<string, Object>;
743753

744754
/**
@@ -767,10 +777,40 @@ export class MarkdownText<TContextData> implements Text<"block", TContextData> {
767777
},
768778
label: toFullHandle,
769779
});
770-
const env: MarkdownEnv = { mentions: [] };
780+
const env: MarkdownEnv = {
781+
mentions: [],
782+
hashtags: [],
783+
origin: "http://localhost",
784+
};
771785
md.render(content, env);
772786
this.#mentions = env.mentions;
773787
}
788+
if (options.hashtags ?? true) {
789+
md.use(hashtagPlugin, {
790+
link(hashtag: string, env: MarkdownEnv) {
791+
const tag = hashtag.substring(1).toLowerCase();
792+
return new URL(`/tags/${encodeURIComponent(tag)}`, env.origin).href;
793+
},
794+
linkAttributes(_hashtag: string, _env: MarkdownEnv) {
795+
return {
796+
class: "mention hashtag",
797+
rel: "tag",
798+
target: "_blank",
799+
};
800+
},
801+
label(hashtag: string, _env: MarkdownEnv) {
802+
const tag = hashtag.substring(1);
803+
return `#<span>${tag}</span>`;
804+
},
805+
});
806+
const env: MarkdownEnv = {
807+
mentions: [],
808+
hashtags: [],
809+
origin: "http://localhost",
810+
};
811+
md.render(content, env);
812+
this.#hashtags = env.hashtags;
813+
}
774814
this.#markdownIt = md;
775815
}
776816

@@ -815,7 +855,12 @@ export class MarkdownText<TContextData> implements Text<"block", TContextData> {
815855
] satisfies [string, string | null]
816856
).filter(([_, url]) => url != null),
817857
);
818-
const env: MarkdownEnv = { mentions: [], actors };
858+
const env: MarkdownEnv = {
859+
mentions: [],
860+
hashtags: [],
861+
origin: session.context.origin,
862+
actors,
863+
};
819864
yield this.#markdownIt.render(this.#content, env);
820865
}
821866

@@ -829,6 +874,18 @@ export class MarkdownText<TContextData> implements Text<"block", TContextData> {
829874
href: object.id,
830875
});
831876
}
877+
if (this.#hashtags != null) {
878+
for (const hashtag of this.#hashtags) {
879+
const tag = hashtag.substring(1).toLowerCase();
880+
yield new Hashtag({
881+
name: `#${tag}`,
882+
href: new URL(
883+
`/tags/${encodeURIComponent(tag)}`,
884+
session.context.origin,
885+
),
886+
});
887+
}
888+
}
832889
}
833890

834891
getCachedObjects(): Object[] {

0 commit comments

Comments
 (0)