Commit 6d41ef5

HPCesia <me@hpcesia.com>
2025-03-26 08:10:11
fix: bidirectional references in spec pages
1 parent a3ae7b7
src/components/misc/BackLinks.astro
@@ -1,11 +1,14 @@
 ---
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
-import type { CollectionEntry } from 'astro:content';
 
 interface Props {
   backLinks: {
-    reference: CollectionEntry<'posts'>;
+    refBy: {
+      title: string;
+      collection: 'posts' | 'spec';
+      id: string;
+    };
     context: string;
     offset: [number, number];
     id: string;
@@ -21,20 +24,23 @@ const { backLinks } = Astro.props;
   <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>
-        ))
+        backLinks.map(({ refBy, context, id, offset }) => {
+          const url = refBy.collection === 'posts' ? `/posts/${refBy.id}` : `/${refBy.id}`;
+          return (
+            <li class="list-row">
+              <a href={`${url}#wiki-${id}`} title={refBy.title} class="list-col-grow">
+                <span class="text-base font-bold">{refBy.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>
src/components/utils/Markdown.astro
@@ -1,12 +1,52 @@
 ---
 import '@/styles/markdown.css';
 import type { HTMLAttributes } from 'astro/types';
+import Replacer from './Replacer.astro';
 
-type Props = HTMLAttributes<'article'>;
+interface Props extends HTMLAttributes<'article'> {
+  'bidirectional-references'?: {
+    references: {
+      reference: string;
+      context: string;
+      id: string;
+    }[];
+    allRefByCurrent: {
+      refTo: {
+        title: string;
+        collection: 'posts' | 'spec';
+        id: string;
+      };
+      context: string;
+      offset: [number, number];
+      id: string;
+    }[];
+  };
+}
 
-const { class: className, ...rest } = Astro.props;
+const {
+  class: className,
+  'bidirectional-references': bidirectionalReferences,
+  ...rest
+} = Astro.props;
+
+const references = bidirectionalReferences?.references;
+const allRefByCurrent = bidirectionalReferences?.allRefByCurrent;
+const replacer = (_: string, reference: string, alias: string) => {
+  const id = references?.find((item) => item.reference === reference)?.id;
+  if (!id) return '';
+  const refTo = allRefByCurrent?.find((it) => it.id === id);
+  if (!refTo) return '';
+  const url =
+    refTo.refTo.collection === 'posts' ? `/posts/${refTo.refTo.id}/` : `/${refTo.refTo.id}/`;
+  return `<a href="${url}" id="wiki-${id}">${alias || reference}</a>`;
+};
+const referencePattern = /%%%%(.*?)(?:%%(.*?))?%%%%/g;
+
+const Fragment = bidirectionalReferences ? Replacer : 'Fragment';
 ---
 
 <article class={className} {...rest}>
-  <slot />
+  <Fragment pattern={referencePattern} replacer={replacer}>
+    <slot />
+  </Fragment>
 </article>
src/pages/posts/[article].astro
@@ -1,17 +1,16 @@
 ---
-import { buildConfig, siteConfig } from '@/config';
+import { siteConfig } from '@/config';
 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';
 import Markdown from '@components/utils/Markdown.astro';
-import Replacer from '@components/utils/Replacer.astro';
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
-import { getPosts } from '@utils/content-utils';
+import { getAllReferences, getPosts } from '@utils/content-utils';
 import { Icon } from 'astro-icon/components';
-import { render, type CollectionEntry } from 'astro:content';
+import { render } from 'astro:content';
 import MarkdownIt from 'markdown-it';
 
 export async function getStaticPaths() {
@@ -29,79 +28,26 @@ const coverSrc =
 const description = article.data.description || remarkPluginFrontmatter.excerpt;
 const isDraft = article.data.draft === true;
 
-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 allReferences = await getAllReferences();
+const allRefByCurrent = allReferences.filter((it) => it.refBy.id === article.id);
+const allRefToCurrent = allReferences.filter((it) => it.refTo.id === article.id);
+
 const references: {
   reference: string;
   context: string;
   id: string;
 }[] = remarkPluginFrontmatter.references || [];
-const replacer = (_: string, reference: string, alias: string) => {
-  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'>;
+  refBy: {
+    title: string;
+    collection: 'posts' | 'spec';
+    id: string;
+  };
   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();
+}[] = allRefToCurrent;
 ---
 
 <PostPageLayout
@@ -129,7 +75,12 @@ const backLinks: {
       <ImageWrapper src={coverSrc!} class="mb-6 rounded-xl shadow" alt={article.data.title} />
     )
   }
-  <Markdown>
+  <Markdown
+    bidirectional-references={{
+      references,
+      allRefByCurrent,
+    }}
+  >
     {
       isDraft && (
         <div class="admonition admonition-note">
@@ -141,9 +92,7 @@ const backLinks: {
         </div>
       )
     }
-    <Replacer pattern={referencePattern} replacer={replacer}>
-      <Content />
-    </Replacer>
+    <Content />
   </Markdown>
   <License time={article.data.published} lang={article.data.lang} />
   {backLinks.length > 0 && <BackLinks backLinks={backLinks} />}
src/pages/about.astro
@@ -4,20 +4,48 @@ import Markdown from '@components/utils/Markdown.astro';
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
+import { getAllReferences } from '@utils/content-utils';
 import { getEntry, render } from 'astro:content';
 
-const aboutMd = await getEntry('spec', 'about');
-const { Content } = aboutMd ? await render(aboutMd) : Fragment;
+const md = await getEntry('spec', 'about');
+
+const { Content, headings, remarkPluginFrontmatter } = md
+  ? await render(md)
+  : {
+      Content: Fragment,
+      headings: [],
+      remarkPluginFrontmatter: { references: [] },
+    };
+
+const allReferences = await getAllReferences();
+let allRefByCurrent: typeof allReferences = [];
+let references: {
+  reference: string;
+  context: string;
+  id: string;
+}[] = [];
+if (md) {
+  allRefByCurrent = allReferences.filter((it) => it.refBy.id === md.id);
+  references = remarkPluginFrontmatter.references || [];
+}
 ---
 
 <PostPageLayout
-  title={aboutMd?.data.title || (i18n(I18nKey.about) as string)}
-  comment={aboutMd?.data.comment}
+  title={md?.data.title || (i18n(I18nKey.about) as string)}
+  headings={headings}
+  comment={md?.data.comment}
 >
   <Fragment slot="header-content">
-    <PostInfo title={i18n(I18nKey.about) as string} />
+    <PostInfo title={md?.data.title || (i18n(I18nKey.about) as string)} />
   </Fragment>
-  <Markdown>
+  <Markdown
+    bidirectional-references={md
+      ? {
+          references,
+          allRefByCurrent,
+        }
+      : undefined}
+  >
     <Content />
   </Markdown>
 </PostPageLayout>
src/pages/links.astro
@@ -6,22 +6,41 @@ import Markdown from '@components/utils/Markdown.astro';
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
+import { getAllReferences } from '@utils/content-utils';
 import { getEntry, render } from 'astro:content';
 
-const linksMd = await getEntry('spec', 'links');
+const md = await getEntry('spec', 'links');
+
+const { Content, headings, remarkPluginFrontmatter } = md
+  ? await render(md)
+  : {
+      Content: Fragment,
+      headings: [],
+      remarkPluginFrontmatter: { references: [] },
+    };
+
+const allReferences = await getAllReferences();
+let allRefByCurrent: typeof allReferences = [];
+let references: {
+  reference: string;
+  context: string;
+  id: string;
+}[] = [];
+if (md) {
+  allRefByCurrent = allReferences.filter((it) => it.refBy.id === md.id);
+  references = remarkPluginFrontmatter.references || [];
+}
+
 const groupHeadings = linksConfig.items.map((item) => ({
   depth: 2,
   slug: `links-group-${item.groupName.toLowerCase().replace(/\s/g, '-')}`,
   text: item.groupName,
 }));
-const { Content, headings } = linksMd
-  ? await render(linksMd)
-  : { Content: Fragment, headings: [] };
 ---
 
 <PostPageLayout
   title={i18n(I18nKey.links) as string}
-  comment={linksMd?.data.comment}
+  comment={md?.data.comment}
   headings={[...groupHeadings, ...headings]}
 >
   <Fragment slot="header-content">
@@ -77,7 +96,14 @@ const { Content, headings } = linksMd
     ))
   }
   <hr class="text-base-content/25 mt-8" />
-  <Markdown>
+  <Markdown
+    bidirectional-references={md
+      ? {
+          references,
+          allRefByCurrent,
+        }
+      : undefined}
+  >
     <Content />
   </Markdown>
 </PostPageLayout>
src/utils/content-utils.ts
@@ -80,3 +80,146 @@ export function getTagUrl(tag: string) {
     ? `/archives/tags/${I18nKey.untagged}/1`
     : `/archives/tags/${tag.replaceAll(/[\\/]/g, '-')}/1`;
 }
+
+/**
+ * 获取所有的引用,返回一个数组
+ */
+export async function getAllReferences() {
+  type frontmatterRef = {
+    /** 引用的文章,即 [[ref|alias]] 中的 ref 部分 */
+    reference: string;
+    /** 引用的上下文,即整个 xxxx[[ref|alias]]xxxx 的内容,截取前后 20 个字符 */
+    context: string;
+    /** 引用的偏移量,即 [[ref|alias]] 在上下文字符串中的起始和结束位置 */
+    offset: [number, number];
+    /** 引用应该被分配的 id,用于通过 #id 跳转 */
+    id: string;
+  };
+  type Article = {
+    /** 文章的标题 */
+    title: string;
+    /** 文章的集合,posts 或 spec,drafts 会被处理为 posts */
+    collection: 'posts' | 'spec';
+    /** 文章的 id,对 posts 来说是 slug 参数,对 spec 来说是文件名 */
+    id: string;
+  };
+
+  const posts = await getPosts();
+  const specs = await getCollection('spec');
+
+  const pathMap = [
+    ...posts.map((it) => ({
+      id: it.id,
+      title: it.data.title,
+      collection: it.collection,
+      filePath: it.filePath,
+    })),
+    ...specs.map((it) => ({
+      id: it.id,
+      title: it.data.title,
+      collection: it.collection,
+      filePath: it.filePath,
+    })),
+  ].reduce(
+    (acc, it) => {
+      const path = it.filePath!.replace('src/content/', '').split('.').slice(0, -1).join('.');
+      acc[path] = {
+        id: it.id,
+        title: it.title || path.split('/').slice(-1)[-1],
+        collection: it.collection,
+      };
+      return acc;
+    },
+    {} as Record<
+      string,
+      {
+        id: string;
+        title: string;
+        collection: 'posts' | 'spec';
+      }
+    >
+  );
+
+  const postsRefData = posts.map(async (post) => {
+    const { remarkPluginFrontmatter } = await render(post);
+    const references: frontmatterRef[] = remarkPluginFrontmatter.references || [];
+    return {
+      title: post.data.title,
+      colletion: 'posts',
+      id: post.id,
+      references,
+    };
+  });
+  const specRefData = specs.map(async (spec) => {
+    const { remarkPluginFrontmatter } = await render(spec);
+    const references: frontmatterRef[] = remarkPluginFrontmatter.references || [];
+    return {
+      title: spec.data.title || spec.filePath?.split('/').slice(-1)[0],
+      colletion: 'spec',
+      id: spec.id,
+      references,
+    };
+  });
+
+  const getArticle = (refPath: string): Article | null => {
+    let collection = refPath.split('/')[0];
+    if (!['posts', 'drafts', 'spec'].includes(collection)) {
+      collection = 'posts';
+      refPath = `posts/${refPath}`;
+    }
+    const { id, title } = pathMap[refPath];
+    if (id) {
+      if (collection === 'spec') {
+        const article = specs.find((it) => it.id === id);
+        if (article) return { title, collection, id };
+      } else {
+        const article = posts.find((it) => it.id === id);
+        if (article) return { title, collection: 'posts', id };
+      }
+    }
+    return null;
+  };
+
+  const references: {
+    refBy: Article;
+    refTo: Article;
+    /** 引用的上下文,即整个 xxxx[[ref|alias]]xxxx 的内容,截取前后 20 个字符 */
+    context: string;
+    /** 引用的偏移量,即 [[ref|alias]] 在上下文字符串中的起始和结束位置 */
+    offset: [number, number];
+    /** 引用应该被分配的 id,用于通过 #id 跳转 */
+    id: string;
+  }[] = [
+    ...(await Promise.all(postsRefData)).flatMap((data) => {
+      const article: Article = {
+        title: data.title,
+        collection: data.colletion as 'posts' | 'spec',
+        id: data.id,
+      };
+      return data.references
+        .map((ref) => {
+          const { reference, context, offset, id } = ref;
+          const refTo = getArticle(reference);
+          if (refTo) return { refBy: article, refTo, context, offset, id };
+          return null;
+        })
+        .filter((it) => it !== null);
+    }),
+    ...(await Promise.all(specRefData)).flatMap((data) => {
+      const article: Article = {
+        title: data.title || data.id,
+        collection: data.colletion as 'posts' | 'spec',
+        id: data.id,
+      };
+      return data.references
+        .map((ref) => {
+          const { reference, context, offset, id } = ref;
+          const refTo = getArticle(reference);
+          if (refTo) return { refBy: article, refTo, context, offset, id };
+          return null;
+        })
+        .filter((it) => it !== null);
+    }),
+  ];
+  return references;
+}