Skip to content

Commit 79cdf2f

Browse files
committed
Switch from remark-directive to remark-mdc, add support for ::tabs / :::tab and :icon
1 parent 068bf4e commit 79cdf2f

8 files changed

Lines changed: 440 additions & 174 deletions

File tree

docs/mdsx.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineConfig } from 'mdsx';
22

33
import rehypeSlug from 'rehype-slug';
44
import remarkGfm from 'remark-gfm';
5-
import remarkDirective from 'remark-directive';
5+
import remarkMDC from 'remark-mdc';
66
import rehypePrettyCode from 'rehype-pretty-code';
77

88
import {
@@ -17,8 +17,8 @@ export const mdsxConfig = defineConfig({
1717
extensions: ['.md'],
1818
remarkPlugins: [
1919
remarkGfm,
20-
remarkDirective, // Parse directive syntax (:::directive)
21-
remarkDirectives, // Transform directives to components
20+
remarkMDC, // Parse MDC syntax (::component, :::component)
21+
remarkDirectives, // Transform MDC components to Svelte components
2222
remarkLiveCode
2323
],
2424
rehypePlugins: [

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@
9393
"prettier-plugin-svelte": "^3.4.0",
9494
"rehype-pretty-code": "^0.14.1",
9595
"rehype-slug": "^6.0.0",
96-
"remark-directive": "^3.0.0",
9796
"remark-gfm": "^4.0.1",
97+
"remark-mdc": "^3.10.0",
9898
"runed": "^0.37.0",
9999
"shiki": "^3.20.0",
100100
"solar-calculator": "^0.3.0",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script lang="ts">
2+
import type { Component } from 'svelte';
3+
import type { HTMLAttributes } from 'svelte/elements';
4+
import { cls } from '@layerstack/tailwind';
5+
6+
interface Props extends HTMLAttributes<HTMLSpanElement> {
7+
name?: string; // Iconify icon name (e.g., "lucide:code" or "i-lucide-code")
8+
component?: Component; // Component import
9+
}
10+
11+
const { name, component, class: className, ...restProps }: Props = $props();
12+
13+
// Convert i-collection-name format to collection:name format for Iconify
14+
const iconifyName = $derived.by(() => {
15+
if (!name) return undefined;
16+
// If already in collection:name format, use as-is
17+
if (name.includes(':')) return name;
18+
// Convert i-collection-name to collection:name
19+
const match = name.match(/^i-([^-]+)-(.+)$/);
20+
if (match) {
21+
const [, collection, iconName] = match;
22+
return `${collection}:${iconName.replace(/-/g, '-')}`;
23+
}
24+
return name;
25+
});
26+
</script>
27+
28+
<svelte:head>
29+
<!-- Load Iconify web component/script - load unconditionally if Icon component is used -->
30+
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js">
31+
</script>
32+
</svelte:head>
33+
34+
{#if component}
35+
<!-- Component import (dynamic by default in runes mode) -->
36+
{@const IconComponent = component}
37+
<IconComponent class={cls('inline-block', className)} {...restProps} />
38+
{:else if iconifyName}
39+
<!-- Iconify web component -->
40+
<iconify-icon icon={iconifyName} class={cls('inline-block', className)} {...restProps}
41+
></iconify-icon>
42+
{/if}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script lang="ts">
2+
import type { Snippet, Component } from 'svelte';
3+
import type { HTMLAttributes } from 'svelte/elements';
4+
import { cls } from '@layerstack/tailwind';
5+
import { getContext, untrack } from 'svelte';
6+
7+
interface Props extends HTMLAttributes<HTMLDivElement> {
8+
children: Snippet;
9+
label?: string;
10+
icon?: string | Component;
11+
}
12+
13+
const { children, label, icon, class: className, ...restProps }: Props = $props();
14+
15+
const tabsContext = getContext<{
16+
activeTab: number;
17+
setActiveTab: (index: number) => void;
18+
registerTab: (label: string | undefined, icon: string | Component | undefined) => number;
19+
}>('tabs');
20+
21+
// Register this tab and get its index
22+
// Use untrack to capture the initial values without creating a reactive dependency
23+
const tabIndex = tabsContext?.registerTab(untrack(() => label), untrack(() => icon)) ?? 0;
24+
25+
const isActive = $derived(tabsContext?.activeTab === tabIndex);
26+
</script>
27+
28+
<div class={cls('tab-content', !isActive && 'hidden', className)} {...restProps}>
29+
{@render children?.()}
30+
</div>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script lang="ts">
2+
import type { Snippet, Component } from 'svelte';
3+
import type { HTMLAttributes } from 'svelte/elements';
4+
import { cls } from '@layerstack/tailwind';
5+
import { setContext } from 'svelte';
6+
7+
interface Props extends HTMLAttributes<HTMLDivElement> {
8+
children: Snippet;
9+
}
10+
11+
const { children, class: className, ...restProps }: Props = $props();
12+
13+
let activeTab = $state(0);
14+
let tabs = $state<Array<{ label?: string; icon?: string | Component }>>([]);
15+
let tabCounter = 0;
16+
17+
// Provide context for Tab components to register themselves
18+
setContext('tabs', {
19+
get activeTab() {
20+
return activeTab;
21+
},
22+
setActiveTab: (index: number) => {
23+
activeTab = index;
24+
},
25+
registerTab: (label: string | undefined, icon: string | Component | undefined) => {
26+
const index = tabCounter++;
27+
tabs = [...tabs, { label, icon }];
28+
return index;
29+
}
30+
});
31+
32+
// Convert i-collection-name format to collection:name format for Iconify
33+
function getIconifyName(name: string): string {
34+
// If already in collection:name format, use as-is
35+
if (name.includes(':')) return name;
36+
// Convert i-collection-name to collection:name
37+
const match = name.match(/^i-([^-]+)-(.+)$/);
38+
if (match) {
39+
const [, collection, iconName] = match;
40+
return `${collection}:${iconName.replace(/-/g, '-')}`;
41+
}
42+
return name;
43+
}
44+
45+
// Check if any tabs have string icons (need Iconify)
46+
const hasIconifyIcons = $derived(tabs.some((tab) => typeof tab.icon === 'string'));
47+
</script>
48+
49+
<svelte:head>
50+
<!-- Load Iconify web component if needed -->
51+
{#if hasIconifyIcons}
52+
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js">
53+
</script>
54+
{/if}
55+
</svelte:head>
56+
57+
<div class={cls('tabs mt-4 flex flex-col', className)} {...restProps}>
58+
<!-- Tabs -->
59+
<div class="flex gap-1 overflow-auto z-1 -mb-px">
60+
{#each tabs as tab, index}
61+
{@const isActive = activeTab === index}
62+
<button
63+
type="button"
64+
class={cls(
65+
'inline-flex items-center gap-1 whitespace-nowrap border px-3 py-2 text-xs transition-colors rounded-t',
66+
isActive
67+
? 'bg-surface-100 text-surface-content border-b-surface-100'
68+
: 'bg-surface-200 text-surface-content/50 hover:text-surface-content'
69+
)}
70+
onclick={() => (activeTab = index)}
71+
>
72+
{#if tab.icon}
73+
{#if typeof tab.icon === 'string'}
74+
<!-- Iconify web component -->
75+
<iconify-icon icon={getIconifyName(tab.icon)} class="size-4"></iconify-icon>
76+
{:else}
77+
<!-- Component import (dynamic by default in runes mode) -->
78+
{@const IconComponent = tab.icon}
79+
<IconComponent class="size-4" />
80+
{/if}
81+
{/if}
82+
{tab.label || `Tab ${index + 1}`}
83+
</button>
84+
{/each}
85+
</div>
86+
87+
<!-- Tab content -->
88+
<div class="border rounded-lg rounded-tl-none p-3 bg-surface-100">
89+
{@render children?.()}
90+
</div>
91+
</div>

docs/src/lib/markdown/remark/directives.js

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,83 @@
1-
import { visit } from 'unist-util-visit';
1+
import { visit, EXIT } from 'unist-util-visit';
22

33
/**
4-
* Remark plugin to transform directives (:::tip, :::note, :::steps, etc.) into custom components
5-
* Works with remark-directive to convert container directives into Svelte components
4+
* Remark plugin to transform MDC components (::tip, ::note, ::steps, etc.) into custom Svelte components
5+
* Works with remark-mdc to convert MDC components into Svelte components
66
*
7-
* Supported directives:
8-
* - :::tip - renders as Note component with variant="tip"
9-
* - :::note - renders as Note component with variant="note"
10-
* - :::warning - renders as Note component with variant="warning"
11-
* - :::caution - renders as Note component with variant="caution"
12-
* - :::steps - renders as Steps component
7+
* Supported components:
8+
* - ::tip / :::tip - renders as Note component with variant="tip"
9+
* - ::note / :::note - renders as Note component with variant="note"
10+
* - ::warning / :::warning - renders as Note component with variant="warning"
11+
* - ::caution / :::caution - renders as Note component with variant="caution"
12+
* - ::steps / :::steps - renders as Steps component
13+
* - ::tabs / :::tabs - renders as Tabs component (supports nested ::tab)
14+
* - ::tab / :::tab - renders as Tab component (used inside tabs, supports icon attribute)
15+
* - :icon - renders as Icon component (inline icon with name attribute)
1316
*
1417
* @returns {(tree: any) => void} A remark transformer function
1518
*/
1619
export function remarkDirectives() {
1720
return (tree) => {
1821
const componentsToImport = new Set();
1922

23+
// Process MDC components (remark-mdc creates leafComponent and containerComponent nodes)
2024
visit(tree, (node) => {
21-
// Handle container directives (:::directive)
22-
if (node.type === 'containerDirective') {
23-
const directiveName = node.name;
25+
// Handle both leafComponent (::component) and containerComponent (::component...::)
26+
if (node.type === 'leafComponent' || node.type === 'containerComponent') {
27+
const componentName = node.name;
2428

2529
// Alert variants all use the Note component
2630
const alertVariants = ['tip', 'note', 'warning', 'caution'];
2731

28-
// Map directive names to component names and variants
29-
let componentName;
32+
// Map component names to Svelte component names and variants
33+
let svelteComponent;
3034
let variant;
3135

32-
if (alertVariants.includes(directiveName)) {
33-
componentName = 'Note';
34-
variant = directiveName;
35-
} else if (directiveName === 'steps') {
36-
componentName = 'Steps';
36+
if (alertVariants.includes(componentName)) {
37+
svelteComponent = 'Note';
38+
variant = componentName;
39+
} else if (componentName === 'steps') {
40+
svelteComponent = 'Steps';
41+
} else if (componentName === 'tabs') {
42+
svelteComponent = 'Tabs';
43+
} else if (componentName === 'tab') {
44+
svelteComponent = 'Tab';
3745
} else {
38-
// Unknown directive, skip
46+
// Unknown component, skip transformation
3947
return;
4048
}
4149

4250
// Track which components we need to import
43-
componentsToImport.add(componentName);
51+
componentsToImport.add(svelteComponent);
4452

45-
// Get directive attributes
53+
// Get component attributes from MDC
4654
const attributes = node.attributes || {};
4755

48-
// Convert the directive into a custom component in the HTML tree
56+
// Convert the MDC component into a component that rehype can handle
4957
// We set data.hName to tell rehype to convert this to the component
5058
const data = node.data || (node.data = {});
51-
data.hName = componentName;
59+
data.hName = svelteComponent;
5260
data.hProperties = {
5361
...attributes,
5462
// Pass variant for alert components
55-
...(variant && { variant }),
56-
// Pass any directive label as a prop
57-
...(node.attributes?.label && { label: node.attributes.label })
63+
...(variant && { variant })
5864
};
5965
}
6066

61-
// Handle text directives (:directive[text]) if needed
62-
if (node.type === 'textDirective') {
63-
// Can be used for inline directives if needed in the future
64-
}
67+
// Handle inline text components (:component)
68+
if (node.type === 'textComponent') {
69+
const componentName = node.name;
70+
71+
// Support :icon{name="i-lucide-code"} syntax
72+
if (componentName === 'icon') {
73+
componentsToImport.add('Icon');
6574

66-
// Handle leaf directives (::directive) if needed
67-
if (node.type === 'leafDirective') {
68-
// Can be used for self-closing directives if needed in the future
75+
const data = node.data || (node.data = {});
76+
data.hName = 'Icon';
77+
data.hProperties = {
78+
...(node.attributes || {})
79+
};
80+
}
6981
}
7082
});
7183

@@ -83,9 +95,10 @@ export function remarkDirectives() {
8395
hasScript = true;
8496
node.value = node.value.replace(
8597
/<script[^>]*>/,
98+
/** @param {string} match */
8699
(match) => `${match}\n${importStatements}`
87100
);
88-
return visit.EXIT;
101+
return EXIT;
89102
}
90103
});
91104

docs/src/routes/docs/markdown/+page.md

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,53 @@ This action cannot be undone. This uses `:::caution`.
163163
First, install the required packages:
164164

165165
```bash
166-
npm install remark-directive
166+
npm install remark-mdc
167167
```
168168

169169
## Step 2: Configure mdsx
170170

171-
Add the directive plugins to your mdsx configuration.
171+
Add the remark-mdc plugin to your mdsx configuration.
172172

173-
## Step 3: Use directives
173+
## Step 3: Use MDC components
174174

175-
Start using `:::directive` syntax in your markdown files!
175+
Start using `::component` and `:::component` syntax in your markdown files!
176176
:::
177+
178+
### Tabs (Nested Components)
179+
180+
:::tabs
181+
182+
::tab{label="JavaScript" icon="vscode-icons:file-type-js-official"}
183+
184+
```js
185+
console.log('Hello from JavaScript');
186+
```
187+
188+
::
189+
190+
::tab{label="TypeScript" icon="vscode-icons:file-type-typescript-official"}
191+
192+
```ts
193+
const message: string = 'Hello from TypeScript';
194+
console.log(message);
195+
```
196+
197+
::
198+
199+
::tab{label="Python" icon="vscode-icons:file-type-python"}
200+
201+
```python
202+
print('Hello from Python')
203+
```
204+
205+
::
206+
207+
:::
208+
209+
### Icons (Inline)
210+
211+
You can also use icons inline with the `:icon` directive:
212+
213+
Here's a :icon{name="lucide:code" class="text-primary"} code icon, a :icon{name="lucide:rocket" class="text-green-500"} rocket, and a :icon{name="simple-icons:github"} GitHub logo.
214+
215+
Icons support both formats: `collection:name` (like `lucide:code`) or `i-collection-name` (like `i-lucide-code`).

0 commit comments

Comments
 (0)