diff --git a/packages/starlight/__tests__/basics/sitemap.test.ts b/packages/starlight/__tests__/basics/sitemap.test.ts
new file mode 100644
index 00000000000..d33820402be
--- /dev/null
+++ b/packages/starlight/__tests__/basics/sitemap.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'vitest';
+import { getSitemapConfig, starlightSitemap } from '../../integrations/sitemap';
+import type { StarlightConfig } from '../../types';
+import { StarlightConfigSchema, type StarlightUserConfig } from '../../utils/user-config';
+
+describe('starlightSitemap', () => {
+ test('returns @astrojs/sitemap integration', () => {
+ const integration = starlightSitemap({} as StarlightConfig);
+ expect(integration.name).toBe('@astrojs/sitemap');
+ });
+});
+
+describe('getSitemapConfig', () => {
+ test('configures i18n config', () => {
+ const config = getSitemapConfig(
+ StarlightConfigSchema.parse({
+ title: 'i18n test',
+ locales: { root: { lang: 'en', label: 'English' }, fr: { label: 'French' } },
+ } satisfies StarlightUserConfig)
+ );
+ expect(config).toMatchInlineSnapshot(`
+ {
+ "i18n": {
+ "defaultLocale": "root",
+ "locales": {
+ "fr": "fr",
+ "root": "en",
+ },
+ },
+ }
+ `);
+ });
+
+ test('no config for monolingual sites', () => {
+ const config = getSitemapConfig(
+ StarlightConfigSchema.parse({ title: 'i18n test' } satisfies StarlightUserConfig)
+ );
+ expect(config).toMatchInlineSnapshot('{}');
+ });
+});
diff --git a/packages/starlight/__tests__/remark-rehype/asides.test.ts b/packages/starlight/__tests__/remark-rehype/asides.test.ts
new file mode 100644
index 00000000000..57fe4191840
--- /dev/null
+++ b/packages/starlight/__tests__/remark-rehype/asides.test.ts
@@ -0,0 +1,87 @@
+import { createMarkdownProcessor } from '@astrojs/markdown-remark';
+import { describe, expect, test } from 'vitest';
+import { starlightAsides } from '../../integrations/asides';
+
+const processor = await createMarkdownProcessor({
+ remarkPlugins: [...starlightAsides()],
+});
+
+test('generates
`);
+ });
+});
+
+describe('custom labels', () => {
+ test.each(['note', 'tip', 'caution', 'danger'])('%s with custom label', async (type) => {
+ const label = 'Custom Label';
+ const res = await processor.render(`
+:::${type}[${label}]
+Some text
+:::
+ `);
+ expect(res.code).includes(`aria-label="${label}"`);
+ expect(res.code).includes(`${label}`);
+ });
+});
+
+test('ignores unknown directive variants', async () => {
+ const res = await processor.render(`
+:::unknown
+Some text
+:::
+`);
+ expect(res.code).toMatchInlineSnapshot('""');
+});
+
+test('handles complex children', async () => {
+ const res = await processor.render(`
+:::note
+Paragraph [link](/href/).
+
+![alt](/img.jpg)
+
+
+See more
+
+More.
+
+
+:::
+`);
+ expect(res.code).toMatchFileSnapshot('./asides/handles-complex-children.html');
+});
+
+test('nested asides', async () => {
+ const res = await processor.render(`
+::::note
+Note contents.
+
+:::tip
+Nested tip.
+:::
+
+::::
+`);
+ expect(res.code).toMatchFileSnapshot('./asides/nested-asides.html');
+});
diff --git a/packages/starlight/__tests__/remark-rehype/asides/generates-aside.html b/packages/starlight/__tests__/remark-rehype/asides/generates-aside.html
new file mode 100644
index 00000000000..fb95a152382
--- /dev/null
+++ b/packages/starlight/__tests__/remark-rehype/asides/generates-aside.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/starlight/__tests__/remark-rehype/asides/handles-complex-children.html b/packages/starlight/__tests__/remark-rehype/asides/handles-complex-children.html
new file mode 100644
index 00000000000..8cb4524903b
--- /dev/null
+++ b/packages/starlight/__tests__/remark-rehype/asides/handles-complex-children.html
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/packages/starlight/__tests__/remark-rehype/asides/nested-asides.html b/packages/starlight/__tests__/remark-rehype/asides/nested-asides.html
new file mode 100644
index 00000000000..51da5fcc4b3
--- /dev/null
+++ b/packages/starlight/__tests__/remark-rehype/asides/nested-asides.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/starlight/__tests__/remark-rehype/rehype-tabs.test.ts b/packages/starlight/__tests__/remark-rehype/rehype-tabs.test.ts
new file mode 100644
index 00000000000..f1f3b1c3155
--- /dev/null
+++ b/packages/starlight/__tests__/remark-rehype/rehype-tabs.test.ts
@@ -0,0 +1,91 @@
+import { expect, test } from 'vitest';
+import { processPanels, TabItemTagname } from '../../user-components/rehype-tabs';
+
+const TabItem = ({ label, slot }: { label: string; slot: string }) =>
+ `<${TabItemTagname} data-label="${label}">${slot}${TabItemTagname}>`;
+
+/** Get an array of HTML strings, one for each `` created by rehype-tabs for each tab item. */
+const extractSections = (html: string) =>
+ [...html.matchAll(//g)].map(([section]) => section);
+
+test('empty component returns no html or panels', () => {
+ const { panels, html } = processPanels('');
+ expect(html).toEqual('');
+ expect(panels).toEqual([]);
+});
+
+test('non-tab-item content is passed unchanged', () => {
+ const input = 'Random paragraph
';
+ const { panels, html } = processPanels(input);
+ expect(html).toEqual(input);
+ expect(panels).toEqual([]);
+});
+
+test('tab items are processed', () => {
+ const label = 'Test';
+ const slot = 'Random paragraph
';
+ const input = TabItem({ label, slot });
+ const { panels, html } = processPanels(input);
+
+ expect(html).toMatchInlineSnapshot(
+ '""'
+ );
+ expect(panels).toHaveLength(1);
+ expect(panels?.[0]?.label).toBe(label);
+ expect(panels?.[0]?.panelId).toMatchInlineSnapshot('"tab-panel-0"');
+ expect(panels?.[0]?.tabId).toMatchInlineSnapshot('"tab-0"');
+});
+
+test('only first item is not hidden', () => {
+ const labels = ['One', 'Two', 'Three'];
+ const input = labels.map((label) => TabItem({ label, slot: `${label}
` })).join('');
+ const { panels, html } = processPanels(input);
+
+ expect(panels).toHaveLength(3);
+ expect(html).toMatchInlineSnapshot(
+ '""'
+ );
+ const sections = extractSections(html);
+ expect(sections).toMatchInlineSnapshot(`
+ [
+ "",
+ "",
+ "",
+ ]
+ `);
+ expect(sections.map((section) => section.includes('hidden'))).toEqual([false, true, true]);
+});
+
+test('applies incrementing ID and aria-labelledby to each tab item', () => {
+ const labels = ['One', 'Two', 'Three'];
+ const input = labels.map((label) => TabItem({ label, slot: `${label}
` })).join('');
+ const { panels, html } = processPanels(input);
+
+ // IDs are incremented globally to ensure they are unique, so we need to extract from the panel data.
+ const firstTabIdMatches = panels?.[0]?.tabId.match(/^tab-(\d)+$/);
+ const firstTabId = parseInt(firstTabIdMatches![1]!, 10);
+
+ extractSections(html).forEach((section, index) => {
+ expect(section).includes(`id="tab-panel-${firstTabId + index}"`);
+ expect(section).includes(`aria-labelledby="tab-${firstTabId + index}"`);
+ });
+});
+
+test('applies tabindex="0" to tab items without focusable content', () => {
+ const input = [
+ TabItem({ label: 'Focusable', slot: `` }),
+ TabItem({ label: 'Not Focusable', slot: `Plain text
` }),
+ TabItem({
+ label: 'Focusable Nested',
+ slot: ``,
+ }),
+ ].join('');
+ const { html } = processPanels(input);
+ expect(html).toMatchInlineSnapshot(
+ '""'
+ );
+ const sections = extractSections(html);
+ expect(sections[0]).not.includes('tabindex="0"');
+ expect(sections[1]).includes('tabindex="0"');
+ expect(sections[2]).not.includes('tabindex="0"');
+});
diff --git a/packages/starlight/integrations/sitemap.ts b/packages/starlight/integrations/sitemap.ts
index ba18af034f1..3b63370ec23 100644
--- a/packages/starlight/integrations/sitemap.ts
+++ b/packages/starlight/integrations/sitemap.ts
@@ -1,19 +1,23 @@
import sitemap, { type SitemapOptions } from '@astrojs/sitemap';
import type { StarlightConfig } from '../types';
-/**
- * A wrapped version of the `@astrojs/sitemap` integration configured based
- * on Starlight i18n config.
- */
-export function starlightSitemap(opts: StarlightConfig) {
+export function getSitemapConfig(opts: StarlightConfig): SitemapOptions {
const sitemapConfig: SitemapOptions = {};
if (opts.isMultilingual) {
sitemapConfig.i18n = {
- defaultLocale: opts.defaultLocale.locale! || 'root',
+ defaultLocale: opts.defaultLocale.locale || 'root',
locales: Object.fromEntries(
Object.entries(opts.locales).map(([locale, config]) => [locale, config?.lang!])
),
};
}
- return sitemap(sitemapConfig);
+ return sitemapConfig;
+}
+
+/**
+ * A wrapped version of the `@astrojs/sitemap` integration configured based
+ * on Starlight i18n config.
+ */
+export function starlightSitemap(opts: StarlightConfig) {
+ return sitemap(getSitemapConfig(opts));
}
diff --git a/packages/starlight/package.json b/packages/starlight/package.json
index 946dc1eb4df..123de1f411f 100644
--- a/packages/starlight/package.json
+++ b/packages/starlight/package.json
@@ -164,6 +164,7 @@
"astro": "^3.2.0"
},
"devDependencies": {
+ "@astrojs/markdown-remark": "^3.2.1",
"@types/node": "^18.16.19",
"@vitest/coverage-v8": "^0.33.0",
"astro": "^3.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c6035741368..e3e9cf1d112 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -181,6 +181,9 @@ importers:
specifier: ^5.3.7
version: 5.3.7
devDependencies:
+ '@astrojs/markdown-remark':
+ specifier: ^3.2.1
+ version: 3.2.1(astro@3.2.3)
'@types/node':
specifier: ^18.16.19
version: 18.16.19