23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/LinkOverride.astro:
--------------------------------------------------------------------------------
1 | ---
2 | type Props = {
3 | href: string;
4 | title?: string;
5 | };
6 |
7 | const { href, title } = Astro.props;
8 | const text = await Astro.slots.render("default");
9 |
10 | const isExternal = href.startsWith("http");
11 | const props = isExternal ? {
12 | target: "_blank",
13 | rel: "noopener"
14 | } : {}
15 | ---
16 |
17 |
18 | {isExternal && ↗}
19 |
20 |
21 |
26 |
--------------------------------------------------------------------------------
/src/content/examples/code.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: code
3 | ---
4 |
5 | ## code-fences to syntax highlight
6 |
7 | ````md
8 | ```ts
9 | const x = 1;
10 | ```
11 | ````
12 |
13 | ```ts
14 | const x = 1;
15 | ```
16 |
17 | ## inline code
18 |
19 | ```md
20 | `test`
21 | ```
22 |
23 | `test`
24 |
25 | ## code-fences to diagram
26 |
27 | ````md
28 | ```mermaid
29 | flowchart LR
30 | id
31 | ```
32 | ````
33 |
34 | ```mermaid
35 | flowchart LR
36 | id
37 | ```
38 |
39 | ## code-fences extra
40 |
41 | ````md
42 | ```some thing else
43 | extra
44 | ```
45 | ````
46 |
47 | ```some thing else
48 | extra
49 | ```
50 |
--------------------------------------------------------------------------------
/src/content/examples/directive.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: directive
3 | ---
4 |
5 | ## TextDirectives
6 |
7 | ```md
8 | :icon{#material-symbols:auto-awesome-outline} or
9 | :icon{id=material-symbols:auto-awesome-outline}
10 | ```
11 |
12 | :icon{#material-symbols:auto-awesome-outline}
13 |
14 | ```md
15 | :youtube{#TtRtkTzHVBU} or
16 | :youtube{id=TtRtkTzHVBU}
17 | ```
18 |
19 | :youtube{#TtRtkTzHVBU}
20 |
21 | ## LeafDirectives
22 |
23 | ```md
24 | ::noexample[text]{something=x}
25 | ```
26 |
27 | ## ContainerDirective
28 |
29 | ```md
30 | :::tip
31 | something
32 | :::
33 | ```
34 |
35 | :::tip
36 | something
37 | :::
38 |
--------------------------------------------------------------------------------
/src/components/CodeOverride.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { Code } from "astro:components";
3 | import { decode } from "./decode";
4 | import Mermaid from "./Mermaid.astro";
5 |
6 | type Props = {
7 | class?: string;
8 | metastring?: string;
9 | };
10 |
11 | const props = Astro.props;
12 | const inline = props.class === undefined;
13 | const lang = props.class?.replace("language-", "") as any;
14 | const code = decode(await Astro.slots.render("default"));
15 | ---
16 |
17 | {
18 | lang === "mermaid" ? (
19 |
20 | ) : (
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ImageOverride.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import type { ImageMetadata } from "astro";
3 | // import { Image } from "astro:assets";
4 | import { Picture } from "astro:assets";
5 |
6 | type Props = {
7 | src: string | ImageMetadata;
8 | alt: string;
9 | title?: string;
10 | };
11 |
12 | const { src, alt, title } = Astro.props;
13 | ---
14 |
15 |
16 | {
17 | typeof src === "string" ? (
18 |
19 | ) : (
20 |
21 | )
22 | }
23 | {title && {title}}
24 |
25 |
26 |
31 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "customize-markdown",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev",
7 | "start": "astro dev",
8 | "build": "astro check && astro build",
9 | "preview": "astro preview",
10 | "astro": "astro"
11 | },
12 | "dependencies": {
13 | "@astrojs/check": "^0.5.10",
14 | "@astrojs/mdx": "^2.3.1",
15 | "@iconify-json/material-symbols": "^1.1.78",
16 | "astro": "^4.16.1",
17 | "astro-embed": "^0.7.1",
18 | "astro-icon": "^1.1.0",
19 | "mermaid-isomorphic": "^2.1.2",
20 | "playwright": "^1.42.1",
21 | "playwright-chromium": "^1.42.1",
22 | "remark-directive": "^3.0.0",
23 | "remark-heading-id": "^1.0.1",
24 | "sharp": "^0.33.3",
25 | "typescript": "^5.4.5",
26 | "unist-util-visit": "^5.0.0"
27 | }
28 | }
--------------------------------------------------------------------------------
/src/layouts/Layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | interface Props {
3 | title: string;
4 | }
5 |
6 | const { title } = Astro.props;
7 | ---
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {title}
18 |
19 |
20 |
21 |
22 |
23 |
39 |
--------------------------------------------------------------------------------
/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "astro/config";
2 | import icon from "astro-icon";
3 | import mdx from "@astrojs/mdx";
4 | import remarkHeadingId from "remark-heading-id";
5 | import remarkDirective from "remark-directive";
6 | import { visit } from "unist-util-visit";
7 |
8 | function smallRemarkAdapter() {
9 | return function (tree) {
10 | visit(tree, function (node) {
11 | if (
12 | node.type === "containerDirective" ||
13 | node.type === "leafDirective" ||
14 | node.type === "textDirective"
15 | ) {
16 | node.data = node.data || {};
17 | node.data.hName = node.type;
18 | node.data.hProperties = {
19 | directive: node.name,
20 | ...(node.attributes || {}),
21 | };
22 | }
23 | });
24 | };
25 | }
26 |
27 | // https://astro.build/config
28 | export default defineConfig({
29 | integrations: [
30 | mdx({
31 | syntaxHighlight: false,
32 | remarkPlugins: [remarkDirective, smallRemarkAdapter, remarkHeadingId],
33 | }),
34 | icon(),
35 | ],
36 | });
37 |
--------------------------------------------------------------------------------
/alternative-proposal.md:
--------------------------------------------------------------------------------
1 | # Alternative proposal
2 |
3 | ## Introduction
4 |
5 | Idea is very similar to [Hugo render hooks](https://gohugo.io/render-hooks/). But in contrary to the initial proposal it doesn't involve Astro components, but instead it proposes to simplify working with remark/rehype plugins. It can be done through writing some kind API layer on top.
6 |
7 | For example, [markdown-it](https://github.com/markdown-it/markdown-it) has much simpler API:
8 |
9 | ```ts
10 | const markdownItInstance = markdownIt({
11 | highlight(value, lang) {
12 | return highlightToHtml(lang, value);
13 | },
14 | });
15 | ```
16 |
17 | So the idea is to create remark / rehype plugin which would expose those simple callbacks for most popular use-cases.
18 |
19 | Input of callback can be specific to tags.
20 |
21 | Output of callback can be:
22 |
23 | - `undefined` - leave node as is
24 | - `string | hast` - replace node with content
25 | - `Promise` - replace node with content of the promise "resolution"
26 | - It seems that remark / rehype supports promises (but I need to verify this)
27 |
--------------------------------------------------------------------------------
/src/components/Aside.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { Icon } from "astro-icon/components";
3 |
4 | type Props = {
5 | type: "note" | "tip" | "important" | "warning" | "caution";
6 | };
7 |
8 | const { type } = Astro.props;
9 |
10 | const icons = {
11 | note: "material-symbols:info",
12 | tip: "material-symbols:lightbulb-outline",
13 | important: "material-symbols:chat-info-outline",
14 | warning: "material-symbols:warning",
15 | caution: "material-symbols:stop-circle-outline",
16 | };
17 | ---
18 |
19 |
23 |
24 |
64 |
--------------------------------------------------------------------------------
/src/pages/examples/[...slug].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from "../../layouts/Content.astro";
3 | import { getCollection } from "astro:content";
4 |
5 | // 1. Generate a new path for every collection entry
6 | export async function getStaticPaths() {
7 | const examples = await getCollection("examples");
8 | return examples.map((example) => {
9 | return {
10 | params: { slug: example.slug },
11 | props: { entry: example },
12 | };
13 | });
14 | }
15 | // 2. For your template, you can get the entry directly from the prop
16 | const { entry } = Astro.props;
17 | const { Content } = await entry.render();
18 |
19 | import CodeOverride from "../../components/CodeOverride.astro";
20 | import ImageOverride from "../../components/ImageOverride.astro";
21 | import PreOverride from "../../components/PreOverride.astro";
22 | import BlockquoteOverride from "../../components/BlockquoteOverride.astro";
23 | import LinkOverride from "../../components/LinkOverride.astro";
24 | import H2Override from "../../components/H2Override.astro";
25 | import LeafDirective from "../../components/LeafDirective.astro";
26 | import ContainerDirective from "../../components/ContainerDirective.astro";
27 | import TextDirective from "../../components/TextDirective.astro";
28 |
29 | const components = {
30 | code: CodeOverride,
31 | img: ImageOverride,
32 | pre: PreOverride,
33 | blockquote: BlockquoteOverride,
34 | a: LinkOverride,
35 | h2: H2Override,
36 | leafdirective: LeafDirective,
37 | containerdirective: ContainerDirective,
38 | textdirective: TextDirective,
39 | };
40 | ---
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Testing override for MDX with components
2 |
3 | Testing how different overrides work in Astro:
4 |
5 | - [ ] Code - doesn't work as expected
6 | - [ ] `inline` not passed - no way to distinguish inline-code and block-code
7 | - had to do hack with `props.class === undefined`
8 | - [ ] `lang` not passed
9 | - had to do hack with `class?.replace("language-", "")`
10 | - [ ] block-code get's wrapped in extra `pre`
11 | - had to do hack with overriding `pre` and render it witout tag
12 | - [ ] `` - content is HTML string, which means that one needs to unescape it
13 | - is there a way to use something like `allowDangerousHTML` to pass raw strings to some nodes?
14 | - [x] `metastring`
15 | - [x] [remark-directive](https://github.com/remarkjs/remark-directive)
16 | - works if you add `smallRemarkAdapter`, then `leafdirective`, `containerdirective` and `textdirective` can be handled with MDX overrides
17 | - [x] Image - works as expected
18 | - [x] `src`
19 | - [x] `alt`
20 | - [x] `title`
21 | - [x] Link - works as expected
22 | - [x] `href`
23 | - [x] `title`
24 | - [x] ``
25 | - [x] Blockquote
26 | - [x] ``
27 | - content is HTML string though
28 | - [x] Heading
29 | - [x] `id`
30 | - [x] ``
31 | - [x] `{#custom-id}` (works with remark plugin)
32 | - `remark-custom-heading-id` didn't work in my case
33 | - `remark-heading-id` works, but in MDX one needs to escape curly-braces `\{#custom-id\}`
34 | - see https://github.com/withastro/roadmap/discussions/329
35 |
36 | ## Inspiration
37 |
38 | Currently overrides only supported for MDX, but not for markdown. [There is a proposal to support component overrides for markdown as well](https://github.com/withastro/roadmap/discussions/769).
39 |
40 | Ideally it should be as easy as [Hugo render hooks](https://gohugo.io/render-hooks/).
41 |
42 | ## Why does it matter?
43 |
44 | ### Case study 1: Aside in Starlight
45 |
46 | Starlight provides [`` component](https://github.com/withastro/starlight/blob/main/packages/starlight/user-components/Aside.astro).
47 |
48 | Also it provides [markdown support for it](https://github.com/withastro/starlight/blob/main/packages/starlight/integrations/asides.ts):
49 |
50 | ```md
51 | :::note
52 | Highlights information that users should take into account, even when skimming.
53 | :::
54 | ```
55 |
56 | They basically duplicate code: once it is written as remark plugin and once it is written as Astro component.
57 |
58 | Instead they can reuse it like this:
59 |
60 | ```astro
61 |
64 | ```
65 |
66 | ### Case study 2: Code in Starlight
67 |
68 | Astro provides [`` component](https://docs.astro.build/en/reference/api-reference/#code-).
69 |
70 | Also it provides markdown support for it:
71 |
72 | ````md
73 | ```ts
74 | const x = 1;
75 | ```
76 | ````
77 |
78 | They basically duplicate code: once it is written as remark plugin and once it is written as Astro component.
79 |
80 | **Plus**: if you want to customize it, for example, to add [Mermaid support](https://github.com/withastro/starlight/discussions/1259) you need to jump through hoops to undo what built-in syntax highlighter does.
81 |
82 | Instead they can reuse components like this:
83 |
84 | ```astro
85 | {
86 | lang === "mermaid" ? (
87 |
88 | ) : (
89 |
90 | )
91 | }
92 | ```
93 |
94 | Other potential use cases:
95 |
96 | - [Code Hike](https://codehike.org/)
97 | - [codehike#255](https://github.com/code-hike/codehike/issues/255)
98 | - [withastro/discussions#470](https://github.com/withastro/roadmap/discussions/470)
99 | - [Shiki-Twoslash](https://shikijs.github.io/twoslash/)
100 | - [starlight/discussions#1381](https://github.com/withastro/starlight/discussions/1381)
101 | - [Sandpack](https://sandpack.codesandbox.io/)
102 | - [A World-Class Code Playground with Sandpack](https://www.joshwcomeau.com/react/next-level-playground/)
103 | - [starry-night](https://github.com/wooorm/starry-night)
104 |
105 | ### Case study 3: Heading
106 |
107 | You may want to add anchors to headings. There is already rehype plugin for this - `rehype-autolink-headings`. But this requires [quite some configuration in different places](https://astro-digital-garden.stereobooster.com/recipes/anchors-for-headings/).
108 |
109 | On the other hand overriding component [encapsulates all logic in one place](src/components/H2Override.astro).
110 |
111 | ### Case study 4: Link
112 |
113 | You may want to add icons to external links or `target="_blank"` or `rel="nofollow"`. There is already rehype plugin for this - `rehype-external-links`. But this requires [quite some configuration in different places](https://astro-digital-garden.stereobooster.com/recipes/icons-to-external-links/).
114 |
115 | On the other hand overriding component [encapsulates all logic in one place](src/components/LinkOverride.astro).
116 |
117 | ### Case study 5: remark-directive
118 |
119 | `remark-directive` allows to create shortcuts for components, for example, one can create:
120 |
121 | - `:youtube{#TtRtkTzHVBU}` to use `astro-embed` YouTube component. See [TextDirective](src/components/TextDirective.astro)
122 | - `:icon{mdi:account}` to use `astro-icon` Icon component
123 | - `:::tip` to use `Aside` component (see above). See [ContainerDirective](src/components/ContainerDirective.astro)
124 |
125 | ## Ideas
126 |
127 | One can implement remark/rehype plugin and use [`await astro.renderToString(Component, { props, slots, request, params })`](https://github.com/withastro/roadmap/issues/533) to render components.
128 |
--------------------------------------------------------------------------------
/extended-proposal.md:
--------------------------------------------------------------------------------
1 | # Allow to override markdown with Astro components
2 |
3 | This is extended version of [original proposal](https://github.com/withastro/roadmap/discussions/769).
4 |
5 | ## Introduction
6 |
7 | Idea is very similar to [Hugo render hooks](https://gohugo.io/render-hooks/). Add ability to custmoize how markdown is rendered with Astro components:
8 |
9 | ```astro
10 | ---
11 | // ...
12 | const { Content } = await post.render();
13 | ---
14 |
15 |
21 | ```
22 |
23 | ## Motivating examples
24 |
25 | ### Reuse components
26 |
27 | If one already has Astro component it would be trivial to reuse it in markdown. For example, Starlight provides [`` component](https://github.com/withastro/starlight/blob/main/packages/starlight/user-components/Aside.astro). There would be no need to [reimplement the same component as remark/rehype plugin](https://github.com/withastro/starlight/blob/main/packages/starlight/integrations/asides.ts).
28 |
29 | Instead one could do:
30 |
31 | ```astro
32 | // ContainerDirective.astro
33 | ---
34 | import Aside from "./Aside.astro";
35 | ---
36 |
39 | ```
40 |
41 | and then
42 |
43 | ```astro
44 |
49 | ```
50 |
51 | ### Astro components are easier to deal with than remark/rehype plugins
52 |
53 | Remark and rehype provide low level API, which is nice if you develop plugins, but for the end-users this causes a lot of confusion.
54 |
55 | Situation is very similar to Vite API. It is overkill for Astro integration layer. That is why there is [Astro Integration Kit](https://github.com/withastro/roadmap/discussions/886), which creates additional layer tailored speciafically for Astro use case.
56 |
57 | Take a look [how much struggle it is to integrate Mermaid in Astro/Starlight](https://github.com/withastro/starlight/discussions/1259).
58 |
59 | On the other hand with proposed API it would be a trivial task:
60 |
61 | ```astro
62 | // CodeOverride.astro
63 | ---
64 | import { Code } from "astro:components";
65 | import Mermaid from "./Mermaid.astro";
66 | const { content, lang, inline } = Astro.props;
67 | ---
68 | {
69 | lang === "mermaid" ? (
70 |
71 | ) : (
72 |
73 | )
74 | }
75 | ```
76 |
77 | and then
78 |
79 | ```astro
80 |
85 | ```
86 |
87 | ### Astro components can be client-side components
88 |
89 | This API opens up door to for using client-side components for Markdown, which would be hard to do with remark/rehype plugins.
90 |
91 | For example, one can override code-fences to render Google Maps in place.
92 |
93 | ````md
94 | ```map
95 | 38.7223° N, 9.1393° W
96 | ```
97 | ````
98 |
99 | With something like this:
100 |
101 | ```astro
102 | // CodeOverride.astro
103 | ---
104 | import { Code } from "astro:components";
105 | import GoogleMaps from "./GoogleMaps.astro";
106 | const { content, lang, inline } = Astro.props;
107 | ---
108 | {
109 | lang === "map" ? (
110 |
111 | ) : (
112 |
113 | )
114 | }
115 | ```
116 |
117 | Or if you have [remark-directive](https://github.com/remarkjs/remark-directive), you can use special directive for this, for example:
118 |
119 | ```md
120 | ::map[38.7223° N, 9.1393° W]
121 | ```
122 |
123 | ## The devil is in the details
124 |
125 | While all of this sounds nice, it may be not very clear how to design API.
126 |
127 | ### At which level shall it operate - `mdast` or `hast`?
128 |
129 | Let's imagine ideal interface for `Code` Astro component:
130 |
131 | ```astro
132 | ---
133 | type Props = {
134 | content: string;
135 | inline: boolean;
136 | lang?: string;
137 | metastring?: string;
138 | };
139 | ---
140 | ```
141 |
142 | Which in case of given example:
143 |
144 | ````md
145 | ```ts {1}
146 | const x = 1;
147 | ```
148 | ````
149 |
150 | would be passed as:
151 |
152 | ```json
153 | {
154 | "content": "const x = 1;",
155 | "inline": false,
156 | "lang": "ts",
157 | "metastring": "{1}"
158 | }
159 | ```
160 |
161 | This is pretty easy at `mdast` level. But at `hast` level we don't have concept of `code-fence`, instead there is `
const x = 1;
`.
162 |
163 | Which means that in order for Astro to support "ideal" interface it would either need to:
164 |
165 | - use `mdast`
166 | - **problem:** some plugins work at `hast` level, for example `rehype-slug`, which adds ids to headings
167 | - or write custom handlers for each case for `hast`
168 |
169 | ### Problem with ``
170 |
171 | Let's take the same `Code` component example from above.
172 |
173 | More traditional way to get content would be: `const content = await Astro.slots.render("default");`. Instead of passing it as prop.
174 |
175 | But the problem here is that content gets processed (html entities escaped, markdown gets processed). So for:
176 |
177 | ````md
178 | ```html
179 |
hi
180 | ```
181 | ````
182 |
183 | `await Astro.slots.render("default");` would produce `<h1>hi</h1>`.
184 |
185 | ### Problem with nested markdown
186 |
187 | Let's say we want to override `Blockquote` in order to implement callouts similar to Github:
188 |
189 | ```md
190 | > [!NOTE]
191 | > something
192 | ```
193 |
194 | And Blockquote component is something like this:
195 |
196 | ```astro
197 | ---
198 | import Aside from "./Aside.astro";
199 |
200 | type Props = {
201 | content: string;
202 | };
203 |
204 | let content = Astro.props.content;
205 | const matches = content.trim().match(/^[!([^\]]*)\]/);
206 | const aside = Boolean(matches);
207 | let type = "note" as any;
208 | if (matches) {
209 | type = matches[1].toLowerCase();
210 | content = content.replace(matches[0], "");
211 | }
212 | ---
213 |
214 | {
215 | aside ? (
216 |
219 | ) : (
220 |
221 |
222 |
223 | )
224 | }
225 | ```
226 |
227 | If we pass "raw" string to content we would also need some kind of `` component. There is [deprecated (AFAIK) componet for this](https://www.npmjs.com/package/@astrojs/markdown-component).
228 |
229 | ### What is allowed in Astro components?
230 |
231 | Astro component in the context of this proposal can imply different expectations (requirements).
232 |
233 | Simplest case: it renders self-contained HTML string. For example, SVG (from Mermaid), syntax-highlighting (like current Shiki plugin), etc.
234 |
235 | Harder case: it also has JavaScript and Style blocks. In this case they need to be deduplicated - e.g. even if component rendered multiple times JavaScript and Style should be outputed only ones.
236 |
237 | Simplest case probably can be implemented in the "user-space" with something, like [`await astro.renderToString(Component, { props, slots, request, params })`](https://github.com/withastro/roadmap/issues/533).
238 |
239 | ### MDX compatibility
240 |
241 | Is it expected that overrides for Markdown would also work for MDX? Is MDX overrides would stay the same or would they change?
242 |
243 | On one side, if we can mix markdown and MDX in [Content Collections](https://docs.astro.build/en/guides/content-collections/) - it is expected that they would work the same.
244 |
245 | On the other side if one already uses MDX they can use it for all files. And treatment of MD and MDX files can be different. For example:
246 |
247 | ```astro
248 |
252 | ```
253 |
254 | If components for MD would work the same way as MDX, they would inherit the same problems as described in [astro-customize-markdown](https://github.com/stereobooster/astro-customize-markdown).
255 |
256 | If current MDX overrides are good enough for your use case, you can rename all files from `.md` to `.mdx`.
257 |
258 | ## PS
259 |
260 | The main questions are:
261 |
262 | - If current overrides for MDX are good enough for your use cases, what prevents you to rename all files from `.md` to `.mdx`?
263 | - If there is an intention to implement different API how would it look like? Will new API be compatible with MDX?
264 |
--------------------------------------------------------------------------------
/src/content/examples/Ghostscript_Tiger.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------