Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-apples-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight-markdoc': minor
---

Adds support for the `icon` attribute in the `aside` tag, allowing the use of any of Starlight’s built-in icons.
5 changes: 5 additions & 0 deletions .changeset/five-flowers-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': minor
---

Adds support for using any of Starlight’s built-in icons in asides.
36 changes: 36 additions & 0 deletions docs/src/content/docs/components/asides.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ A warning aside *with* a custom title.

</Preview>

### Use custom icons

Override the default aside icons by using the [`icon`](#icon) attribute set to the name of [one of Starlight’s built-in icons](/reference/icons/#all-icons).

<Preview>

```mdx 'icon="starlight"'
import { Aside } from '@astrojs/starlight/components';

<Aside type="tip" icon="starlight">
A warning aside *with* a custom icon.
</Aside>
```

<Fragment slot="markdoc">

```markdoc 'icon="starlight"'
{% aside type="tip" icon="starlight" %}
A warning aside *with* a custom icon.
{% /aside %}
```

</Fragment>

<Aside slot="preview" type="tip" icon="starlight">
A warning aside *with* a custom icon.
</Aside>

</Preview>

## `<Aside>` Props

**Implementation:** [`Aside.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/user-components/Aside.astro)
Expand All @@ -156,3 +186,9 @@ The type of aside to display:

The title of the aside to display.
If `title` is not set, the default title for the current aside `type` will be used.

### `icon`

**type:** [`StarlightIcon`](/reference/icons/#starlighticon-type)

An aside can include an `icon` attribute set to the name of [one of Starlight’s built-in icons](/reference/icons/#all-icons).
15 changes: 15 additions & 0 deletions docs/src/content/docs/guides/authoring-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ Astro helps you build faster websites with [“Islands Architecture”](https://
:::
```

### Custom aside icons

You can specify a custom icon for the aside in curly brackets following the aside type or [custom title](#custom-aside-titles), e.g. `:::tip{icon="heart"}` or `:::tip[Did you know?]{icon="heart"}` respectively.
The icon name must be set to the name of [one of Starlight’s built-in icons](/reference/icons/#all-icons).

:::tip{icon="heart"}
Astro helps you build faster websites with [“Islands Architecture”](https://docs.astro.build/en/concepts/islands/).
:::

```md
:::tip{icon="heart"}
Astro helps you build faster websites with [“Islands Architecture”](https://docs.astro.build/en/concepts/islands/).
:::
```

### More aside types

Caution and danger asides are helpful for drawing a user’s attention to details that may trip them up.
Expand Down
4 changes: 4 additions & 0 deletions packages/markdoc/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export const StarlightMarkdocPreset = {
aside: {
render: component('@astrojs/starlight/components', 'Aside'),
attributes: {
icon: {
type: String,
required: false,
},
title: {
type: String,
required: false,
Expand Down
2 changes: 1 addition & 1 deletion packages/markdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"peerDependencies": {
"@astrojs/markdoc": ">=0.12.1",
"@astrojs/starlight": ">=0.34.0"
"@astrojs/starlight": ">=0.35.0"
},
"publishConfig": {
"provenance": true
Expand Down
76 changes: 75 additions & 1 deletion packages/starlight/__tests__/remark-rehype/asides.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createMarkdownProcessor, type MarkdownProcessor } from '@astrojs/markdown-remark';
import type { Root } from 'mdast';
import { visit } from 'unist-util-visit';
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import { starlightAsides, remarkDirectivesRestoration } from '../../integrations/asides';
import { createTranslationSystemFromFs } from '../../utils/translations-fs';
import { StarlightConfigSchema, type StarlightUserConfig } from '../../utils/user-config';
Expand Down Expand Up @@ -126,6 +126,80 @@ Some text
);
});

describe('custom icons', () => {
test.each(['note', 'tip', 'caution', 'danger'])('%s with custom icon', async (type) => {
const res = await renderMarkdown(`
:::${type}{icon="heart"}
Some text
:::
`);
await expect(res.code).toMatchFileSnapshot(
`./snapshots/generates-aside-${type}-custom-icon.html`
);
});

test.each(['note', 'tip', 'caution', 'danger'])('%s with invalid custom icon', async (type) => {
// Temporarily mock console.error to avoid cluttering test output when the Astro Markdown
// processor logs an error before rethrowing it.
// https://github.com/withastro/astro/blob/98853ce7e31a8002fd7be83d7932a53cfec84d27/packages/markdown/remark/src/index.ts#L161
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(async () =>
renderMarkdown(
`
:::${type}{icon="invalid-icon-name"}
Some text
:::
`
)
).rejects.toThrowError(
// We are not relying on `toThrowErrorMatchingInlineSnapshot()` and our custom snapshot
// serializer in this specific test as error thrown in a remark plugin includes a dynamic file
// path.
expect.objectContaining({
type: 'AstroUserError',
hint: expect.stringMatching(
/An aside custom icon must be set to the name of one of Starlight’s built-in icons, but received `invalid-icon-name`/
),
})
);

// Restore the original console.error implementation.
consoleError.mockRestore();
});

test('test custom icon with multiple paths inside the svg', async () => {
const res = await renderMarkdown(`
:::note{icon="external"}
Some text
:::
`);
await expect(res.code).toMatchFileSnapshot(
`./snapshots/generates-aside-note-multiple-path-custom-icon.html`
);
const pathCount = (res.code.match(/path/g) || []).length;
// If we have two pairs of opening and closing tags of path,
// we will have 4 occurences of that word.
expect(pathCount).eq(4);
});
});

describe('custom labels with custom icons', () => {
test.each(['note', 'tip', 'caution', 'danger'])('%s with custom label', async (type) => {
const label = 'Custom Label';
const res = await renderMarkdown(`
:::${type}[${label}]{icon="heart"}
Some text
:::
`);
expect(res.code).includes(`aria-label="${label}"`);
expect(res.code).includes(`</svg>${label}</p>`);
await expect(res.code).toMatchFileSnapshot(
`./snapshots/generates-aside-${type}-custom-label-and-icon.html`
);
});
});

test('ignores unknown directive variants', async () => {
const res = await renderMarkdown(`
:::unknown
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Caution" class="starlight-aside starlight-aside--caution"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Caution</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Custom Label" class="starlight-aside starlight-aside--caution"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Custom Label</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Danger" class="starlight-aside starlight-aside--danger"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Danger</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Custom Label" class="starlight-aside starlight-aside--danger"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Custom Label</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Note" class="starlight-aside starlight-aside--note"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Note</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Custom Label" class="starlight-aside starlight-aside--note"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Custom Label</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Note" class="starlight-aside starlight-aside--note"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M19.33 10.18a1 1 0 0 1-.77 0 1 1 0 0 1-.62-.93l.01-1.83-8.2 8.2a1 1 0 0 1-1.41-1.42l8.2-8.2H14.7a1 1 0 0 1 0-2h4.25a1 1 0 0 1 1 1v4.25a1 1 0 0 1-.62.93Z"></path><path d="M11 4a1 1 0 1 1 0 2H7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h4Z"></path></svg>Note</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Tip" class="starlight-aside starlight-aside--tip"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Tip</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<aside aria-label="Custom Label" class="starlight-aside starlight-aside--tip"><p class="starlight-aside__title" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="starlight-aside__icon"><path d="M20.16 5A6.29 6.29 0 0 0 12 4.36a6.27 6.27 0 0 0-8.16 9.48l6.21 6.22a2.78 2.78 0 0 0 3.9 0l6.21-6.22a6.27 6.27 0 0 0 0-8.84m-1.41 7.46-6.21 6.21a.76.76 0 0 1-1.08 0l-6.21-6.24a4.29 4.29 0 0 1 0-6 4.27 4.27 0 0 1 6 0 1 1 0 0 0 1.42 0 4.27 4.27 0 0 1 6 0 4.29 4.29 0 0 1 .08 6Z"></path></svg>Custom Label</p><div class="starlight-aside__content"><p>Some text</p></div></aside>
46 changes: 43 additions & 3 deletions packages/starlight/integrations/asides.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="mdast-util-directive" />

import type { AstroConfig, AstroIntegration, AstroUserConfig } from 'astro';
import { h as _h, s as _s, type Properties } from 'hastscript';
import { h as _h, s as _s, type Properties, type Result } from 'hastscript';
import type { Node, Paragraph as P, Parent, PhrasingContent, Root } from 'mdast';
import {
type Directives,
Expand All @@ -14,8 +14,12 @@ import { toString } from 'mdast-util-to-string';
import remarkDirective from 'remark-directive';
import type { Plugin, Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import type { HookParameters, StarlightConfig } from '../types';
import type { HookParameters, StarlightConfig, StarlightIcon } from '../types';
import { getRemarkRehypeDocsCollectionPath, shouldTransformFile } from './remark-rehype-utils';
import { Icons } from '../components/Icons';
import { fromHtml } from 'hast-util-from-html';
import type { Element } from 'hast';
import { AstroError } from 'astro/errors';

interface AsidesOptions {
starlightConfig: Pick<StarlightConfig, 'defaultLocale' | 'locales'>;
Expand Down Expand Up @@ -88,6 +92,20 @@ function transformUnhandledDirective(
}
}

/** Hacky function that generates the children of an mdast SVG tree. */
function makeSvgChildNodes(children: Result['children']): any[] {
const nodes: P[] = [];
for (const child of children) {
if (child.type !== 'element') continue;
nodes.push({
type: 'paragraph',
data: { hName: child.tagName, hProperties: child.properties },
children: makeSvgChildNodes(child.children),
});
}
return nodes;
}

/**
* remark plugin that converts blocks delimited with `:::` into styled
* asides (a.k.a. “callouts”, “admonitions”, etc.). Depends on the
Expand Down Expand Up @@ -164,6 +182,7 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> {
return;
}
const variant = node.name;
const attributes = node.attributes;
if (!isAsideVariant(variant)) return;

// remark-directive converts a container’s “label” to a paragraph added as the head of its
Expand All @@ -185,6 +204,19 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> {
node.children.splice(0, 1);
}

let iconPath = iconPaths[variant];

if (attributes?.['icon']) {
const iconName = attributes['icon'] as StarlightIcon;
const icon = Icons[iconName];
if (!icon) throwInvalidAsideIconError(iconName);
// Omit the root node and return only the first child which is the SVG element.
const iconHastTree = fromHtml(`<svg>${icon}</svg>`, { fragment: true, space: 'svg' })
.children[0] as Element;
// Render all SVG child nodes.
iconPath = makeSvgChildNodes(iconHastTree.children);
}

const aside = h(
'aside',
{
Expand All @@ -202,7 +234,7 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> {
fill: 'currentColor',
class: 'starlight-aside__icon',
},
iconPaths[variant]
iconPath
),
...titleNode,
]),
Expand All @@ -221,6 +253,14 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> {

type RemarkPlugins = NonNullable<NonNullable<AstroUserConfig['markdown']>['remarkPlugins']>;

export function throwInvalidAsideIconError(icon: string) {
throw new AstroError(
'Invalid aside icon',
`An aside custom icon must be set to the name of one of Starlight\’s built-in icons, but received \`${icon}\`.\n\n` +
'See https://starlight.astro.build/reference/icons/#all-icons for a list of available icons.'
);
}

export function starlightAsides(options: AsidesOptions): RemarkPlugins {
return [remarkDirective, remarkAsides(options)];
}
Expand Down
9 changes: 7 additions & 2 deletions packages/starlight/user-components/Aside.astro
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
---
import { AstroError } from 'astro/errors';
import Icon from './Icon.astro';
import { Icons, type StarlightIcon } from '../components/Icons';
import { throwInvalidAsideIconError } from '../integrations/asides';

const asideVariants = ['note', 'tip', 'caution', 'danger'] as const;
const icons = { note: 'information', tip: 'rocket', caution: 'warning', danger: 'error' } as const;

interface Props {
type?: (typeof asideVariants)[number];
title?: string;
icon?: StarlightIcon;
}

let { type = 'note', title } = Astro.props;
let { type = 'note', title, icon } = Astro.props;

if (!asideVariants.includes(type)) {
throw new AstroError(
Expand All @@ -20,14 +23,16 @@ if (!asideVariants.includes(type)) {
);
}

if (icon && !Icons[icon]) throwInvalidAsideIconError(icon);

if (!title) {
title = Astro.locals.t(`aside.${type}`);
}
---

<aside aria-label={title} class={`starlight-aside starlight-aside--${type}`}>
<p class="starlight-aside__title" aria-hidden="true">
<Icon name={icons[type]} class="starlight-aside__icon" />{title}
<Icon name={icon || icons[type]} class="starlight-aside__icon" />{title}
</p>
<div class="starlight-aside__content">
<slot />
Expand Down