Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: aside directive local icons
  • Loading branch information
HiDeoo committed Apr 4, 2025
commit 4f8808d59fe244aa2050ae07299f4bf70308ba1c
20 changes: 20 additions & 0 deletions docs/src/content/docs/test.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,23 @@ Aside with an icon from the user-installed Iconify `mdi` set.
</Fragment>

</Preview>

Aside directive with icon from the user `src/icons/` directory:

<Preview>

```mdx
:::danger[Highly experimental]{icon="test"}
Aside with an icon from the user `src/icons/` directory.
:::
```

<Fragment slot="preview">

:::danger[Highly experimental]{icon="test"}
Aside with an icon from the user `src/icons/` directory.
:::

</Fragment>

</Preview>
38 changes: 32 additions & 6 deletions packages/starlight/__e2e__/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,33 +342,59 @@ test.describe('tabs', () => {
});
});

test.describe('Iconify icons', () => {
test.describe('custom icons', () => {
test('renders Iconify icons', async ({ page, getProdServer }) => {
const starlight = await getProdServer();
await starlight.goto('/iconify');
await starlight.goto('/icons');

const expectedPath =
'<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"></path>';
const expectedSymbol = `<symbol id="ai:mdi:checkbox-blank-circle" viewBox="0 0 24 24">${expectedPath}</symbol>`;
const expectedUse = '<use href="#ai:mdi:checkbox-blank-circle"></use>';

// First Iconify icon: symbol with path + use
const iconComponentIcon = await getSvgByTestid(page, 'icon-component');
const iconComponentIcon = await getSvgByTestid(page, 'iconify-icon-component');
expect(iconComponentIcon).toBe(expectedSymbol + expectedUse);

// Second Iconify icon: use only
const cardComponentIcon = await getSvgByTestid(page, 'card-component');
const cardComponentIcon = await getSvgByTestid(page, 'iconify-card-component');
expect(cardComponentIcon).toBe(expectedUse);

// Third Iconify icon: use only
const asideComponentIcon = await getSvgByTestid(page, 'aside-component');
const asideComponentIcon = await getSvgByTestid(page, 'iconify-aside-component');
expect(asideComponentIcon).toBe(expectedUse);

// Fourth Iconify icon from a directive: path only
const asideDirectiveIcon = await getSvgByTestid(page, 'aside-directive');
const asideDirectiveIcon = await getSvgByTestid(page, 'iconify-aside-directive');
expect(asideDirectiveIcon).toBe(expectedPath);
});

test('renders local icons', async ({ page, getProdServer }) => {
const starlight = await getProdServer();
await starlight.goto('/icons');

const expectedPaths =
'<g fill="currentColor"><path d="M11.84 22.52a7.35 7.35 0 0 1-3.2-14 .75.75 0 0 1 .65 1.35 5.83 5.83 0 1 0 4.85-.13.75.75 0 1 1 .59-1.37 7.35 7.35 0 0 1-2.89 14.11z"></path><path d="M9 10a.74.74 0 0 1-.75-.75V5.38A.75.75 0 0 1 9 4.63h5.46a.75.75 0 0 1 .75.75V9.1a.75.75 0 1 1-1.5 0v-3h-4v3.1A.75.75 0 0 1 9 10m1.78 7.76a29 29 0 0 1-5.55-.56.75.75 0 0 1-.62-.73.76.76 0 0 1 .91-.74c.37.08 9.06 1.85 12.36-1.56a.76.76 0 0 1 1.06 0 .73.73 0 0 1 0 1v.05c-1.94 2.02-5.27 2.54-8.16 2.54"></path><path d="M13.58 6.13H10a.74.74 0 0 1-.75-.75V2.53a.75.75 0 0 1 .75-.75h3.63a.75.75 0 0 1 .75.75v2.85a.74.74 0 0 1-.8.75m-2.88-1.5h2.13V3.28H10.7z"></path></g>';
const expectedSymbol = `<symbol id="ai:local:test" viewBox="0 0 24 24">${expectedPaths}</symbol>`;
const expectedUse = '<use href="#ai:local:test"></use>';

// First local icon: symbol with path + use
const iconComponentIcon = await getSvgByTestid(page, 'local-icon-component');
expect(iconComponentIcon).toBe(expectedSymbol + expectedUse);

// Second local icon: use only
const cardComponentIcon = await getSvgByTestid(page, 'local-card-component');
expect(cardComponentIcon).toBe(expectedUse);

// Third local icon: use only
const asideComponentIcon = await getSvgByTestid(page, 'local-aside-component');
expect(asideComponentIcon).toBe(expectedUse);

// Fourth local icon from a directive: path only
const asideDirectiveIcon = await getSvgByTestid(page, 'local-aside-directive');
expect(asideDirectiveIcon).toBe(expectedPaths);
});

async function getSvgByTestid(page: Page, testId: string) {
const html = await page.getByTestId(testId).locator('svg').innerHTML();
return html.trim();
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
title: Iconify
---

import TestId from '../../components/TestId.astro';
import { Aside, Card, Icon } from '@astrojs/starlight/components';

## Iconify icons

<TestId id="iconify-icon-component">
<Icon name="mdi:checkbox-blank-circle" />
</TestId>

<TestId id="iconify-card-component">
<Card icon="mdi:checkbox-blank-circle" title="Example">
A card with an icon from the user-installed Iconify `mdi` set.
</Card>
</TestId>

<TestId id="iconify-aside-component">
<Aside icon="mdi:checkbox-blank-circle">
Aside with an icon from the user-installed Iconify `mdi` set.
</Aside>
</TestId>

<TestId id="iconify-aside-directive">

:::note{icon="mdi:checkbox-blank-circle"}
Aside with an icon from the user-installed Iconify `mdi` set.
:::

</TestId>

## Local icons

<TestId id="local-icon-component">
<Icon name="test" />
</TestId>

<TestId id="local-card-component">
<Card icon="test" title="Example">
A card with an icon from the user `src/icons/` directory.
</Card>
</TestId>

<TestId id="local-aside-component">
<Aside icon="test">
Aside with an icon from the user `src/icons/` directory.
</Aside>
</TestId>

<TestId id="local-aside-directive">

:::note{icon="test"}
Aside with an icon from the user `src/icons/` directory.
:::

</TestId>
7 changes: 7 additions & 0 deletions packages/starlight/__e2e__/fixtures/basics/src/icons/test.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions packages/starlight/__tests__/basics/config-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ test('parses valid config successfully', () => {
"type": "image/svg+xml",
},
"head": [],
"icons": {
"iconDir": "src/icons",
"svgoOptions": {
"plugins": [
"preset-default",
],
},
},
"isMultilingual": false,
"isUsingBuiltInDefaultLocale": true,
"lastUpdated": false,
Expand Down
6 changes: 5 additions & 1 deletion packages/starlight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './utils/plugins';
import { processI18nConfig } from './utils/i18n';
import type { StarlightConfig } from './types';
import { loadIcons } from './utils/icons';

export default function StarlightIntegration(
userOpts: StarlightUserConfigWithPlugins
Expand Down Expand Up @@ -88,6 +89,9 @@ export default function StarlightIntegration(
prerender: starlightConfig.prerender,
});

// Load local and Iconify icons that should be available in remark and rehype plugins.
await loadIcons(config.root, userConfig.icons);

// Add built-in integrations only if they are not already added by the user through the
// config or by a plugin.
const allIntegrations = [...config.integrations, ...integrations];
Expand All @@ -101,7 +105,7 @@ export default function StarlightIntegration(
integrations.push(mdx({ optimize: true }));
}
if (!allIntegrations.find(({ name }) => name === 'astro-icon')) {
integrations.push(astroIcon());
integrations.push(astroIcon(userConfig.icons));
}

// Add Starlight directives restoration integration at the end of the list so that remark
Expand Down
13 changes: 5 additions & 8 deletions packages/starlight/integrations/asides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import type { HookParameters, StarlightConfig, StarlightIcon } from '../types';
import {
AsideDefaultIcons,
getBuiltInIconHastTree,
getIconifyIconHastTree,
getCollectionIconHastTree,
isBuiltInIcon,
loadIconifyCollections,
} from '../utils/icons';

export const AsideVariants = ['note', 'tip', 'caution', 'danger'] as const;
Expand Down Expand Up @@ -58,16 +57,15 @@ function s(el: string, attrs: Properties = {}, children: any[] = []): P {
}

/** Hacky function that generates the children of an mdast SVG tree. */
function makeSvgChildNodes(children: Result['children']): P[] {
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: [],
children: makeSvgChildNodes(child.children),
});
nodes.push(...makeSvgChildNodes(child.children));
}
return nodes;
}
Expand Down Expand Up @@ -120,7 +118,7 @@ function transformUnhandledDirective(
function makeSVGIcon(icon: StarlightIcon) {
const iconHastTree = isBuiltInIcon(icon)
? getBuiltInIconHastTree(icon)
: getIconifyIconHastTree(icon);
: getCollectionIconHastTree(icon);

return s(
'svg',
Expand Down Expand Up @@ -160,8 +158,7 @@ function makeSVGIcon(icon: StarlightIcon) {
* ```
*/
function remarkAsides(options: AsidesOptions): Plugin<[], Root> {
const transformer: Transformer<Root> = async (tree, file) => {
await loadIconifyCollections(options.astroConfig.root);
const transformer: Transformer<Root> = (tree, file) => {
const lang = options.absolutePathToLang(file.path);
const t = options.useTranslations(lang);
visit(tree, (node, index, parent) => {
Expand Down
1 change: 1 addition & 0 deletions packages/starlight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@
"dependencies": {
"@astrojs/mdx": "^4.0.5",
"@astrojs/sitemap": "^3.2.1",
"@iconify/tools": "^4.1.2",
"@iconify/utils": "^2.3.0",
"@pagefind/default-ui": "^1.3.0",
"@types/hast": "^3.0.4",
Expand Down
35 changes: 35 additions & 0 deletions packages/starlight/schemas/astroIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { z } from 'astro/zod';
import astroIcon from 'astro-icon';
import type { runSVGO } from '@iconify/tools/lib/index.js';

/**
* Default Astro Icon local icon directory.
* @see https://github.com/natemoo-re/astro-icon/blob/85cfae2426b8a94ca7f25429dba307558f232345/packages/core/src/vite-plugin-astro-icon.ts#L19
*/
const defaultIconDir = 'src/icons';

/**
* Default Astro Icon SVGO options.
* @see https://github.com/natemoo-re/astro-icon/blob/85cfae2426b8a94ca7f25429dba307558f232345/packages/core/src/loaders/loadLocalCollection.ts#L13
*/
const defaultSVGOOptions: SVGOOptions = { plugins: ['preset-default'] };

export const AstroIconSchema = () =>
z
.custom<AstroIconOptions>((value) => typeof value === 'object' && value)
.describe(
'Define Astro Icon options used to load and render local and Iconify icons. See https://www.astroicon.dev/reference/configuration/ for more details.'
)
.optional()
.transform((options) => {
const astroIconOptions = options ?? {};
astroIconOptions.iconDir ??= defaultIconDir;
astroIconOptions.svgoOptions ??= defaultSVGOOptions;
return astroIconOptions as StarlightAstroIconOptions;
});

type AstroIconOptions = NonNullable<Parameters<typeof astroIcon>[0]>;
type StarlightAstroIconOptions = AstroIconOptions &
Required<Pick<AstroIconOptions, 'iconDir' | 'svgoOptions'>>;

type SVGOOptions = Omit<Parameters<typeof runSVGO>[1], 'keepShapes'>;
2 changes: 1 addition & 1 deletion packages/starlight/user-components/rehype-file-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ function makeSVGIcon(icon: StarlightBuiltInIcon) {
'aria-hidden': 'true',
viewBox: '0 0 24 24',
},
getBuiltInIconHastTree(icon)
getBuiltInIconHastTree(icon).children
);
}

Expand Down
Loading