fix: bidirectional references in spec pages

This commit is contained in:
HPCesia 2025-03-26 16:10:11 +08:00
parent a3ae7b72a5
commit 6d41ef585a
6 changed files with 294 additions and 102 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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} />}

View File

@ -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 或 specdrafts 会被处理为 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;
}