Commit dc2cc8d

HPCesia <me@hpcesia.com>
2025-03-14 10:00:07
feat: wiki link
This commit add the supports of Obsidian style wiki link, and also back links.
1 parent f9ac917
src/components/misc/BackLinks.astro
@@ -0,0 +1,41 @@
+---
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import type { CollectionEntry } from 'astro:content';
+
+interface Props {
+  backLinks: {
+    reference: CollectionEntry<'posts'>;
+    context: string;
+    offset: [number, number];
+    id: string;
+  }[];
+}
+
+const { backLinks } = Astro.props;
+---
+
+<div class="bg-base-100 border-base-content/25 collapse-plus collapse my-4 border">
+  <input type="checkbox" />
+  <div class="collapse-title text-lg font-semibold">{i18n(I18nKey.backLinks)}</div>
+  <div class="collapse-content">
+    <ul class="list">
+      {
+        backLinks.map(({ reference: { data }, context, id, offset }) => (
+          <li class="list-row">
+            <a href={`/posts/${data.slug}#wiki-${id}`} title={data.title} class="list-col-grow">
+              <span class="text-base font-bold">{data.title}</span>
+              <div class="text-base-content/60 mt-2 flex flex-wrap items-start gap-x-4 gap-y-2 text-sm">
+                <span>
+                  {context.slice(0, offset[0])}
+                  <strong class="text-primary">{context.slice(offset[0], offset[1])}</strong>
+                  {context.slice(offset[1])}
+                </span>
+              </div>
+            </a>
+          </li>
+        ))
+      }
+    </ul>
+  </div>
+</div>
src/content/posts/markdown.md
@@ -101,9 +101,7 @@ And that's it!
 
 #### I Think You Want to Have a Look at Heading Level 4
 
-Bidirectional article links are also supported, you can use `[[slug]]` to create a link to another article.
-
-[[q1k423y0]]
+Bidirectional article links are also supported, you can use `[[slug]]` to create a link to another article like [[posts/create-custom-page/index|create custom page]] or [[manual]]
 
 ##### Too Many Nested Headings is Not a Good Idea, but Here is Heading Level 5
 
