Commit c8f92be

HPCesia <me@hpcesia.com>
2025-02-12 10:19:56
feat: static prettier codeblock
1 parent 66234ae
src/components/utils/Markdown.astro
@@ -1,108 +1,12 @@
 ---
 import '@/styles/markdown.css';
-import Button from '@components/widgets/Button.astro';
-import { isFirstInstance } from '@utils/component-utils';
-import { Icon } from 'astro-icon/components';
 import type { HTMLAttributes } from 'astro/types';
 
 type Props = HTMLAttributes<'article'>;
 
 const { class: className, ...rest } = Astro.props;
-
-const html = await Astro.slots.render('default');
-const hasPre = /<pre[\s>]/i.test(html);
-// 开发环境下,保存文件后 isFirstInstance 会返回 false,所以需要强制显示工具栏
-const firstHasPre = hasPre && (isFirstInstance('md-has-pre', Astro.url) || import.meta.env.DEV);
 ---
 
 <article class={className} {...rest}>
-  <Fragment set:html={html} />
+  <slot />
 </article>
-
-{
-  firstHasPre && (
-    <template
-      id="code-toolbar-template"
-      class="code-block-wrapper collapse-open collapse my-4 w-[initial]"
-    >
-      <div class="bg-primary/60 text-primary-content z-10 flex w-full items-center justify-between">
-        <Button class="toggle-btn btn-ghost btn-primary rounded-bl-none">
-          <Icon
-            name="material-symbols:keyboard-arrow-down-rounded"
-            class="h-5 w-5 duration-300"
-          />
-        </Button>
-        <span class="language font-mono text-sm" />
-        <Button class="copy-btn btn-ghost btn-primary rounded-br-none">
-          <Icon name="material-symbols:file-copy-rounded" class="h-5 w-5 duration-300" />
-        </Button>
-      </div>
-      <div class="collapse-content w-full !p-0" />
-    </template>
-  )
-}
-
-<script>
-  async function init() {
-    prettierPreCode();
-  }
-
-  function prettierPreCode() {
-    const codeBlocks = document.querySelectorAll('article pre');
-    const template = document.getElementById('code-toolbar-template') as HTMLTemplateElement;
-    if (!template) return;
-
-    const wrapPre = (pre: HTMLPreElement) => {
-      const language = (pre?.getAttribute('data-language') || 'plaintext').toLocaleUpperCase();
-
-      const wrapper = document.createElement('div');
-      wrapper.className = template.className;
-
-      const toolbar = template.content.cloneNode(true) as DocumentFragment;
-      const langLabel = toolbar.querySelector('.language');
-      if (langLabel) {
-        langLabel.textContent = language;
-      }
-
-      pre.parentNode?.insertBefore(wrapper, pre);
-      wrapper.appendChild(toolbar);
-      wrapper.querySelector('.collapse-content')?.appendChild(pre);
-
-      const toggleBtn = wrapper.querySelector('.toggle-btn');
-      const copyBtn = wrapper.querySelector('.copy-btn');
-
-      toggleBtn?.addEventListener('click', () => {
-        toggleBtn.querySelector('svg')?.classList.toggle('-rotate-90');
-        wrapper.classList.toggle('collapse-open');
-      });
-
-      const code = pre.querySelector('code');
-      copyBtn?.addEventListener('click', async () => {
-        try {
-          const text = code?.textContent || '';
-          await navigator.clipboard.writeText(text);
-          copyBtn?.classList.add('text-success');
-          setTimeout(() => {
-            copyBtn?.classList.remove('text-success');
-          }, 600);
-        } catch (err) {
-          console.error('Failed to copy:', err);
-          copyBtn?.classList.add('text-error');
-          setTimeout(() => {
-            copyBtn?.classList.remove('text-error');
-          }, 600);
-        }
-      });
-    };
-
-    const isWrapped = (pre: HTMLPreElement) => {
-      return pre.parentElement?.classList.contains('collapse-content');
-    };
-
-    codeBlocks.forEach((pre) => {
-      if (!isWrapped(pre as HTMLPreElement)) wrapPre(pre as HTMLPreElement);
-    });
-  }
-
-  document.addEventListener('astro:page-load', init);
-</script>
src/plugins/rehype-prettier-codes.ts
@@ -0,0 +1,56 @@
+import { wrapperTagName } from './shiki-transformers';
+import type { RehypePlugin } from '@astrojs/markdown-remark';
+import type { ElementContent } from 'hast';
+import { visit } from 'unist-util-visit';
+
+export const rehypePrettierCodes: RehypePlugin = function () {
+  return (tree) => {
+    visit(tree, 'element', (node) => {
+      if (node.tagName !== wrapperTagName) return;
+      node.tagName = 'div';
+      const originalClass = (node.properties?.class as string) || '';
+      const additionalClass = ['group'];
+      const newClass = originalClass.split(' ').concat(additionalClass).join(' ');
+      node.properties = { ...node.properties, class: newClass };
+
+      const language = node.properties?.dataLanguage as string;
+      const languageTag: ElementContent = {
+        type: 'element',
+        tagName: 'span',
+        properties: {
+          class:
+            'badge badge-outline absolute top-2 right-2 group-hover:opacity-0 duration-200',
+        },
+        children: [
+          {
+            type: 'text',
+            value: language,
+          },
+        ],
+      };
+
+      const copyIcon: ElementContent = {
+        type: 'element',
+        tagName: 'span',
+        properties: {
+          class: 'icon-[material-symbols--file-copy-rounded]',
+        },
+        children: [],
+      };
+      const copyBtn: ElementContent = {
+        type: 'element',
+        tagName: 'button',
+        properties: {
+          class:
+            'badge badge-outline tooltip tooltip-left absolute top-2 right-3 group-hover:opacity-100 duration-200 opacity-0',
+          onclick: `navigator.clipboard.writeText(this.parentElement.children[0].textContent);
+          this.dataset.tip = 'Copied!'; setTimeout(() => this.dataset.tip = 'Copy', 1000);`,
+          'data-tip': 'Copy',
+        },
+        children: [copyIcon],
+      };
+
+      node.children.push(languageTag, copyBtn);
+    });
+  };
+};
src/plugins/shiki-transformers.ts
@@ -0,0 +1,15 @@
+import { h } from 'hastscript';
+import type { ShikiTransformer } from 'shiki';
+
+export const wrapperTagName = 'code-block';
+
+export const wrapCode = (): ShikiTransformer => {
+  return {
+    name: 'shiki-transformer-wrap-code',
+    pre(node) {
+      const container = h('pre', node.children);
+      node.children = [container];
+      node.tagName = wrapperTagName;
+    },
+  };
+};
src/styles/markdown.css
@@ -168,14 +168,12 @@ article {
 
   /* 代码样式 */
   pre {
-    padding: 0.5rem;
+    padding: 1rem 2rem 1rem 0.5rem;
     counter-reset: line;
     transition: 300ms all ease;
+    overflow-x: scroll;
 
     code {
-      white-space: pre-wrap;
-      overflow-wrap: anywhere;
-      word-break: break-all;
       display: flex;
       flex-direction: column;
       gap: 0.125em;
@@ -201,6 +199,14 @@ article {
     }
   }
 
+  div:has(> pre) {
+    @apply relative rounded-md;
+
+    > :last-child > span {
+      @apply bg-base-content/60!;
+    }
+  }
+
   code:not(pre code) {
     padding: 0 0.25rem;
 
src/utils/component-utils.ts
@@ -1,12 +0,0 @@
-import type { AstroGlobal } from 'astro';
-
-const renderedInstance = new Set<string>();
-
-export function isFirstInstance(id: string, url: AstroGlobal['url']): boolean {
-  const key = `${id}-${url.pathname}`;
-  if (renderedInstance.has(key)) {
-    return false;
-  }
-  renderedInstance.add(key);
-  return true;
-}
astro.config.mjs
@@ -1,14 +1,18 @@
 // @ts-check
 import { CDN } from './src/constants/cdn.mjs';
+import { rehypePrettierCodes } from './src/plugins/rehype-prettier-codes.ts';
 import { rehypeWrapTables } from './src/plugins/rehype-wrap-tables.ts';
 import { remarkExcerpt } from './src/plugins/remark-excerpt.ts';
 import { remarkGithubBlockquote } from './src/plugins/remark-github-blockquote.ts';
 // import { remarkHeadingShift } from './src/plugins/remark-heading-shift.ts';
 import { remarkImageProcess } from './src/plugins/remark-image-process.ts';
 import { remarkReadingTime } from './src/plugins/remark-reading-time.ts';
+import { wrapCode } from './src/plugins/shiki-transformers.ts';
 import { rehypeHeadingIds } from '@astrojs/markdown-remark';
 import mdx from '@astrojs/mdx';
 import sitemap from '@astrojs/sitemap';
+import { transformerNotationDiff } from '@shikijs/transformers';
+import { transformerNotationHighlight } from '@shikijs/transformers';
 import tailwindcss from '@tailwindcss/vite';
 import icon from 'astro-icon';
 import pagefind from 'astro-pagefind';
@@ -36,6 +40,7 @@ export default defineConfig({
         dark: 'one-dark-pro',
       },
       defaultColor: false,
+      transformers: [transformerNotationDiff(), transformerNotationHighlight(), wrapCode()],
     },
     remarkPlugins: [
       // remarkHeadingShift,
@@ -69,6 +74,7 @@ export default defineConfig({
         },
       ],
       rehypeWrapTables,
+      rehypePrettierCodes,
     ],
   },
   vite: {
package.json
@@ -20,6 +20,7 @@
     "@iconify-json/ic": "^1.2.2",
     "@iconify-json/material-symbols": "^1.2.14",
     "@iconify-json/mdi": "^1.2.3",
+    "@shikijs/transformers": "^2.3.2",
     "@tailwindcss/vite": "^4.0.6",
     "astro": "^5.2.5",
     "astro-compress": "2.3.5",
@@ -43,7 +44,8 @@
     "sharp": "^0.33.5",
     "tailwindcss": "^4.0.6",
     "typescript": "^5.7.3",
-    "unist-util-visit": "^5.0.0"
+    "unist-util-visit": "^5.0.0",
+    "shiki": "^2.3.2"
   },
   "devDependencies": {
     "@astrojs/check": "^0.9.4",