Skip to content

Commit 6cc807c

Browse files
committed
feat: add character chat markdown plugin
1 parent 2eda1bb commit 6cc807c

11 files changed

Lines changed: 223 additions & 46 deletions

File tree

astro.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import rehypeExternalLinks from 'rehype-external-links'
1717
import remarkDirective from 'remark-directive' /* Handle ::: directives as nodes */
1818
import rehypeUnwrapImages from 'rehype-unwrap-images'
1919
import { remarkAdmonitions } from './src/plugins/remark-admonitions' /* Add admonitions */
20+
import remarkCharacterDialogue from './src/plugins/remark-character-dialogue' /* Custom plugin to handle character admonitions */
21+
import remarkUnknownDirectives from './src/plugins/remark-unknown-directives' /* Custom plugin to handle unknown admonitions */
2022
import remarkMath from 'remark-math' /* for latex math support */
2123
import rehypeKatex from 'rehype-katex' /* again, for latex math support */
2224
import remarkGemoji from './src/plugins/remark-gemoji' /* for shortcode emoji support */
@@ -34,6 +36,8 @@ export default defineConfig({
3436
remarkDirective,
3537
remarkGithubCard,
3638
remarkAdmonitions,
39+
[remarkCharacterDialogue, { characters: siteConfig.characters }],
40+
remarkUnknownDirectives,
3741
remarkMath,
3842
remarkGemoji,
3943
],

public/duck.webp

21.3 KB
Loading

public/owl.webp

20.7 KB
Loading

public/unicorn.webp

16.3 KB
Loading

src/content/posts/showing-off-blog-features/index.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,48 @@ testing123
120120
testing123
121121
:::
122122

123+
## Character Chats
124+
125+
```md title="Custom character chat" wrap
126+
:::duck
127+
**Did you know?** You can easily create custom character chats for your blog with MultiTerm!
128+
:::
129+
```
130+
131+
:::duck
132+
**Did you know?** You can easily create custom character chats for your blog with MultiTerm!
133+
:::
134+
135+
### Adding Your Own
136+
137+
To add your own character, first add an image file to the top-level `/public` directory in your cloned MultiTerm repo. Astro cannot automatically optimize image assets from markdown plugins, so make sure to compress the image to a web-friendly size (<100kb).
138+
139+
I recommend Google's free [Squoosh](https://squoosh.app) web app for creating super small webp files. The characters here have been resized to 300 pixels wide and exported to webp with 75% quality using Squoosh.
140+
141+
After you've added your image, update the `characters` option in `site.config.ts` with your newly added image file and restart the development server.
142+
143+
### Character Conversations
144+
145+
When there are multiple character chats in a row, the order of the chat image and chat bubble reverses to give the chat more of a back-and-forth appearance.
146+
147+
```md title="Sequential character chats"
148+
:::owl
149+
This is a cool feature!
150+
:::
151+
152+
:::unicorn
153+
I agree!
154+
:::
155+
```
156+
157+
:::owl
158+
This is a cool feature!
159+
:::
160+
161+
:::unicorn
162+
I agree!
163+
:::
164+
123165
## GitHub Cards
124166

125167
GitHub overview cards heavily inspired by [Astro Cactus](https://github.com/chrismwilliams/astro-theme-cactus).
@@ -130,7 +172,7 @@ GitHub overview cards heavily inspired by [Astro Cactus](https://github.com/chri
130172

131173
::github{repo="stelcodes/multiterm-astro"}
132174

133-
```md title="GitHub user card example in markdown"
175+
```md wrap=true title="GitHub user card example in markdown"
134176
::github{user="withastro"}
135177
```
136178

src/plugins/remark-admonitions.ts

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
1-
import type { Parent, PhrasingContent, Root } from 'mdast'
2-
import type { LeafDirective, TextDirective } from 'mdast-util-directive'
3-
import { directiveToMarkdown } from 'mdast-util-directive'
4-
import { toMarkdown } from 'mdast-util-to-markdown'
1+
import type { PhrasingContent, Root } from 'mdast'
52
import { toString as mdastToString } from 'mdast-util-to-string'
63
import type { Plugin } from 'unified'
74
import { visit } from 'unist-util-visit'
85
import type { AdmonitionType } from '~/types'
96
import { h as _h, type Properties } from 'hastscript'
10-
import type { Node, Paragraph as P } from 'mdast'
11-
import type { Directives } from 'mdast-util-directive'
12-
13-
/** Checks if a node is a directive. */
14-
function isNodeDirective(node: Node): node is Directives {
15-
return (
16-
node.type === 'containerDirective' ||
17-
node.type === 'leafDirective' ||
18-
node.type === 'textDirective'
19-
)
20-
}
7+
import type { Paragraph as P } from 'mdast'
218

229
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
2310
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
@@ -43,36 +30,9 @@ function isAdmonition(s: string): s is AdmonitionType {
4330
return Admonitions.has(s as AdmonitionType)
4431
}
4532

46-
/**
47-
* From Astro Starlight:
48-
* Transforms directives not supported back to original form as it can break user content and result in 'broken' output.
49-
*/
50-
function transformUnhandledDirective(
51-
node: LeafDirective | TextDirective,
52-
index: number,
53-
parent: Parent,
54-
) {
55-
const textNode = {
56-
type: 'text',
57-
value: toMarkdown(node, { extensions: [directiveToMarkdown()] }),
58-
} as const
59-
if (node.type === 'textDirective') {
60-
parent.children[index] = textNode
61-
} else {
62-
parent.children[index] = {
63-
children: [textNode],
64-
type: 'paragraph',
65-
}
66-
}
67-
}
68-
6933
export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => {
7034
visit(tree, (node, index, parent) => {
71-
if (!parent || index === undefined || !isNodeDirective(node)) return
72-
if (node.type === 'textDirective' || node.type === 'leafDirective') {
73-
transformUnhandledDirective(node, index, parent)
74-
return
75-
}
35+
if (!parent || index === undefined || node.type !== 'containerDirective') return
7636

7737
const admonitionType = node.name
7838
if (!isAdmonition(admonitionType)) return
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { PhrasingContent, Root } from 'mdast'
2+
import { toString as mdastToString } from 'mdast-util-to-string'
3+
import type { Plugin } from 'unified'
4+
import { visit } from 'unist-util-visit'
5+
import { h as _h, type Properties } from 'hastscript'
6+
import type { Paragraph as P } from 'mdast'
7+
8+
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
9+
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
10+
const { properties, tagName } = _h(el, attrs)
11+
return {
12+
children,
13+
data: { hName: tagName, hProperties: properties },
14+
type: 'paragraph',
15+
}
16+
}
17+
18+
const remarkCharacterDialogue: Plugin<[{ characters: Record<string, string> }], Root> =
19+
(opts) => (tree) => {
20+
// Type guard to check if a string is a valid character dialogue key
21+
function isCharacterDialogue(s: string): s is keyof typeof opts.characters {
22+
return opts.characters.hasOwnProperty(s) && opts.characters[s] !== undefined
23+
}
24+
25+
// Do nothing if no characters are defined
26+
if (!opts.characters || Object.keys(opts.characters).length === 0) {
27+
return
28+
}
29+
30+
visit(tree, (node, index, parent) => {
31+
if (!parent || index === undefined || node.type !== 'containerDirective') return
32+
33+
const characterName = node.name
34+
if (!isCharacterDialogue(characterName)) return
35+
36+
let title: string = characterName
37+
let titleNode: PhrasingContent[] = [{ type: 'text', value: title }]
38+
39+
// Check if there's a custom title
40+
const firstChild = node.children[0]
41+
if (
42+
firstChild?.type === 'paragraph' &&
43+
firstChild.data &&
44+
'directiveLabel' in firstChild.data &&
45+
firstChild.children.length > 0
46+
) {
47+
titleNode = firstChild.children
48+
title = mdastToString(firstChild.children)
49+
// The first paragraph contains a custom title, we can safely remove it.
50+
node.children.splice(0, 1)
51+
}
52+
53+
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
54+
const admonition = h(
55+
'aside',
56+
{
57+
'aria-label': `Character dialogue: ${title}`,
58+
class: 'character-dialogue',
59+
'data-character': characterName,
60+
},
61+
[
62+
// h('p', { class: 'admonition-title', 'aria-hidden': 'true' }, [...titleNode]),
63+
h('img', {
64+
class: 'character-dialogue-image',
65+
alt: title,
66+
loading: 'lazy',
67+
src: opts.characters[characterName],
68+
width: 100,
69+
}),
70+
h('div', { class: 'character-dialogue-content' }, node.children),
71+
],
72+
)
73+
74+
parent.children[index] = admonition
75+
})
76+
}
77+
78+
export default remarkCharacterDialogue
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Parent, Root } from 'mdast'
2+
import type { LeafDirective, TextDirective } from 'mdast-util-directive'
3+
import { directiveToMarkdown } from 'mdast-util-directive'
4+
import { toMarkdown } from 'mdast-util-to-markdown'
5+
import type { Plugin } from 'unified'
6+
import { visit } from 'unist-util-visit'
7+
import { h as _h } from 'hastscript'
8+
import type { Node } from 'mdast'
9+
import type { Directives } from 'mdast-util-directive'
10+
11+
/** Checks if a node is a directive. */
12+
function isNodeDirective(node: Node): node is Directives {
13+
return (
14+
node.type === 'containerDirective' ||
15+
node.type === 'leafDirective' ||
16+
node.type === 'textDirective'
17+
)
18+
}
19+
20+
/**
21+
* From Astro Starlight:
22+
* Transforms directives not supported back to original form as it can break user content and result in 'broken' output.
23+
*/
24+
function transformUnhandledDirective(
25+
node: LeafDirective | TextDirective,
26+
index: number,
27+
parent: Parent,
28+
) {
29+
const textNode = {
30+
type: 'text',
31+
value: toMarkdown(node, { extensions: [directiveToMarkdown()] }),
32+
} as const
33+
if (node.type === 'textDirective') {
34+
parent.children[index] = textNode
35+
} else {
36+
parent.children[index] = {
37+
children: [textNode],
38+
type: 'paragraph',
39+
}
40+
}
41+
}
42+
43+
const remarkUnknownAdmonitions: Plugin<[], Root> = () => (tree) => {
44+
visit(tree, (node, index, parent) => {
45+
if (!parent || index === undefined || !isNodeDirective(node)) return
46+
if (node.type === 'textDirective' || node.type === 'leafDirective') {
47+
transformUnhandledDirective(node, index, parent)
48+
}
49+
})
50+
}
51+
52+
export default remarkUnknownAdmonitions

src/site.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ const config: SiteConfig = {
171171
categoryId: 'DIC_kwDOPNnBis4CteOc',
172172
reactionsEnabled: true, // Enable reactions on post itself
173173
},
174+
// These are characters available for the character chat feature.
175+
// To add your own character, add an image file to the top-level `/public` directory
176+
// Make sure to compress the image to a web-friendly size (<100kb)
177+
// Try using the excellent https://squoosh.app web app for creating small webp files
178+
characters: {
179+
owl: '/owl.webp',
180+
unicorn: '/unicorn.webp',
181+
duck: '/duck.webp',
182+
},
174183
}
175184

176185
export default config

src/styles/global.css

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,38 @@ article img {
377377
}
378378

379379
aside {
380-
@apply bg-foreground/8;
380+
@apply bg-foreground/7;
381+
382+
&.character-dialogue {
383+
@apply sm:bg-transparent flex sm:items-start items-center justify-center gap-3 sm:flex-row px-2 md:px-5;
384+
385+
& + aside.character-dialogue {
386+
@apply sm:pt-1 -mt-2 sm:-mt-7;
387+
388+
&:nth-of-type(even) {
389+
@apply flex-row-reverse;
390+
391+
.character-dialogue-content:after {
392+
@apply -right-3 -rotate-90;
393+
left: initial;
394+
}
395+
}
396+
}
397+
398+
img.character-dialogue-image {
399+
@apply m-0 border-none shrink-1 w-20 sm:w-25;
400+
}
401+
402+
.character-dialogue-content {
403+
@apply relative shrink-5 sm:bg-foreground/6 px-1 mx-1.5 sm:px-6 rounded-xl sm:py-6 max-w-full sm:min-w-1/8;
404+
405+
/* Add pointy part of speech bubble to the bottom left corner */
406+
&:after {
407+
@apply hidden sm:block absolute top-8 -left-3 rotate-90 w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-foreground/6;
408+
content: '';
409+
}
410+
}
411+
}
381412

382413
p.admonition-title {
383414
@apply mb-2 inline-block font-semibold relative ml-6.5 md:ml-8.5;

0 commit comments

Comments
 (0)