From 20728f504d2ddba55e4bcca2f7a1b6793fa9ba04 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Wed, 19 Nov 2025 16:51:49 +0800 Subject: [PATCH] feat(theme): add `plugin-llms` and `` component (#753) --- docs/.vuepress/client.ts | 2 + docs/.vuepress/collections/en/theme-config.ts | 1 + docs/.vuepress/collections/zh/theme-config.ts | 1 + docs/.vuepress/config.ts | 17 - docs/.vuepress/llmstxtTOC.ts | 131 -------- docs/.vuepress/theme.ts | 12 + docs/config/plugins/llms.md | 104 ++++++ docs/en/config/plugins/llms.md | 112 +++++++ docs/en/guide/custom/slots.md | 2 + docs/guide/custom/slots.md | 2 + docs/package.json | 1 - .../layout-slots/docs/.vuepress/client.ts | 2 + pnpm-lock.yaml | 6 +- theme/package.json | 1 + theme/src/client/components/VPContent.vue | 6 + theme/src/client/components/VPDoc.vue | 6 + theme/src/client/components/VPDocMeta.vue | 29 +- .../features/components/PageContextMenu.vue | 306 ++++++++++++++++++ theme/src/client/layouts/Layout.vue | 6 + theme/src/node/config/resolveThemeData.ts | 1 + theme/src/node/detector/fields.ts | 1 + theme/src/node/locales/de.ts | 10 + theme/src/node/locales/en.ts | 10 + theme/src/node/locales/fr.ts | 10 + theme/src/node/locales/ja.ts | 10 + theme/src/node/locales/ko.ts | 10 + theme/src/node/locales/ru.ts | 10 + theme/src/node/locales/zh-tw.ts | 10 + theme/src/node/locales/zh.ts | 10 + theme/src/node/plugins/llms.ts | 142 ++++++++ theme/src/node/plugins/setupPlugins.ts | 6 + theme/src/shared/locale.ts | 10 + theme/src/shared/options.ts | 11 +- theme/src/shared/plugins.ts | 6 + 34 files changed, 844 insertions(+), 160 deletions(-) delete mode 100644 docs/.vuepress/llmstxtTOC.ts create mode 100644 docs/config/plugins/llms.md create mode 100644 docs/en/config/plugins/llms.md create mode 100644 theme/src/client/features/components/PageContextMenu.vue create mode 100644 theme/src/node/plugins/llms.ts diff --git a/docs/.vuepress/client.ts b/docs/.vuepress/client.ts index 06a29137..45635590 100644 --- a/docs/.vuepress/client.ts +++ b/docs/.vuepress/client.ts @@ -3,6 +3,7 @@ import { defineMermaidConfig } from '@vuepress/plugin-markdown-chart/client' import { defineAsyncComponent, h } from 'vue' import { Layout } from 'vuepress-theme-plume/client' import VPPostItem from 'vuepress-theme-plume/components/Posts/VPPostItem.vue' +import PageContextMenu from 'vuepress-theme-plume/features/PageContextMenu.vue' import { defineClientConfig } from 'vuepress/client' import AsideNav from '~/components/AsideNav.vue' import { setupThemeColors } from '~/composables/theme-colors.js' @@ -25,6 +26,7 @@ export default defineClientConfig({ layouts: { Layout: h(Layout, null, { 'aside-outline-after': () => h(AsideNav), + 'doc-title-after': () => h(PageContextMenu), }), }, }) as ClientConfig diff --git a/docs/.vuepress/collections/en/theme-config.ts b/docs/.vuepress/collections/en/theme-config.ts index 01ba537b..2bcbf373 100644 --- a/docs/.vuepress/collections/en/theme-config.ts +++ b/docs/.vuepress/collections/en/theme-config.ts @@ -40,6 +40,7 @@ export const themeConfig: ThemeCollectionItem = defineCollection({ 'shiki', 'search', 'reading-time', + 'llms', 'markdown-enhance', 'markdown-power', 'markdown-image', diff --git a/docs/.vuepress/collections/zh/theme-config.ts b/docs/.vuepress/collections/zh/theme-config.ts index d2ffce4f..3a0b078a 100644 --- a/docs/.vuepress/collections/zh/theme-config.ts +++ b/docs/.vuepress/collections/zh/theme-config.ts @@ -40,6 +40,7 @@ export const themeConfig: ThemeCollectionItem = defineCollection({ 'shiki', 'search', 'reading-time', + 'llms', 'markdown-enhance', 'markdown-power', 'markdown-image', diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index 62752ba8..85570c80 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -3,9 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { viteBundler } from '@vuepress/bundler-vite' import { addViteOptimizeDepsInclude, addViteSsrExternal } from '@vuepress/helper' -import { llmsPlugin } from '@vuepress/plugin-llms' import { defineUserConfig } from 'vuepress' -import { tocGetter } from './llmstxtTOC.js' import { theme } from './theme.js' const pnpmWorkspace = fs.readFileSync(path.resolve(__dirname, '../../pnpm-workspace.yaml'), 'utf-8') @@ -47,21 +45,6 @@ export default defineUserConfig({ '~/composables': path.resolve(__dirname, './themes/composables'), }, - plugins: [ - llmsPlugin({ - llmsTxtTemplateGetter: { - description: (_, { currentLocale }) => { - return currentLocale === '/' - ? '一个简约易用的,功能丰富的 vuepress 文档&博客 主题' - : 'An easy-to-use and feature-rich vuepress documentation and blog theme' - }, - details: '', - toc: tocGetter, - }, - locale: 'all', - }), - ], - bundler: viteBundler(), shouldPrefetch: false, diff --git a/docs/.vuepress/llmstxtTOC.ts b/docs/.vuepress/llmstxtTOC.ts deleted file mode 100644 index 653184a7..00000000 --- a/docs/.vuepress/llmstxtTOC.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { LLMPage, LLMState } from '@vuepress/plugin-llms' -import type { ThemeSidebarItem } from 'vuepress-theme-plume' -import { generateTOCLink as rawGenerateTOCLink } from '@vuepress/plugin-llms' -import { ensureEndingSlash, ensureLeadingSlash } from 'vuepress/shared' -import { path } from 'vuepress/utils' -import { enCollections, zhCollections } from './collections/index.js' - -function normalizePath(prefix: string, path = ''): string { - if (path.startsWith('/')) - return path - - return `${ensureEndingSlash(prefix)}${path}` -} - -function withBase(url = '', base = '/'): string { - if (!url) - return '' - if (url.startsWith(base)) - return normalizePath(url) - return path.join(base, url) -} - -function genStarsWith(stars: string | undefined, locale: string) { - return (url: string): boolean => { - if (!stars) - return false - return url.startsWith(withBase(stars, locale)) - } -} - -export function tocGetter(llmPages: LLMPage[], llmState: LLMState): string { - const { currentLocale } = llmState - const isZh = currentLocale === '/' - const collections = isZh ? zhCollections : enCollections - - let tableOfContent = '' - const usagePages: LLMPage[] = [] - - collections - .filter(item => item.type === 'post') - .forEach(({ title, linkPrefix, link }) => { - tableOfContent += `### ${title}\n\n` - const withLinkPrefix = genStarsWith(linkPrefix, currentLocale) - const withLink = genStarsWith(link, currentLocale) - const withFallback = genStarsWith('/article/', currentLocale) - const list: string[] = [] - llmPages.forEach((page) => { - if (withLinkPrefix(page.path) || withLink(page.path) || withFallback(page.path)) { - usagePages.push(page) - list.push(rawGenerateTOCLink(page, llmState)) - } - }) - tableOfContent += `${list.filter(Boolean).join('')}\n` - }) - - const generateTOCLink = (path: string): string => { - const filepath = path.endsWith('/') ? `${path}README.md` : path.endsWith('.md') ? path : `${path || 'README'}.md` - const link = path.endsWith('/') ? `${path}index.html` : `${path}.html` - const page = llmPages.find((item) => { - return ensureLeadingSlash(item.filePathRelative || '') === filepath || link === item.path - }) - - if (page) { - usagePages.push(page) - return rawGenerateTOCLink(page, llmState) - } - return '' - } - - const processAutoSidebar = (prefix: string): string[] => { - const list: string[] = [] - llmPages.forEach((page) => { - if (ensureLeadingSlash(page.filePathRelative || '').startsWith(prefix)) { - usagePages.push(page) - list.push(rawGenerateTOCLink(page, llmState)) - } - }) - return list.filter(Boolean) - } - - const processSidebar = (items: (string | ThemeSidebarItem)[], prefix: string): string[] => { - const result: string[] = [] - items.forEach((item) => { - if (typeof item === 'string') { - result.push(generateTOCLink(normalizePath(prefix, item))) - } - else { - if (item.link) { - result.push(generateTOCLink(normalizePath(prefix, item.link))) - } - if (item.items === 'auto') { - result.push(...processAutoSidebar(normalizePath(prefix, item.prefix))) - } - else if (item.items?.length) { - result.push(...processSidebar(item.items, normalizePath(prefix, item.prefix))) - } - } - }) - return result - } - - // Collections - collections - .filter(collection => collection.type === 'doc') - .forEach(({ dir, title, sidebar = [] }) => { - tableOfContent += `### ${title}\n\n` - const prefix = normalizePath(ensureLeadingSlash(withBase(dir, currentLocale))) - if (sidebar === 'auto') { - tableOfContent += `${processAutoSidebar(prefix).join('')}\n` - } - else if (sidebar.length) { - const home = generateTOCLink(ensureEndingSlash(prefix)) - const list = processSidebar(sidebar, prefix) - if (home && !list.includes(home)) { - list.unshift(home) - } - tableOfContent += `${list.join('')}\n` - } - }) - - // Others - const unUsagePages = llmPages.filter(page => !usagePages.includes(page)) - if (unUsagePages.length) { - tableOfContent += '### Others\n\n' - tableOfContent += unUsagePages - .map(page => rawGenerateTOCLink(page, llmState)) - .join('') - } - - return tableOfContent -} diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts index 8116869f..bab440aa 100644 --- a/docs/.vuepress/theme.ts +++ b/docs/.vuepress/theme.ts @@ -76,4 +76,16 @@ export const theme: Theme = plumeTheme({ content: 'vuepress-theme-plume', }, }, + + llmstxt: { + locale: 'all', + llmsTxtTemplateGetter: { + description: (_, { currentLocale }) => { + return currentLocale === '/' + ? '一个简约易用的,功能丰富的 vuepress 文档&博客 主题' + : 'An easy-to-use and feature-rich vuepress documentation and blog theme' + }, + details: '', + }, + }, }) diff --git a/docs/config/plugins/llms.md b/docs/config/plugins/llms.md new file mode 100644 index 00000000..099ce5b5 --- /dev/null +++ b/docs/config/plugins/llms.md @@ -0,0 +1,104 @@ +--- +title: LLMs txt +createTime: 2025/11/19 14:48:35 +permalink: /config/plugins/llmstxt/ +--- + +## 概述 + +为站点添加 [llms.txt](https://llmstxt.org/),以提供对 LLM 友好的内容。 + +**关联插件**: [@vuepress/plugin-llms](https://ecosystem.vuejs.press/zh/plugins/ai/llms.html) + +## 为什么需要 llms.txt? + +大型语言模型越来越依赖网站信息,但面临一个关键限制:上下文窗口太小,无法完整处理大多数网站。将包含导航、广告和 JavaScript 的复杂 HTML 页面转换为适合 LLM 的纯文本既困难又不精确。 + +虽然网站同时为人类读者和 LLM 服务,但后者受益于在一个可访问的位置收集的更简洁、专家级的信息。这对于开发环境等使用案例尤其重要,因为 LLM 需要快速访问编程文档和 API。 + +向网站添加 `/llms.txt` Markdown 文件,以提供对 LLM 友好的内容。此文件提供了简短的背景信息、指南和指向详细 Markdown 文件的链接。 + +## 功能 + +插件通过检索你的文档源目录中的所有 Markdown 文件,并将其转换为 LLM 友好的纯文本文件。 + +::: file-tree + +- .vuepress/dist + - llms.txt + - llms-full.txt + - markdown-examples.html + - markdown-examples.md + - … +::: + +点击以下链接查看本文档站点的 llms.txt 文件: + +- [llms.txt](/llms.txt){.no-icon} +- [llms-full.txt](/llms-full.txt){.no-icon} + +::: tip +插件仅在生产构建时,即执行 `vuepress build` 命令时,生成 `llms.txt` 文件,以及其它 LLM 友好的文档文件,并将它们输出到 `.vuepress/dist` 目录中。 + +::: + +[完整功能说明请查看 **插件官方文档**](https://ecosystem.vuejs.press/zh/plugins/ai/llms.html#%E6%8F%92%E4%BB%B6%E5%8A%9F%E8%83%BD){.read-more} + +## 配置 + +主题默认不启用此功能,你可以通过 `llmstxt` 配置项启用它: + +```ts title=".vuepress/config.ts" +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + // 使用主题内置的默认配置 + // llmstxt: true, + + // 使用自定义配置 + llmstxt: { + locale: '/', + // ...其它配置 + }, + + // 也可以在 `plugins.llmstxt` 配置,但不推荐 + plugins: { + llmstxt: true + } + }), +}) +``` + +[完整配置项说明请查看 **插件官方文档**](https://ecosystem.vuejs.press/zh/plugins/ai/llms.html#options){.read-more} + +## 组件 + +为进一步增强 文档站点 与 LLMs 的互动,你可以在文档站点中添加 `` 组件。 +该组件不作为内置组件,而是主题额外的 `features` 实现,因此你需要手动引入它, +并在合适的位置,通过 [组件插槽](../../guide/custom/slots.md) 添加到文档站点中: + +```ts title=".vuepress/client.ts" +import { defineAsyncComponent, h } from 'vue' +import { Layout } from 'vuepress-theme-plume/client' +import PageContextMenu from 'vuepress-theme-plume/features/PageContextMenu.vue' // [!code ++] +import { defineClientConfig } from 'vuepress/client' + +export default defineClientConfig({ + layouts: { + Layout: h(Layout, null, { + // 将 PageContextMenu 添加到 doc-title-after 插槽,即文章标题的右侧 + 'doc-title-after': () => h(PageContextMenu), // [!code ++] + }), + }, +}) +``` + +你可以在当前页面的标题的右侧,体验该组件的功能。 + +::: important +此组件完全依赖于 `@vuepress/plugin-llms` 插件,仅当你启用了此插件功能后,才能使用它。 + +因此,此组件提供的功能 **仅在构建后的生产包中才可用** 。 +::: diff --git a/docs/en/config/plugins/llms.md b/docs/en/config/plugins/llms.md new file mode 100644 index 00000000..3589a003 --- /dev/null +++ b/docs/en/config/plugins/llms.md @@ -0,0 +1,112 @@ +--- +title: LLMs txt +createTime: 2025/11/19 14:48:35 +permalink: /en/config/plugins/llmstxt/ +--- + +## Overview + +Add [llms.txt](https://llmstxt.org/) to your site to provide LLM-friendly content. + +**Related Plugin**: [@vuepress/plugin-llms](https://ecosystem.vuejs.press/plugins/ai/llms.html) + +## Why llms.txt? + +Large Language Models increasingly rely on website information but face a key limitation: +their context window is too small to fully process most websites. +Converting complex HTML pages containing navigation, ads, and JavaScript into LLM-friendly plain text is both difficult and imprecise. + +While websites serve both human readers and LLMs, the latter benefit from more concise, +expert-level information collected in one accessible location. +This is particularly important for use cases like development environments where LLMs need quick access to programming documentation and APIs. + +Add a `/llms.txt` Markdown file to your website to provide LLM-friendly content. +This file provides brief background information, guidelines, and links to detailed Markdown files. + +## Features + +The plugin retrieves all Markdown files from your documentation source directory and converts them into LLM-friendly plain text files. + +::: file-tree + +- .vuepress/dist + - llms.txt + - llms-full.txt + - markdown-examples.html + - markdown-examples.md + - … +::: + +Click the links below to view the llms.txt files for this documentation site: + +- [llms.txt](/llms.txt){.no-icon} +- [llms-full.txt](/llms-full.txt){.no-icon} + +::: tip +The plugin only generates `llms.txt` files and other LLM-friendly documentation files during +production builds, i.e., when executing the `vuepress build` command, and outputs them to the `.vuepress/dist` directory. + +::: + +[View the complete feature description in the **Plugin Official Documentation**](https://ecosystem.vuejs.press/plugins/ai/llms.html#%E6%8F%92%E4%BB%B6%E5%8A%9F%E8%83%BD){.read-more} + +## Configuration + +This feature is not enabled by default in the theme. You can enable it through the `llmstxt` configuration option: + +```ts title=".vuepress/config.ts" +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + // Use the theme's built-in default configuration + // llmstxt: true, + + // Use custom configuration + llmstxt: { + locale: '/', + // ...other configurations + }, + + // Can also configure via `plugins.llmstxt`, but not recommended + plugins: { + llmstxt: true + } + }), +}) +``` + +[View the complete configuration options in the **Plugin Official Documentation**](https://ecosystem.vuejs.press/plugins/ai/llms.html#options){.read-more} + +## Components + +To further enhance interaction between your documentation site and LLMs, +you can add the `` component to your documentation site. + +This component is not built-in but is implemented as an additional feature of the theme. +Therefore, you need to manually import it and place it in an appropriate location through [component slots](../../guide/custom/slots.md): + +```ts title=".vuepress/client.ts" +import { defineAsyncComponent, h } from 'vue' +import { Layout } from 'vuepress-theme-plume/client' +import PageContextMenu from 'vuepress-theme-plume/features/PageContextMenu.vue' // [!code ++] +import { defineClientConfig } from 'vuepress/client' + +export default defineClientConfig({ + layouts: { + Layout: h(Layout, null, { + // Add PageContextMenu to the doc-title-after slot, i.e., to the right of the article title + 'doc-title-after': () => h(PageContextMenu), // [!code ++] + }), + }, +}) +``` + +You can experience this component's functionality to the right of the current page's title. + +::: important +This component relies entirely on the `@vuepress/plugin-llms` plugin and can only be used when you have enabled this plugin's functionality. + +Therefore, the functionality provided by this component **is only available in the built production package**. +::: diff --git a/docs/en/guide/custom/slots.md b/docs/en/guide/custom/slots.md index e0ff2741..b88c04e6 100644 --- a/docs/en/guide/custom/slots.md +++ b/docs/en/guide/custom/slots.md @@ -106,6 +106,8 @@ You can preview to see the positions of - `doc-footer-before` - `doc-before` - `doc-after` + - `doc-title-before` + - `doc-title-after` - `doc-meta-top` - `doc-meta-bottom` - `doc-meta-before` diff --git a/docs/guide/custom/slots.md b/docs/guide/custom/slots.md index 1e13d5dc..b674cec2 100644 --- a/docs/guide/custom/slots.md +++ b/docs/guide/custom/slots.md @@ -104,6 +104,8 @@ export default defineClientConfig({ - `doc-footer-before` - `doc-before` - `doc-after` + - `doc-title-before` + - `doc-title-after` - `doc-meta-top` - `doc-meta-bottom` - `doc-meta-before` diff --git a/docs/package.json b/docs/package.json index 75915694..b61fe7d3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -19,7 +19,6 @@ "@lunariajs/core": "catalog:dev", "@simonwep/pickr": "catalog:dev", "@vuepress/bundler-vite": "catalog:vuepress", - "@vuepress/plugin-llms": "catalog:vuepress", "@vuepress/shiki-twoslash": "catalog:vuepress", "chart.js": "catalog:prod", "echarts": "catalog:prod", diff --git a/examples/layout-slots/docs/.vuepress/client.ts b/examples/layout-slots/docs/.vuepress/client.ts index 3e42fd49..7ef9f965 100644 --- a/examples/layout-slots/docs/.vuepress/client.ts +++ b/examples/layout-slots/docs/.vuepress/client.ts @@ -30,6 +30,8 @@ export default defineClientConfig({ 'doc-footer-before': () => h(SlotDemo, { name: 'doc-footer-before' }), 'doc-before': () => h(SlotDemo, { name: 'doc-before', mt: 16 }), 'doc-after': () => h(SlotDemo, { name: 'doc-after' }), + 'doc-title-before': () => h(SlotDemo, { name: 'doc-title-before', h: 24 }), + 'doc-title-after': () => h(SlotDemo, { name: 'doc-title-after', h: 24 }), 'doc-meta-before': () => h(SlotDemo, { name: 'doc-meta-before', h: 24 }), 'doc-meta-after': () => h(SlotDemo, { name: 'doc-meta-after', h: 24 }), 'doc-meta-top': () => h(SlotDemo, { name: 'doc-meta-top' }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a141ac..fe925a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,9 +551,6 @@ importers: '@vuepress/bundler-vite': specifier: catalog:vuepress version: 2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1) - '@vuepress/plugin-llms': - specifier: catalog:vuepress - version: 2.0.0-rc.118(typescript@5.9.3)(vuepress@2.0.0-rc.26(@vuepress/bundler-vite@2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) '@vuepress/shiki-twoslash': specifier: catalog:vuepress version: 2.0.0-rc.118(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))(vuepress@2.0.0-rc.26(@vuepress/bundler-vite@2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) @@ -817,6 +814,9 @@ importers: '@vuepress/plugin-git': specifier: catalog:vuepress version: 2.0.0-rc.118(typescript@5.9.3)(vuepress@2.0.0-rc.26(@vuepress/bundler-vite@2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) + '@vuepress/plugin-llms': + specifier: catalog:vuepress + version: 2.0.0-rc.118(typescript@5.9.3)(vuepress@2.0.0-rc.26(@vuepress/bundler-vite@2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) '@vuepress/plugin-markdown-chart': specifier: catalog:vuepress version: 2.0.0-rc.118(chart.js@4.5.1)(echarts@6.0.0)(flowchart.ts@3.0.1)(markdown-it@14.1.0)(markmap-lib@0.18.12(markmap-common@0.18.9))(markmap-toolbar@0.18.12(markmap-common@0.18.9))(markmap-view@0.18.12(markmap-common@0.18.9))(mermaid@11.12.1)(typescript@5.9.3)(vuepress@2.0.0-rc.26(@vuepress/bundler-vite@2.0.0-rc.26(@types/node@24.10.1)(jiti@2.5.1)(less@4.4.2)(sass-embedded@1.93.3)(sass@1.94.0)(stylus@0.64.0)(typescript@5.9.3)(yaml@2.8.1))(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3))) diff --git a/theme/package.json b/theme/package.json index 026db61a..a460eb66 100644 --- a/theme/package.json +++ b/theme/package.json @@ -113,6 +113,7 @@ "@vuepress/plugin-copy-code": "catalog:vuepress", "@vuepress/plugin-docsearch": "catalog:vuepress", "@vuepress/plugin-git": "catalog:vuepress", + "@vuepress/plugin-llms": "catalog:vuepress", "@vuepress/plugin-markdown-chart": "catalog:vuepress", "@vuepress/plugin-markdown-hint": "catalog:vuepress", "@vuepress/plugin-markdown-image": "catalog:vuepress", diff --git a/theme/src/client/components/VPContent.vue b/theme/src/client/components/VPContent.vue index c95e06f0..bd1c7843 100644 --- a/theme/src/client/components/VPContent.vue +++ b/theme/src/client/components/VPContent.vue @@ -147,6 +147,12 @@ watch( + + diff --git a/theme/src/client/components/VPDoc.vue b/theme/src/client/components/VPDoc.vue index 624290b4..c2bdaddf 100644 --- a/theme/src/client/components/VPDoc.vue +++ b/theme/src/client/components/VPDoc.vue @@ -122,6 +122,12 @@ watch( + + diff --git a/theme/src/client/components/VPDocMeta.vue b/theme/src/client/components/VPDocMeta.vue index 27b036b6..adc430bf 100644 --- a/theme/src/client/components/VPDocMeta.vue +++ b/theme/src/client/components/VPDocMeta.vue @@ -53,11 +53,15 @@ const hasMeta = computed(() => diff --git a/theme/src/client/layouts/Layout.vue b/theme/src/client/layouts/Layout.vue index 19230108..bcf9233a 100644 --- a/theme/src/client/layouts/Layout.vue +++ b/theme/src/client/layouts/Layout.vue @@ -100,6 +100,12 @@ useCloseSidebarOnEscape(isSidebarOpen, closeSidebar) + + diff --git a/theme/src/node/config/resolveThemeData.ts b/theme/src/node/config/resolveThemeData.ts index 4649455f..4e4b5f11 100644 --- a/theme/src/node/config/resolveThemeData.ts +++ b/theme/src/node/config/resolveThemeData.ts @@ -28,6 +28,7 @@ const EXCLUDE_LIST: (keyof ThemeOptions)[] = [ 'watermark', 'readingTime', 'copyCode', + 'llmstxt', ] // 过滤不需要出现在多语言配置中的字段 const EXCLUDE_LOCALE_LIST: (keyof ThemeOptions)[] = [...EXCLUDE_LIST, 'blog', 'appearance'] diff --git a/theme/src/node/detector/fields.ts b/theme/src/node/detector/fields.ts index 8a056432..799cb351 100644 --- a/theme/src/node/detector/fields.ts +++ b/theme/src/node/detector/fields.ts @@ -22,6 +22,7 @@ export const PLUGINS_SUPPORTED_FIELDS: (keyof ThemeBuiltinPlugins)[] = [ 'readingTime', 'watermark', 'replaceAssets', + 'llmstxt', ] export const MARKDOWN_CHART_FIELDS: (keyof MarkdownChartPluginOptions)[] = [ diff --git a/theme/src/node/locales/de.ts b/theme/src/node/locales/de.ts index 1c6660d0..939bebe3 100644 --- a/theme/src/node/locales/de.ts +++ b/theme/src/node/locales/de.ts @@ -51,6 +51,16 @@ export const deLocale: ThemeLocaleText = { message: 'Unterstützt von VuePress & vuepress-theme-plume', }, + + copyPageText: 'Seite kopieren', + copiedPageText: 'Kopieren !', + copingPageText: 'Wird kopiert..', + copyTagline: 'Seite als Markdown für LLMs kopieren', + viewMarkdown: 'Als Markdown anzeigen', + viewMarkdownTagline: 'Diese Seite als Nur-Text anzeigen', + askAIText: 'In {name} öffnen', + askAITagline: '{name} zu dieser Seite befragen', + askAIMessage: 'Lese {link} und beantworte Fragen zum Inhalt.', } export const dePresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/en.ts b/theme/src/node/locales/en.ts index c8ad2606..edb39570 100644 --- a/theme/src/node/locales/en.ts +++ b/theme/src/node/locales/en.ts @@ -38,6 +38,16 @@ export const enLocale: ThemeLocaleText = { message: 'Powered by VuePress & vuepress-theme-plume', }, + + copyPageText: 'Copy page', + copiedPageText: 'Copied !', + copingPageText: 'Copying..', + copyTagline: 'Copy page as Markdown for LLMs', + viewMarkdown: 'View as Markdown', + viewMarkdownTagline: 'View this page as plain text', + askAIText: 'Open in {name}', + askAITagline: 'Ask {name} about this page', + askAIMessage: 'Read {link} and answer content-related questions.', } export const enPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/fr.ts b/theme/src/node/locales/fr.ts index bd22964a..fe6ca9c7 100644 --- a/theme/src/node/locales/fr.ts +++ b/theme/src/node/locales/fr.ts @@ -51,6 +51,16 @@ export const frLocale: ThemeLocaleText = { message: 'Propulsé par VuePress & vuepress-theme-plume', }, + + copyPageText: 'Copier la page', + copiedPageText: 'Copie réussie', + copingPageText: 'Copie en cours..', + copyTagline: 'Copier la page au format Markdown pour une utilisation avec des LLM', + viewMarkdown: 'Voir en Markdown', + viewMarkdownTagline: 'Voir cette page en texte brut', + askAIText: 'Ouvrir dans {name}', + askAITagline: 'Interroger {name} sur cette page', + askAIMessage: 'Lisez {link} et répondez aux questions concernant son contenu.', } export const frPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/ja.ts b/theme/src/node/locales/ja.ts index 04074381..598453bf 100644 --- a/theme/src/node/locales/ja.ts +++ b/theme/src/node/locales/ja.ts @@ -51,6 +51,16 @@ export const jaLocale: ThemeLocaleText = { message: 'VuePress & vuepress-theme-plume によって提供されています', }, + + copyPageText: 'ページをコピー', + copiedPageText: 'コピーしました', + copingPageText: 'コピー中..', + copyTagline: 'ページをMarkdown形式でコピーしてLLMで使用', + viewMarkdown: 'Markdown形式で表示', + viewMarkdownTagline: 'このページをプレーンテキストで表示', + askAIText: '{name} で開く', + askAITagline: 'このページについて {name} に質問する', + askAIMessage: '{link} を読み、内容に関する質問に答えてください。', } export const jaPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/ko.ts b/theme/src/node/locales/ko.ts index b9bb11cd..7bcf9d54 100644 --- a/theme/src/node/locales/ko.ts +++ b/theme/src/node/locales/ko.ts @@ -51,6 +51,16 @@ export const koLocale: ThemeLocaleText = { message: 'Powered by VuePress & vuepress-theme-plume', }, + + copyPageText: '페이지 복사', + copiedPageText: '복사 완료', + copingPageText: '복사 중..', + copyTagline: '페이지를 마크다운 형식으로 복사하여 LLM에서 사용', + viewMarkdown: 'Markdown 형식으로 보기', + viewMarkdownTagline: '이 페이지를 일반 텍스트로 보기', + askAIText: '{name} 에서 열기', + askAITagline: '이 페이지에 대해 {name} 에 질문하기', + askAIMessage: '{link} 을(를) 읽고 내용과 관련된 질문에 답변해 주세요.', } export const koPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/ru.ts b/theme/src/node/locales/ru.ts index 42300a04..dbce92c5 100644 --- a/theme/src/node/locales/ru.ts +++ b/theme/src/node/locales/ru.ts @@ -51,6 +51,16 @@ export const ruLocale: ThemeLocaleText = { message: 'Работает на VuePress & vuepress-theme-plume', }, + + copyPageText: 'Копировать страницу', + copiedPageText: 'Скопировано успешно', + copingPageText: 'Копируется...', + copyTagline: 'Скопировать страницу в формате Markdown для использования в LLM', + viewMarkdown: 'Просмотреть в Markdown', + viewMarkdownTagline: 'Просмотреть эту страницу в виде простого текста', + askAIText: 'Открыть в {name}', + askAITagline: 'Спросить {name} об этой странице', + askAIMessage: 'Прочитайте {link} и ответьте на вопросы, связанные с содержанием.', } export const ruPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/zh-tw.ts b/theme/src/node/locales/zh-tw.ts index f19cd426..6734410c 100644 --- a/theme/src/node/locales/zh-tw.ts +++ b/theme/src/node/locales/zh-tw.ts @@ -51,6 +51,16 @@ export const zhTwLocale: ThemeLocaleText = { message: 'Powered by VuePress & vuepress-theme-plume', }, + + copyPageText: '複製頁面', + copiedPageText: '複製成功', + copingPageText: '複製中..', + copyTagline: '將頁面以 Markdown 格式複製供 LLMs 使用', + viewMarkdown: '以 Markdown 格式檢視', + viewMarkdownTagline: '以純文字檢視此頁面', + askAIText: '在 {name} 中開啟', + askAITagline: '向 {name} 提問有關此頁面', + askAIMessage: '閱讀 {link} 並回答內容相關的問題。', } export const zhTwPresetLocale: PresetLocale = { diff --git a/theme/src/node/locales/zh.ts b/theme/src/node/locales/zh.ts index 0de83380..a0dd04b7 100644 --- a/theme/src/node/locales/zh.ts +++ b/theme/src/node/locales/zh.ts @@ -50,6 +50,16 @@ export const zhLocale: ThemeLocaleText = { message: 'Powered by VuePress & vuepress-theme-plume', }, + + copyPageText: '复制页面', + copiedPageText: '复制成功', + copingPageText: '复制中..', + copyTagline: '将页面以 Markdown 格式复制供 LLMs 使用', + viewMarkdown: '以 Markdown 格式查看', + viewMarkdownTagline: '以纯文本查看此页面', + askAIText: '在 {name} 中打开', + askAITagline: '向 {name} 提问有关此页面', + askAIMessage: '阅读 {link} 并回答内容相关的问题。', } export const zhPresetLocale: PresetLocale = { diff --git a/theme/src/node/plugins/llms.ts b/theme/src/node/plugins/llms.ts new file mode 100644 index 00000000..e02453fd --- /dev/null +++ b/theme/src/node/plugins/llms.ts @@ -0,0 +1,142 @@ +import type { LLMPage, LlmsPluginOptions, LLMState } from '@vuepress/plugin-llms' +import type { App, PluginConfig } from 'vuepress' +import type { ThemeSidebarItem } from '../../shared/index.js' +import { generateTOCLink as rawGenerateTOCLink, llmsPlugin as rawLlmsPlugin } from '@vuepress/plugin-llms' +import { ensureEndingSlash, ensureLeadingSlash, isPlainObject } from 'vuepress/shared' +import { getThemeConfig } from '../loadConfig/index.js' +import { withBase } from '../utils/index.js' + +export function llmsPlugin(app: App, userOptions: true | LlmsPluginOptions): PluginConfig { + if (!app.env.isBuild) + return [] + + const { llmsTxtTemplateGetter, ...userLLMsTxt } = isPlainObject(userOptions) ? userOptions : {} + + function tocGetter(llmPages: LLMPage[], llmState: LLMState): string { + const options = getThemeConfig() + const { currentLocale } = llmState + const collections = options.locales?.[currentLocale]?.collections || [] + + if (!collections.length) + return '' + + let tableOfContent = '' + const usagePages: LLMPage[] = [] + + collections + .filter(item => item.type === 'post') + .forEach(({ title, linkPrefix, link }) => { + tableOfContent += `### ${title}\n\n` + const withLinkPrefix = genStarsWith(linkPrefix, currentLocale) + const withLink = genStarsWith(link, currentLocale) + const withFallback = genStarsWith('/article/', currentLocale) + const list: string[] = [] + llmPages.forEach((page) => { + if (withLinkPrefix(page.path) || withLink(page.path) || withFallback(page.path)) { + usagePages.push(page) + list.push(rawGenerateTOCLink(page, llmState)) + } + }) + tableOfContent += `${list.filter(Boolean).join('')}\n` + }) + + const generateTOCLink = (path: string): string => { + const filepath = path.endsWith('/') ? `${path}README.md` : path.endsWith('.md') ? path : `${path || 'README'}.md` + const link = path.endsWith('/') ? `${path}index.html` : `${path}.html` + const page = llmPages.find((item) => { + return ensureLeadingSlash(item.filePathRelative || '') === filepath || link === item.path + }) + + if (page) { + usagePages.push(page) + return rawGenerateTOCLink(page, llmState) + } + return '' + } + + const processAutoSidebar = (prefix: string): string[] => { + const list: string[] = [] + llmPages.forEach((page) => { + if (ensureLeadingSlash(page.filePathRelative || '').startsWith(prefix)) { + usagePages.push(page) + list.push(rawGenerateTOCLink(page, llmState)) + } + }) + return list.filter(Boolean) + } + + const processSidebar = (items: (string | ThemeSidebarItem)[], prefix: string): string[] => { + const result: string[] = [] + items.forEach((item) => { + if (typeof item === 'string') { + result.push(generateTOCLink(normalizePath(prefix, item))) + } + else { + if (item.link) { + result.push(generateTOCLink(normalizePath(prefix, item.link))) + } + if (item.items === 'auto') { + result.push(...processAutoSidebar(normalizePath(prefix, item.prefix))) + } + else if (item.items?.length) { + result.push(...processSidebar(item.items, normalizePath(prefix, item.prefix))) + } + } + }) + return result + } + + // Collections + collections + .filter(collection => collection.type === 'doc') + .forEach(({ dir, title, sidebar = [] }) => { + tableOfContent += `### ${title}\n\n` + const prefix = normalizePath(ensureLeadingSlash(withBase(dir, currentLocale))) + if (sidebar === 'auto') { + tableOfContent += `${processAutoSidebar(prefix).join('')}\n` + } + else if (sidebar.length) { + const home = generateTOCLink(ensureEndingSlash(prefix)) + const list = processSidebar(sidebar, prefix) + if (home && !list.includes(home)) { + list.unshift(home) + } + tableOfContent += `${list.join('')}\n` + } + }) + + // Others + const unUsagePages = llmPages.filter(page => !usagePages.includes(page)) + if (unUsagePages.length) { + tableOfContent += '### Others\n\n' + tableOfContent += unUsagePages + .map(page => rawGenerateTOCLink(page, llmState)) + .join('') + } + + return tableOfContent + } + + return [rawLlmsPlugin({ + ...userLLMsTxt, + llmsTxtTemplateGetter: { + toc: tocGetter, + ...llmsTxtTemplateGetter, + }, + })] +} + +function genStarsWith(stars: string | undefined, locale: string) { + return (url: string): boolean => { + if (!stars) + return false + return url.startsWith(withBase(stars, locale)) + } +} + +function normalizePath(prefix: string, path = ''): string { + if (path.startsWith('/')) + return path + + return `${ensureEndingSlash(prefix)}${path}` +} diff --git a/theme/src/node/plugins/setupPlugins.ts b/theme/src/node/plugins/setupPlugins.ts index e85346be..b39ddb23 100644 --- a/theme/src/node/plugins/setupPlugins.ts +++ b/theme/src/node/plugins/setupPlugins.ts @@ -18,6 +18,7 @@ import { watermarkPlugin } from '@vuepress/plugin-watermark' import { getThemeConfig } from '../loadConfig/index.js' import { codePlugins } from './code.js' import { gitPlugin } from './git.js' +import { llmsPlugin } from './llms.js' import { markdownPlugins } from './markdown.js' export function setupPlugins( @@ -114,6 +115,11 @@ export function setupPlugins( plugins.push(replaceAssetsPlugin(replaceAssets)) } + const llmstxt = options.llmstxt ?? pluginOptions.llmstxt + if (llmstxt) { + plugins.push(...llmsPlugin(app, llmstxt)) + } + /** * 站点地图,仅在生产构建时,且 hostname 存在时生效 */ diff --git a/theme/src/shared/locale.ts b/theme/src/shared/locale.ts index 07bc3d13..fa4f2e2c 100644 --- a/theme/src/shared/locale.ts +++ b/theme/src/shared/locale.ts @@ -396,4 +396,14 @@ export interface ThemeLocaleText { * 加密时输入框的 placeholder */ encryptPlaceholder?: string + + copyPageText?: string + copiedPageText?: string + copingPageText?: string + copyTagline?: string + viewMarkdown?: string + viewMarkdownTagline?: string + askAIText?: string + askAITagline?: string + askAIMessage?: string } diff --git a/theme/src/shared/options.ts b/theme/src/shared/options.ts index 8fab8460..7e09726d 100644 --- a/theme/src/shared/options.ts +++ b/theme/src/shared/options.ts @@ -1,6 +1,7 @@ import type { CommentPluginOptions } from '@vuepress/plugin-comment' import type { CopyCodePluginOptions } from '@vuepress/plugin-copy-code' import type { ChangelogOptions, ContributorsOptions } from '@vuepress/plugin-git' +import type { LlmsPluginOptions } from '@vuepress/plugin-llms' import type { ReadingTimePluginOptions } from '@vuepress/plugin-reading-time' import type { ReplaceAssetsPluginOptions } from '@vuepress/plugin-replace-assets' import type { ShikiPluginOptions } from '@vuepress/plugin-shiki' @@ -94,7 +95,10 @@ export interface ThemeFeatureOptions { /** * 站点搜索配置 * - * @default { provider: 'local' } + * @default + * ``` + * { provider: 'local' } + * ``` */ search?: boolean | SearchOptions @@ -132,4 +136,9 @@ export interface ThemeFeatureOptions { * 资源链接替换 */ replaceAssets?: false | ReplaceAssetsPluginOptions + + /** + * llmstxt 配置 + */ + llmstxt?: boolean | LlmsPluginOptions } diff --git a/theme/src/shared/plugins.ts b/theme/src/shared/plugins.ts index 016d5561..f8c8b1fa 100644 --- a/theme/src/shared/plugins.ts +++ b/theme/src/shared/plugins.ts @@ -3,6 +3,7 @@ import type { CachePluginOptions } from '@vuepress/plugin-cache' import type { CommentPluginOptions } from '@vuepress/plugin-comment' import type { CopyCodePluginOptions } from '@vuepress/plugin-copy-code' import type { DocSearchOptions } from '@vuepress/plugin-docsearch' +import type { LlmsPluginOptions } from '@vuepress/plugin-llms' import type { MarkdownChartPluginOptions } from '@vuepress/plugin-markdown-chart' import type { MarkdownImagePluginOptions } from '@vuepress/plugin-markdown-image' import type { MarkdownIncludePluginOptions } from '@vuepress/plugin-markdown-include' @@ -130,4 +131,9 @@ export interface ThemeBuiltinPlugins { * 资源链接替换 */ replaceAssets?: false | ReplaceAssetsPluginOptions + + /** + * llmstxt 配置 + */ + llmstxt?: boolean | LlmsPluginOptions }