Commit f218cc8

HPCesia <me@hpcesia.com>
2025-02-12 04:17:50
feat: use MDX instead of directives
1 parent b5bc27a
src/components/user/TabItem.astro
@@ -0,0 +1,19 @@
+---
+interface Props {
+  label: string;
+  active?: boolean;
+}
+
+const { label, active } = Astro.props;
+---
+
+<input
+  type="radio"
+  class="tab"
+  name="{{{tabs-name}}}"
+  aria-label={label}
+  {...active ? { checked: 'checked' } : {}}
+/>
+<div class="tab-content p-4">
+  <slot />
+</div>
src/components/user/Tabs.astro
@@ -0,0 +1,62 @@
+---
+interface Props {
+  syncKey?: string;
+}
+
+const syncKey = Astro.props.syncKey;
+const tabsName = `tabs-${Math.random().toString(36).substring(2, 9)}`;
+
+const html = (await Astro.slots.render('default')).replaceAll(/{{{tabs-name}}}/g, tabsName);
+---
+
+<div
+  class="tabs tabs-box bg-base-200/15 my-4 shadow"
+  {...syncKey ? { 'data-sync-key': syncKey } : {}}
+>
+  <Fragment set:html={html} />
+</div>
+
+<script>
+  import { syncTabs } from '@scripts/stores';
+  import { listenKeys } from 'nanostores';
+
+  function init() {
+    const tabsNeedSync = document.querySelectorAll('.tabs[data-sync-key]');
+    tabsNeedSync.forEach((tab) => {
+      const syncKey = tab.getAttribute('data-sync-key')!;
+      const tabItems: NodeListOf<HTMLInputElement> = tab.querySelectorAll(
+        ':scope > input[type="radio"]'
+      );
+      if (syncKey in syncTabs.get()) {
+        const activeTabIndex = syncTabs.get()[syncKey];
+        if (activeTabIndex === -1) {
+          tabItems.forEach((tab) => tab.removeAttribute('checked'));
+        } else {
+          tabItems[activeTabIndex].setAttribute('checked', 'checked');
+        }
+      } else {
+        const activeTabIndex = Array.from(tabItems).findIndex((tab) => tab.checked);
+        console.log(syncKey, 'init', activeTabIndex);
+        syncTabs.setKey(syncKey, activeTabIndex);
+      }
+      tabItems.forEach((tab, index) => {
+        tab.addEventListener('change', () => {
+          if (tab.checked) {
+            syncTabs.setKey(syncKey, index);
+          }
+        });
+      });
+      listenKeys(syncTabs, [syncKey], (curr) => {
+        const activeTabIndex = curr[syncKey];
+        if (activeTabIndex === -1) {
+          tabItems.forEach((tab) => (tab.checked = false));
+        } else {
+          tabItems[activeTabIndex].checked = true;
+        }
+      });
+    });
+  }
+
+  document.addEventListener('astro:after-swap', init);
+  init();
+</script>
src/content/posts/markdown-components.mdx
@@ -0,0 +1,110 @@
+---
+title: Markdown Components
+slug: q1k423y0
+category: Example
+tags:
+  - markdown
+  - example
+published: 2025-02-10T21:23:23+08:00
+---
+
+import TabItem from '@components/user/TabItem.astro';
+import Tabs from '@components/user/Tabs.astro';
+
+## Tabs
+
+<Tabs>
+  <TabItem label="Component Syntax">
+    ```jsx
+    <Tabs syncKey={syncKey}>
+      <TabItem label={tabsTitle} active={isActive}>
+        {content}
+      </TabItem>
+    </Tabs>
+    ```
+  </TabItem>
+  <TabItem label="Parameter Description">
+    - `syncKey`: The key to sync tabs.
+    - `tabsTitle`: The title of the tab.
+    - `isActive`: The active tab.
+    - `content`: The content of the tab.
+  </TabItem>
+  <TabItem label="Component Examples" active={true}>
+    <Tabs>
+      <TabItem label="Foo">
+        This is the content of common Tab 1
+      </TabItem>
+      <TabItem label="Bar" active={true}>
+        This is the content of common Tab 2
+      </TabItem>
+    </Tabs>
+    <Tabs>
+      <TabItem label="Lorem">
+        This is the content of spoiler Tab 1
+      </TabItem>
+      <TabItem label="Ipsum">
+        This is the content of spoiler Tab 2
+      </TabItem>
+    </Tabs>
+    <Tabs syncKey="test-tabs-sync">
+      <TabItem label="Tab 1" active={true}>
+        This is the content of sync Tab 1, sync with next Tabs.
+      </TabItem>
+      <TabItem label="Tab 2">
+        This is the content of sync Tab 2, sync with next Tabs.
+      </TabItem>
+    </Tabs>
+    <Tabs syncKey="test-tabs-sync">
+      <TabItem label="NPM" active={true}>
+        ```bash
+        npm install foo bar
+        ```
+      </TabItem>
+      <TabItem label="PNPM">
+        ```bash
+        pnpm add foo bar
+        ```
+      </TabItem>
+    </Tabs>
+  </TabItem>
+  <TabItem label='Code of Examples'>
+    ````jsx
+    <Tabs>
+      <TabItem label="Foo">
+        This is the content of common Tab 1
+      </TabItem>
+      <TabItem label="Bar" active={true}>
+        This is the content of common Tab 2
+      </TabItem>
+    </Tabs>
+    <Tabs>
+      <TabItem label="Lorem">
+        This is the content of spoiler Tab 1
+      </TabItem>
+      <TabItem label="Ipsum">
+        This is the content of spoiler Tab 2
+      </TabItem>
+    </Tabs>
+    <Tabs syncKey="test-tabs-sync">
+      <TabItem label="Tab 1" active={true}>
+        This is the content of sync Tab 1, sync with next Tabs.
+      </TabItem>
+      <TabItem label="Tab 2">
+        This is the content of sync Tab 2, sync with next Tabs.
+      </TabItem>
+    </Tabs>
+    <Tabs syncKey="test-tabs-sync">
+      <TabItem label="NPM" active={true}>
+        ```bash
+        npm install foo bar
+        ```
+      </TabItem>
+      <TabItem label="PNPM">
+        ```bash
+        pnpm add foo bar
+        ```
+      </TabItem>
+    </Tabs>
+    ````
+  </TabItem>
+</Tabs>
src/content/posts/Markdown-Extensions.md
@@ -1,138 +0,0 @@
----
-title: Markdown Extensions
-slug: q1k423y0
-category: Example
-tags:
-  - markdown
-  - example
-published: 2025-02-10T21:23:23+08:00
----
-
-This post demonstrates the use of markdown extensions.
-
-## Directives
-
-With [remark-directive](https://github.com/remarkjs/remark-directive), you can use three types of directives instead of HTML comments in markdown files. For example, you can generate a details element with a summary element as follows:
-
-::::details
-::summary[This is a collapsible content]
-And to create this:sup[:a[1]{#fake-footnote-here data-footnote-ref href="#fake-footnote-there"}], you can use the following markdown:
-
-````markdown
-:::details
-::summary[This is a collapsible content]
-And to create this:sup[:a[1]{#fake-footnote-here data-footnote-ref href="#fake-footnote-there"}], you can use the following markdown:
-
-```markdown
-An infinite loop! So there is no need to write the markdown code again and again here.
-```
-
-:a[1: fake footnote here.]{#fake-footnote-there data-footnote-backref href="#fake-footnote-here"}
-:::
-````
-
-:a[1: fake footnote here.]{#fake-footnote-there data-footnote-backref href="#fake-footnote-here"}
-::::
-
-Instead of using HTML comments:
-
-```html
-<details>
-  <summary>This is a collapsible content</summary>
-  You can't use markdown here. To create code blocks, you need to use HTML like this:
-  <pre>
-    <code>
-      <span class="line">HTML Tags Hell!</span>
-    </code>
-  </pre>
-</details>
-```
-
-## Components
-
-Using directives, you can write HTML with markdown inside. But to generate complex components, it's a bit tedious, not elegant, and not easy to maintain. It's another hell of HTML tags[^1]. Fortunately, with [rehype-components](https://github.com/marekweb/rehype-components), you can use pre-defined components in markdown files.
-
-[^1]: If you want to know how hellish it is, check the [source code](https://github.com/HPCesia/astral-halo/tree/master/src/content/posts/Markdown-Extensions.md) of this post.
-
-### Tabs
-
-::::tabs
-::tab[Component Syntax]
-
-```md
-:::tabs
-::tab[title]{active}
-[content]
-::tab[title]
-[content]
-:::
-```
-
-::tab[Parameter Description]
-
-- `title`: The title of the tab.
-- `active`: The tab is active by default, should only be used once in a tabs group.
-- `content`: The content of the tab.
-
-::tab[Component Examples]{active}
-
-:::tabs
-::tab[Tab Example 1]{active}
-This is the content of tab 1.
-::tab[Tab Example 2]
-This is the content of tab 2.
-:::
-
-:::tabs
-::tab
-This is the content of tab 1.
-::tab{active}
-If no title, will automatically use `Tab [index]` as title.
-:::
-
-::tab[Code of Examples]
-
-```md
-:::tabs
-::tab[Tab 1]{active}
-This is the content of tab 1.
-::tab[Tab 2]
-This is the content of tab 2.
-:::
-```
-
-```md
-:::tabs
-::tab
-This is the content of tab 1.
-::tab{active}
-If no title, will automatically use `Tab [index]` as title.
-:::
-```
-
-::::
-
-### Inline
-
-:::tabs
-::tab[Component Syntax]
-
-```md
-:inline[content]
-```
-
-::tab[Parameter Description]
-
-- `content`: The content of the inline component, will add `inline` class to the rendered content.
-
-::tab[Component Examples]{active}
-
-This is an inline img example: :inline[![Inline Image](/favicon/favicon-192x192.png)].
-
-::tab[Code of Examples]
-
-```md
-This is an inline img example: :inline[![Inline Image](/favicon/favicon-192x192.png)].
-```
-
-:::
src/plugins/components/inline.mjs
@@ -1,41 +0,0 @@
-/// <reference types="mdast" />
-import { h } from 'hastscript';
-
-/**
- * Create a Tabs component.
- *
- * @param {Object} props - The properties of the component.
- * @param {import('mdast').RootContent[]} children - The children elements of the component.
- * @returns {import('mdast').Parent} The created Tabs component.
- */
-export function componentInline(props, children) {
-  if (!Array.isArray(children)) {
-    return h(
-      'span',
-      { class: 'hidden' },
-      'Invalid directive. ("inline" directive must be a text directive with child.)'
-    );
-  }
-  if (children.length !== 1) {
-    return h(
-      'span',
-      { class: 'hidden' },
-      'Invalid directive. ("inline" directive must be a text directive with child.)'
-    );
-  }
-  const child = children[0];
-
-  if (child.tagName === 'img') {
-    delete child.properties['data-zoom'];
-  }
-
-  const classes = [
-    ...new Set([
-      ...('class' in child.properties ? child.properties.class : '').split(' '),
-      'inline',
-    ]),
-  ].join(' ');
-  child.properties.class = classes;
-
-  return h(child.tagName, child.properties, child.children);
-}
src/plugins/components/tabs.mjs
@@ -1,70 +0,0 @@
-/// <reference types="mdast" />
-import { h } from 'hastscript';
-
-/**
- * Create a Tabs component.
- *
- * @param {Object} props - The properties of the component.
- * @param {import('mdast').RootContent[]} children - The children elements of the component.
- * @returns {import('mdast').Parent} The created Tabs component.
- */
-export function componentTabs(props, children) {
-  if (!Array.isArray(children) || children.length === 0) {
-    return h(
-      'div',
-      { class: 'hidden' },
-      'Invalid directive. ("tabs" directive must container type and have at least one tab.)'
-    );
-  }
-  if (children[0].tagName !== 'tab') {
-    return h(
-      'div',
-      { class: 'hidden' },
-      'Invalid directive. ("tabs" directive must container type with "tab" leaf directive as first child.)'
-    );
-  }
-
-  const { result: tabs } = children.reduce(
-    (acc, child, index) => {
-      if (child.tagName === 'tab') {
-        if (acc.temp.title !== '') {
-          acc.result.push(acc.temp);
-          acc.temp = { title: '', content: [] };
-        }
-        acc.temp.title =
-          child.children.length > 0 ? child.children[0].value : `Tab ${acc.result.length + 1}`;
-        if ('active' in child.properties) {
-          acc.temp.active = true;
-        }
-        return acc;
-      }
-      acc.temp.content.push(child);
-      if (index === children.length - 1) acc.result.push(acc.temp);
-
-      return acc;
-    },
-    {
-      temp: {
-        title: '',
-        active: false,
-        content: [],
-      },
-      result: [],
-    }
-  );
-
-  const tabsId = `tabs-${Math.random().toString(36).substring(2, 15)}`;
-  const tabsContent = tabs.flatMap((tab) => {
-    const tabTitle = h('input', {
-      class: 'tab',
-      type: 'radio',
-      name: tabsId,
-      'aria-label': tab.title,
-      checked: tab.active ? 'checked' : undefined,
-    });
-    const tabContent = h('div', { class: 'tab-content p-4' }, ...tab.content);
-    return [tabTitle, tabContent];
-  });
-
-  return h('div', { class: 'tabs tabs-box bg-base-200/15 my-4' }, tabsContent);
-}
src/plugins/remark-prase-directive.mjs
@@ -1,37 +0,0 @@
-/**
- * @import {} from 'mdast-util-directive'
- * @import {Root} from 'mdast'
- */
-import { h } from 'hastscript';
-import { visit } from 'unist-util-visit';
-
-export function parseDirectiveNodes() {
-  /**
-   * @param {Root} tree
-   *   Tree.
-   * @returns {undefined}
-   *   Nothing.
-   */
-  return (tree) => {
-    visit(tree, (node) => {
-      if (
-        node.type === 'containerDirective' ||
-        node.type === 'leafDirective' ||
-        node.type === 'textDirective'
-      ) {
-        const data = node.data || (node.data = {});
-        node.attributes = node.attributes || {};
-        if (
-          node.children.length > 0 &&
-          node.children[0].data &&
-          node.children[0].data.directiveLabel
-        ) {
-          node.attributes['has-directive-label'] = true;
-        }
-        const hast = h(node.name, node.attributes);
-        data.hName = hast.tagName;
-        data.hProperties = hast.properties;
-      }
-    });
-  };
-}
src/scripts/stores.ts
@@ -0,0 +1,7 @@
+import { map } from 'nanostores';
+
+export interface SyncTabs {
+  [key: string]: number;
+}
+
+export const syncTabs = map<SyncTabs>();
src/content.config.ts
@@ -3,7 +3,7 @@ import { defineCollection, z } from 'astro:content';
 
 const postsCollection = defineCollection({
   loader: glob({
-    pattern: '**/*.md',
+    pattern: '**/*.{md,mdx}',
     base: 'src/content/posts',
   }),
   schema: z.object({
@@ -23,7 +23,7 @@ const postsCollection = defineCollection({
 
 const specCollection = defineCollection({
   loader: glob({
-    pattern: '**/*.md',
+    pattern: '**/*.{md,mdx}',
     base: 'src/content/spec',
   }),
   schema: z.object({
astro.config.mjs
@@ -1,22 +1,18 @@
 // @ts-check
 import { CDN } from './src/constants/cdn.mjs';
-import { componentInline } from './src/plugins/components/inline.mjs';
-import { componentTabs } from './src/plugins/components/tabs.mjs';
 import { rehypeWrapTables } from './src/plugins/rehype-wrap-tables.mjs';
 import { remarkExcerpt } from './src/plugins/remark-excerpt';
 import { remarkImageProcess } from './src/plugins/remark-image-process.mjs';
-import { parseDirectiveNodes } from './src/plugins/remark-prase-directive.mjs';
 import { remarkReadingTime } from './src/plugins/remark-reading-time.mjs';
 import { rehypeHeadingIds } from '@astrojs/markdown-remark';
+import mdx from '@astrojs/mdx';
 import sitemap from '@astrojs/sitemap';
 import tailwindcss from '@tailwindcss/vite';
 import icon from 'astro-icon';
 import pagefind from 'astro-pagefind';
 import { defineConfig } from 'astro/config';
 import rehypeAutolinkHeadings from 'rehype-autolink-headings';
-import rehypeComponents from 'rehype-components';
 import rehypeMathJaxCHtml from 'rehype-mathjax/chtml';
-import remarkDirective from 'remark-directive';
 import remarkGithubBlockQuote from 'remark-github-beta-blockquote-admonitions';
 import remarkMath from 'remark-math';
 
@@ -30,6 +26,7 @@ export default defineConfig({
     icon(),
     sitemap({ filter: (page) => !page.includes('/archives/') && !page.includes('/about/') }),
     pagefind(),
+    mdx(),
   ],
   markdown: {
     shikiConfig: {
@@ -56,17 +53,9 @@ export default defineConfig({
           },
         },
       ],
-      remarkDirective,
-      parseDirectiveNodes,
     ],
     rehypePlugins: [
       rehypeHeadingIds,
-      [
-        rehypeComponents,
-        {
-          components: { tabs: componentTabs, inline: componentInline },
-        },
-      ],
       [
         rehypeAutolinkHeadings,
         {
package.json
@@ -14,6 +14,7 @@
   },
   "dependencies": {
     "@astrojs/markdown-remark": "^6.1.0",
+    "@astrojs/mdx": "^4.0.8",
     "@astrojs/rss": "^4.0.11",
     "@astrojs/sitemap": "^3.2.1",
     "@iconify-json/material-symbols": "^1.2.14",
@@ -30,12 +31,11 @@
     "markdown-it": "^14.1.0",
     "mdast-util-to-string": "^4.0.0",
     "medium-zoom": "^1.1.0",
+    "nanostores": "^0.11.3",
     "postcss-load-config": "^6.0.1",
     "reading-time": "^1.5.0",
     "rehype-autolink-headings": "^7.1.0",
-    "rehype-components": "^0.3.0",
     "rehype-mathjax": "^6.0.0",
-    "remark-directive": "^3.0.1",
     "remark-github-beta-blockquote-admonitions": "^3.1.1",
     "remark-math": "^6.0.0",
     "sanitize-html": "^2.14.0",