Commit dc2cc8d
Changed files (10)
src
components
misc
content
posts
i18n
pages
posts
plugins
types
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,