src/i18n/langs/en.ts
@@ -16,6 +16,7 @@ export const en: Translation = {
 
   [Key.comments]: 'Comments',
   [Key.subscribe]: 'Subscribe',
+  [Key.backLinks]: 'Back Links',
 
   [Key.untitled]: 'Untitled',
   [Key.uncategorized]: 'Uncategorized',
src/i18n/langs/zh_CN.ts
@@ -16,6 +16,7 @@ export const zh_CN: Translation = {
 
   [Key.comments]: '评论',
   [Key.subscribe]: '订阅',
+  [Key.backLinks]: '反向链接',
 
   [Key.untitled]: '无标题',
   [Key.uncategorized]: '未分类',
src/i18n/langs/zh_TW.ts
@@ -16,6 +16,7 @@ export const zh_TW: Translation = {
 
   [Key.comments]: '評論',
   [Key.subscribe]: '訂閱',
+  [Key.backLinks]: '反向連結',
 
   [Key.untitled]: '無標題',
   [Key.uncategorized]: '未分類',
src/i18n/I18nKey.ts
@@ -13,6 +13,7 @@ enum I18nKey {
 
   comments = 'comments',
   subscribe = 'subscribe',
+  backLinks = 'backLinks',
 
   untitled = 'untitled',
   uncategorized = 'uncategorized',
src/pages/posts/[article].astro
@@ -1,5 +1,7 @@
 ---
-import { siteConfig } from '@/config';
+import { buildConfig, siteConfig } from '@/config';
+import { getContainerRenderer as mdxContainerRenderer } from '@astrojs/mdx';
+import BackLinks from '@components/misc/BackLinks.astro';
 import License from '@components/misc/License.astro';
 import PostInfo from '@components/misc/PostInfo.astro';
 import ImageWrapper from '@components/utils/ImageWrapper.astro';
@@ -9,7 +11,9 @@ import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
 import { getPosts } from '@utils/content-utils';
 import { Icon } from 'astro-icon/components';
-import { render } from 'astro:content';
+import { experimental_AstroContainer } from 'astro/container';
+import { loadRenderers } from 'astro:container';
+import { render, type CollectionEntry } from 'astro:content';
 import MarkdownIt from 'markdown-it';
 import path from 'path';
 
@@ -32,6 +36,85 @@ const coverSrc = hasCover
   : undefined;
 const description = article.data.description || remarkPluginFrontmatter.excerpt;
 const isDraft = article.data.draft === true;
+
+const container = await experimental_AstroContainer.create({
+  renderers: await loadRenderers([mdxContainerRenderer()]),
+});
+let contentString = await container.renderToString(Content);
+
+const referencePattern = /%%%%(.*?)(?:%%(.*?))?%%%%/g;
+const allPosts = await getPosts();
+const pathIdMap = allPosts.reduce(
+  (acc, post) => {
+    const slug = post.id;
+    const path = post.filePath!.replace('src/content/', '').split('.').slice(0, -1).join('.');
+    acc[path] = slug;
+    return acc;
+  },
+  {} as Record<string, string>
+);
+const getArticle = (refPath: string) => {
+  let collection = refPath.split('/')[0];
+  if (!['posts', 'drafts', 'spec'].includes(collection)) {
+    collection = 'posts';
+    refPath = `posts/${refPath}`;
+  }
+  const id = pathIdMap[refPath];
+  if (id) {
+    return allPosts.find((post) => post.id === id);
+  }
+};
+const references: {
+  reference: string;
+  context: string;
+  id: string;
+}[] = remarkPluginFrontmatter.references || [];
+contentString = contentString.replaceAll(referencePattern, (match, reference, alias) => {
+  const refArticle = getArticle(reference);
+  if (
+    refArticle &&
+    (refArticle.data.draft === false || (buildConfig.showDraftsOnDev && import.meta.env.DEV))
+  ) {
+    const url = `/posts/${refArticle.id}`;
+    const id = references.find((item) => item.reference === reference)?.id;
+    return `<a href="${url}" id="wiki-${id}">${alias || reference}</a>`;
+  } else {
+    return '';
+  }
+});
+
+const backLinks: {
+  reference: CollectionEntry<'posts'>;
+  context: string;
+  offset: [number, number];
+  id: string;
+}[] = (
+  await Promise.all(
+    allPosts.map(async (post) => {
+      const { remarkPluginFrontmatter } = await render(post);
+      const references: {
+        reference: string;
+        context: string;
+        offset: [number, number];
+        id: string;
+      }[] = remarkPluginFrontmatter.references || [];
+      return references
+        .map((reference) => {
+          const refArticle = getArticle(reference.reference);
+          if (refArticle && refArticle.id === article.id) {
+            return {
+              reference: post,
+              context: reference.context,
+              offset: reference.offset,
+              id: reference.id,
+            };
+          }
+          return undefined;
+        })
+        .filter((item) => item !== undefined);
+    })
+  )
+).flat();
 ---
 
 <PostPageLayout
@@ -71,7 +154,8 @@ const isDraft = article.data.draft === true;
         </div>
       )
     }
-    <Content />
+    <Fragment set:html={contentString} />
   </Markdown>
   <License time={article.data.published} lang={article.data.lang} />
+  {backLinks.length > 0 && <BackLinks backLinks={backLinks} />}
 </PostPageLayout>
src/plugins/remark-article-references.ts
@@ -0,0 +1,41 @@
+import type { RemarkPlugin } from '@astrojs/markdown-remark';
+import crypto from 'crypto';
+import { visit } from 'unist-util-visit';
+
+export const remarkArticleReferences: RemarkPlugin = function () {
+  return (tree, { data }) => {
+    visit(tree, 'text', (node) => {
+      const references: {
+        reference: string;
+        context: string;
+        offset: [number, number];
+        id: string;
+      }[] = [];
+      // \[\[ - 匹配开始的 [[
+      // ((?:\\.|[^\|\]])*?) - 第一个捕获组($1),匹配:
+      //   \\. - 任何转义字符(包括\|)
+      //   或 [^|\]] - 任何不是|和]的字符
+      // (?:\|(.*?))? - 非捕获组,可选的,匹配:
+      //   \| - 分隔符|
+      //   (.*?) - 第二个捕获组($2),非贪婪匹配任意字符
+      // \]\] - 匹配结束的 ]]
+      const linkPattern = /\[\[((?:\\.|[^|\]])*?)(?:\|(.*?))?\]\]/g;
+      node.value = node.value.replaceAll(linkPattern, (match, reference, alias, offset) => {
+        const startOffset = Math.max(0, offset - 40);
+        const endOffset = Math.min(node.value.length, offset + match.length + 40);
+        const matchOffsetStart = offset - startOffset + 3;
+        const matchOffsetEnd = match.length + matchOffsetStart;
+        const context = `...${node.value.substring(startOffset, endOffset).trim()}...`;
+        references.push({
+          reference,
+          context,
+          offset: [matchOffsetStart, matchOffsetEnd],
+          id: crypto.randomUUID(),
+        });
+        if (alias) return `%%%%${reference}%%${alias}%%%%`;
+        return `%%%%${reference}%%%%`;
+      });
+      if (references.length) data.astro!.frontmatter!.references = references;
+    });
+  };
+};
src/types/data.ts
@@ -15,7 +15,8 @@ export type BlogPostData = {
 
 export type BlogPost = {
   id: string;
-  rendered: RenderedContent;
   body: string;
   data: BlogPostData;
+  rendered?: RenderedContent;
+  filePath?: string;
 };
astro.config.mjs
@@ -3,6 +3,7 @@ import { CDN } from './src/constants/cdn.mjs';
 import { rehypeComponentsList } from './src/plugins/rehype-components-list.ts';
 import { rehypePrettierCodes } from './src/plugins/rehype-prettier-codes.ts';
 import { rehypeWrapTables } from './src/plugins/rehype-wrap-tables.ts';
+import { remarkArticleReferences } from './src/plugins/remark-article-references';
 import { remarkCreateTime } from './src/plugins/remark-create-time.ts';
 import { remarkExcerpt } from './src/plugins/remark-excerpt.ts';
 import { remarkGithubBlockquote } from './src/plugins/remark-github-blockquote.ts';
@@ -65,6 +66,7 @@ export default defineConfig({
       remarkExcerpt,
       remarkImageProcess,
       remarkGithubBlockquote,
+      remarkArticleReferences,
     ],
     rehypePlugins: [
       rehypeHeadingIds,