@lupinum/ginko-docs 0.3.0 → 0.4.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/app/app.config.ts +2 -0
- package/app/app.vue +26 -2
- package/app/assets/css/tailwind.css +24 -0
- package/app/assets/css/theme-presets.css +141 -0
- package/app/components/OgImage/GinkoDocs.satori.vue +131 -10
- package/app/components/site/SiteBanner.vue +2 -2
- package/app/components/site/SiteFooter.vue +1 -1
- package/app/components/site/SiteHeader.vue +1 -1
- package/app/components/site/SiteSocialLinks.vue +9 -1
- package/app/components/ui/badge/index.ts +1 -1
- package/app/components/ui/button/index.ts +1 -1
- package/app/composables/site-navigation.utils.ts +3 -7
- package/app/composables/useLocalizedPath.ts +3 -2
- package/app/error.vue +18 -8
- package/app/features/docs/components/DocsPageContent.vue +2 -2
- package/app/features/docs/components/DocsSidebarItem.vue +2 -2
- package/app/features/docs/components/DocsSidebarList.vue +1 -1
- package/app/features/docs/components/DocsToc.vue +2 -2
- package/app/features/search/command-center.ts +33 -0
- package/app/features/search/components/SiteCommandCenter.vue +25 -9
- package/app/features/search/components/SiteSearchPageHighlight.vue +4 -2
- package/app/features/search/useCommandCenter.ts +2 -2
- package/app/layouts/docs.vue +1 -1
- package/app/pages/index.vue +22 -3
- package/app/plugins/ginko-docs-theme.ts +1 -0
- package/content-collections.ts +13 -5
- package/content.js +16 -10
- package/content.ts +25 -8
- package/i18n/messages/global/command.ts +1 -0
- package/icon-bundle.ts +24 -4
- package/index.d.ts +1 -0
- package/modules/feature-routing.ts +47 -22
- package/modules/syntax-highlighting.ts +5 -0
- package/nuxt.config.ts +7 -7
- package/package.json +9 -4
- package/runtime/generate-favicons.mjs +124 -0
- package/server/plugins/sitemap-policy.ts +19 -0
- package/server/routes/.well-known/ai-catalog.json.get.ts +10 -0
- package/server/routes/[locale]/blog/rss.xml.get.ts +6 -1
- package/server/routes/blog/rss.xml.get.ts +10 -2
- package/server/routes/mcp/server-card.get.ts +14 -0
- package/server/utils/blog-feed.ts +24 -6
- package/server/utils/mcp-server-card.ts +45 -0
- package/server/utils/redirects.ts +10 -3
- package/server/utils/redirects.utils.ts +10 -7
- package/server/utils/sitemap.ts +50 -0
- package/shared/theme-presets.ts +3 -0
- package/shared/types/app-config.ts +20 -2
- package/shared/types/nuxt-config.ts +3 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineNuxtModule, installModule } from "@nuxt/kit";
|
|
2
2
|
import type {} from "@lupinum/ginko-content";
|
|
3
|
+
import type { LocaleCode } from "../i18n/locales";
|
|
3
4
|
import { defaultLocale, localeCodes, locales, localizedPath } from "../i18n/locales";
|
|
4
5
|
import { routeSlugs } from "../shared/route-slugs";
|
|
5
6
|
|
|
@@ -7,9 +8,39 @@ interface PageRoute {
|
|
|
7
8
|
path: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
(
|
|
12
|
-
|
|
11
|
+
function configuredLocaleCodes(configuredLocales: unknown) {
|
|
12
|
+
if (!Array.isArray(configuredLocales)) return new Set<string>();
|
|
13
|
+
return new Set(
|
|
14
|
+
configuredLocales.flatMap((locale) => {
|
|
15
|
+
if (typeof locale === "string") return [locale];
|
|
16
|
+
return locale && typeof locale === "object" && typeof locale.code === "string"
|
|
17
|
+
? [locale.code]
|
|
18
|
+
: [];
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function removeInactivePageLocales(
|
|
24
|
+
pages: Record<string, unknown> | undefined,
|
|
25
|
+
activeLocales: ReadonlySet<string>,
|
|
26
|
+
) {
|
|
27
|
+
if (!pages || activeLocales.size === 0) return;
|
|
28
|
+
|
|
29
|
+
for (const routeMap of Object.values(pages)) {
|
|
30
|
+
if (!routeMap || typeof routeMap !== "object" || Array.isArray(routeMap)) continue;
|
|
31
|
+
for (const locale of Object.keys(routeMap)) {
|
|
32
|
+
if (!activeLocales.has(locale)) delete (routeMap as Record<string, unknown>)[locale];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const blogFeedRoutes = (
|
|
38
|
+
primaryLocale: LocaleCode = defaultLocale,
|
|
39
|
+
activeLocales: ReadonlySet<string> = new Set(localeCodes),
|
|
40
|
+
) =>
|
|
41
|
+
localeCodes
|
|
42
|
+
.filter((locale) => activeLocales.has(locale))
|
|
43
|
+
.map((locale) => `${localizedPath(locale, routeSlugs.blog[locale], primaryLocale)}/rss.xml`);
|
|
13
44
|
|
|
14
45
|
export function removeBlogPages(pages: PageRoute[], blogEnabled: boolean) {
|
|
15
46
|
if (blogEnabled) return;
|
|
@@ -25,25 +56,19 @@ export function removeBlogPages(pages: PageRoute[], blogEnabled: boolean) {
|
|
|
25
56
|
export default defineNuxtModule({
|
|
26
57
|
meta: { name: "ginko-docs-feature-routing" },
|
|
27
58
|
async setup(_options, nuxt) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
36
|
-
} else if (configuredLocales.length > 1) {
|
|
37
|
-
const fallbackIndex = configuredLocales.findIndex(
|
|
38
|
-
(locale) =>
|
|
39
|
-
typeof locale === "object" &&
|
|
40
|
-
locale !== null &&
|
|
41
|
-
locale.code === fallback.code &&
|
|
42
|
-
locale.language === fallback.language &&
|
|
43
|
-
locale.name === fallback.name,
|
|
44
|
-
);
|
|
45
|
-
if (fallbackIndex >= 0) configuredLocales.splice(fallbackIndex, 1);
|
|
59
|
+
const ginkoDocs = nuxt.options.ginkoDocs;
|
|
60
|
+
const primaryLocale =
|
|
61
|
+
ginkoDocs && typeof ginkoDocs === "object"
|
|
62
|
+
? (ginkoDocs.primaryLocale ?? defaultLocale)
|
|
63
|
+
: defaultLocale;
|
|
64
|
+
if (!locales.some((locale) => locale.code === primaryLocale)) {
|
|
65
|
+
throw new Error(`Missing locale definition for ${primaryLocale}.`);
|
|
46
66
|
}
|
|
67
|
+
const activeLocales = configuredLocaleCodes(nuxt.options.i18n?.locales);
|
|
68
|
+
removeInactivePageLocales(
|
|
69
|
+
nuxt.options.i18n?.pages as Record<string, unknown> | undefined,
|
|
70
|
+
activeLocales,
|
|
71
|
+
);
|
|
47
72
|
await installModule("@nuxtjs/i18n");
|
|
48
73
|
|
|
49
74
|
let blogEnabled = false;
|
|
@@ -62,7 +87,7 @@ export default defineNuxtModule({
|
|
|
62
87
|
if (!blogEnabled) return;
|
|
63
88
|
nitroConfig.prerender ??= {};
|
|
64
89
|
nitroConfig.prerender.routes ??= [];
|
|
65
|
-
nitroConfig.prerender.routes.push(...blogFeedRoutes);
|
|
90
|
+
nitroConfig.prerender.routes.push(...blogFeedRoutes(primaryLocale, activeLocales));
|
|
66
91
|
});
|
|
67
92
|
},
|
|
68
93
|
});
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
GinkoDocsSyntaxHighlightingConfig,
|
|
6
6
|
GinkoDocsNuxtConfig,
|
|
7
7
|
} from "../shared/types/nuxt-config";
|
|
8
|
+
import type { LocaleCode } from "../i18n/locales";
|
|
9
|
+
import { defaultLocale } from "../i18n/locales";
|
|
8
10
|
|
|
9
11
|
export const DEFAULT_SYNTAX_THEMES = {
|
|
10
12
|
light: "light-plus",
|
|
@@ -77,9 +79,11 @@ export async function patchShikiThemes(
|
|
|
77
79
|
export function mergeSyntaxRuntimeConfig(
|
|
78
80
|
current: Record<string, unknown> | undefined,
|
|
79
81
|
themes: GinkoDocsSyntaxHighlightingConfig["themes"],
|
|
82
|
+
primaryLocale: LocaleCode = defaultLocale,
|
|
80
83
|
) {
|
|
81
84
|
return {
|
|
82
85
|
...current,
|
|
86
|
+
primaryLocale,
|
|
83
87
|
syntaxHighlighting: {
|
|
84
88
|
themes: {
|
|
85
89
|
light: themes.light,
|
|
@@ -100,6 +104,7 @@ export default defineNuxtModule<GinkoDocsNuxtConfig>({
|
|
|
100
104
|
nuxt.options.runtimeConfig.public.ginkoDocs = mergeSyntaxRuntimeConfig(
|
|
101
105
|
nuxt.options.runtimeConfig.public.ginkoDocs,
|
|
102
106
|
themes,
|
|
107
|
+
options.primaryLocale,
|
|
103
108
|
);
|
|
104
109
|
|
|
105
110
|
if (!options.syntaxHighlighting) return;
|
package/nuxt.config.ts
CHANGED
|
@@ -7,9 +7,10 @@ import darkPlus from "shiki/dist/themes/dark-plus.mjs";
|
|
|
7
7
|
import lightPlus from "shiki/dist/themes/light-plus.mjs";
|
|
8
8
|
import { contentComponentPolicy, contentComponentTags } from "./tags";
|
|
9
9
|
import { i18nPages } from "./i18n/routes";
|
|
10
|
-
import {
|
|
10
|
+
import { localeCodes, localizedPath } from "./i18n/locales";
|
|
11
11
|
import { routeSlugs } from "./shared/route-slugs";
|
|
12
12
|
import { layerIconCollections, layerIconNames } from "./icon-bundle";
|
|
13
|
+
import packageMetadata from "./package.json" with { type: "json" };
|
|
13
14
|
|
|
14
15
|
const root = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const app = join(root, "app");
|
|
@@ -56,7 +57,7 @@ export default defineNuxtConfig({
|
|
|
56
57
|
},
|
|
57
58
|
mcp: {
|
|
58
59
|
name: "Ginko Docs",
|
|
59
|
-
version:
|
|
60
|
+
version: packageMetadata.version,
|
|
60
61
|
},
|
|
61
62
|
components: {
|
|
62
63
|
dirs: [
|
|
@@ -129,9 +130,6 @@ export default defineNuxtConfig({
|
|
|
129
130
|
customRoutes: "config",
|
|
130
131
|
defaultLocale: "en",
|
|
131
132
|
detectBrowserLanguage: false,
|
|
132
|
-
locales: locales
|
|
133
|
-
.filter((locale) => locale.code === defaultLocale)
|
|
134
|
-
.map(({ code, language, name }) => ({ code, language, name })),
|
|
135
133
|
pages: i18nPages,
|
|
136
134
|
strategy: "prefix_except_default",
|
|
137
135
|
vueI18n: join(root, "i18n/i18n.config.ts"),
|
|
@@ -163,13 +161,15 @@ export default defineNuxtConfig({
|
|
|
163
161
|
},
|
|
164
162
|
search: { engine: "minisearch" },
|
|
165
163
|
sitemap: true,
|
|
166
|
-
agent: { routes: true, linkHeaders: true,
|
|
164
|
+
agent: { routes: true, linkHeaders: true, delivery: "static" },
|
|
167
165
|
},
|
|
168
166
|
sitemap: {
|
|
169
167
|
excludeAppSources: ["nuxt:prerender"],
|
|
170
168
|
// The docs roots prerender as redirects to the first docs page; a sitemap
|
|
171
169
|
// must not list redirecting URLs.
|
|
172
|
-
exclude: localeCodes.
|
|
170
|
+
exclude: localeCodes.flatMap((primaryLocale) =>
|
|
171
|
+
localeCodes.map((locale) => localizedPath(locale, routeSlugs.docs[locale], primaryLocale)),
|
|
172
|
+
),
|
|
173
173
|
},
|
|
174
174
|
app: {
|
|
175
175
|
head: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lupinum/ginko-docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.10",
|
|
4
4
|
"description": "A Nuxt documentation layer powered by Ginko Content.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"content",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"url": "git+https://github.com/lupinum-dev/ginko-docs.git",
|
|
21
21
|
"directory": "layer"
|
|
22
22
|
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"ginko-docs-favicons": "./runtime/generate-favicons.mjs"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"app",
|
|
25
28
|
"i18n",
|
|
@@ -74,21 +77,23 @@
|
|
|
74
77
|
"@vueuse/core": "^14.4.0",
|
|
75
78
|
"class-variance-authority": "^0.7.1",
|
|
76
79
|
"clsx": "^2.1.1",
|
|
80
|
+
"h3": "1.15.11",
|
|
77
81
|
"motion-v": "^2.3.0",
|
|
78
82
|
"nitropack": "^2.13.4",
|
|
79
83
|
"nuxt-og-image": "^6.7.8",
|
|
80
84
|
"reka-ui": "^2.10.3",
|
|
81
85
|
"satori": "^0.19.3",
|
|
86
|
+
"sharp": "^0.35.4",
|
|
82
87
|
"shiki": "^4.4.3",
|
|
83
88
|
"tailwind-merge": "^3.6.0",
|
|
84
89
|
"tailwindcss": "^4.3.3",
|
|
85
90
|
"tw-animate-css": "^1.4.0",
|
|
86
|
-
"zod": "
|
|
91
|
+
"zod": "4.4.3"
|
|
87
92
|
},
|
|
88
93
|
"peerDependencies": {
|
|
89
|
-
"@lupinum/ginko-content": ">=0.
|
|
94
|
+
"@lupinum/ginko-content": ">=1.0.0-beta.7 <2.0.0",
|
|
90
95
|
"nuxt": ">=4.5.1 <5",
|
|
91
|
-
"vue": "^3.5.
|
|
96
|
+
"vue": "^3.5.40",
|
|
92
97
|
"vue-router": "^5.1.0"
|
|
93
98
|
},
|
|
94
99
|
"engines": {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { extname, join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
|
|
8
|
+
const ICO_SIZES = [16, 32, 48];
|
|
9
|
+
|
|
10
|
+
function createIco(images) {
|
|
11
|
+
const headerSize = 6;
|
|
12
|
+
const entrySize = 16;
|
|
13
|
+
const directory = Buffer.alloc(headerSize + entrySize * images.length);
|
|
14
|
+
|
|
15
|
+
directory.writeUInt16LE(0, 0);
|
|
16
|
+
directory.writeUInt16LE(1, 2);
|
|
17
|
+
directory.writeUInt16LE(images.length, 4);
|
|
18
|
+
|
|
19
|
+
let imageOffset = directory.length;
|
|
20
|
+
images.forEach(({ size, data }, index) => {
|
|
21
|
+
const entryOffset = headerSize + entrySize * index;
|
|
22
|
+
directory.writeUInt8(size === 256 ? 0 : size, entryOffset);
|
|
23
|
+
directory.writeUInt8(size === 256 ? 0 : size, entryOffset + 1);
|
|
24
|
+
directory.writeUInt8(0, entryOffset + 2);
|
|
25
|
+
directory.writeUInt8(0, entryOffset + 3);
|
|
26
|
+
directory.writeUInt16LE(1, entryOffset + 4);
|
|
27
|
+
directory.writeUInt16LE(32, entryOffset + 6);
|
|
28
|
+
directory.writeUInt32LE(data.length, entryOffset + 8);
|
|
29
|
+
directory.writeUInt32LE(imageOffset, entryOffset + 12);
|
|
30
|
+
imageOffset += data.length;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return Buffer.concat([directory, ...images.map(({ data }) => data)]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function renderSquare(source, size) {
|
|
37
|
+
return sharp(source, { density: 512 }).resize(size, size, { fit: "contain" }).png().toBuffer();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function embeddedSvg(png) {
|
|
41
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img">
|
|
42
|
+
<image width="512" height="512" href="data:image/png;base64,${png.toString("base64")}" />
|
|
43
|
+
</svg>
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function generateFavicons({ source, outputDirectory }) {
|
|
48
|
+
const sourcePath = resolve(source);
|
|
49
|
+
const outputPath = resolve(outputDirectory);
|
|
50
|
+
const sourceBuffer = await readFile(sourcePath);
|
|
51
|
+
const metadata = await sharp(sourceBuffer, { density: 512 }).metadata();
|
|
52
|
+
|
|
53
|
+
if (!metadata.width || !metadata.height) {
|
|
54
|
+
throw new Error(`Could not read icon dimensions from ${sourcePath}`);
|
|
55
|
+
}
|
|
56
|
+
if (metadata.width !== metadata.height) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`The favicon source must be square. Received ${metadata.width}x${metadata.height}.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [png96, appleTouchIcon, ...icoPngs] = await Promise.all([
|
|
63
|
+
renderSquare(sourceBuffer, 96),
|
|
64
|
+
renderSquare(sourceBuffer, 180),
|
|
65
|
+
...ICO_SIZES.map(async (size) => ({ size, data: await renderSquare(sourceBuffer, size) })),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const svg =
|
|
69
|
+
extname(sourcePath).toLowerCase() === ".svg"
|
|
70
|
+
? sourceBuffer
|
|
71
|
+
: Buffer.from(embeddedSvg(await renderSquare(sourceBuffer, 512)));
|
|
72
|
+
|
|
73
|
+
await mkdir(outputPath, { recursive: true });
|
|
74
|
+
await Promise.all([
|
|
75
|
+
writeFile(join(outputPath, "favicon.svg"), svg),
|
|
76
|
+
writeFile(join(outputPath, "favicon-96x96.png"), png96),
|
|
77
|
+
writeFile(join(outputPath, "apple-touch-icon.png"), appleTouchIcon),
|
|
78
|
+
writeFile(join(outputPath, "favicon.ico"), createIco(icoPngs)),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
return ["favicon.svg", "favicon-96x96.png", "apple-touch-icon.png", "favicon.ico"];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseArguments(arguments_) {
|
|
85
|
+
const options = {
|
|
86
|
+
source: "public/favicon.svg",
|
|
87
|
+
outputDirectory: "public",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
91
|
+
const argument = arguments_[index];
|
|
92
|
+
if (argument === "--source") options.source = arguments_[++index];
|
|
93
|
+
else if (argument === "--out") options.outputDirectory = arguments_[++index];
|
|
94
|
+
else if (argument === "--help" || argument === "-h") return undefined;
|
|
95
|
+
else throw new Error(`Unknown argument: ${argument}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!options.source || !options.outputDirectory) {
|
|
99
|
+
throw new Error("Both --source and --out require a path.");
|
|
100
|
+
}
|
|
101
|
+
return options;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
const options = parseArguments(process.argv.slice(2));
|
|
106
|
+
if (!options) {
|
|
107
|
+
console.log(`Usage: ginko-docs-favicons [--source <path>] [--out <directory>]
|
|
108
|
+
|
|
109
|
+
Defaults:
|
|
110
|
+
--source public/favicon.svg
|
|
111
|
+
--out public`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const files = await generateFavicons(options);
|
|
116
|
+
console.log(`Generated ${files.join(", ")} in ${resolve(options.outputDirectory)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
120
|
+
main().catch((error) => {
|
|
121
|
+
console.error(error instanceof Error ? error.message : error);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineNitroPlugin } from "nitropack/runtime/plugin";
|
|
2
|
+
import { filterSitemapEntries } from "../utils/sitemap";
|
|
3
|
+
import { defaultLocale, isLocaleCode } from "../../i18n/locales";
|
|
4
|
+
|
|
5
|
+
export default defineNitroPlugin((nitro) => {
|
|
6
|
+
nitro.hooks.hook("sitemap:resolved", (context) => {
|
|
7
|
+
const runtimeConfig = useRuntimeConfig(context.event);
|
|
8
|
+
const content = runtimeConfig.content;
|
|
9
|
+
const configuredPrimaryLocale = runtimeConfig.public.ginkoDocs?.primaryLocale;
|
|
10
|
+
context.urls = filterSitemapEntries(context.urls, {
|
|
11
|
+
locales: content.locales,
|
|
12
|
+
blogEnabled: Boolean(content.collections.blog),
|
|
13
|
+
primaryLocale:
|
|
14
|
+
typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
|
|
15
|
+
? configuredPrimaryLocale
|
|
16
|
+
: defaultLocale,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useAppConfig } from "#imports";
|
|
2
|
+
import { createGinkoDocsAiCatalog } from "../../utils/mcp-server-card";
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler((event) => {
|
|
5
|
+
event.node.res.setHeader("access-control-allow-origin", "*");
|
|
6
|
+
event.node.res.setHeader("cache-control", "public, max-age=3600");
|
|
7
|
+
event.node.res.setHeader("content-type", "application/ai-catalog+json; charset=utf-8");
|
|
8
|
+
|
|
9
|
+
return createGinkoDocsAiCatalog(useAppConfig().ginkoDocs.site.url);
|
|
10
|
+
});
|
|
@@ -6,7 +6,12 @@ import { serveBlogFeed } from "../../../utils/blog-feed";
|
|
|
6
6
|
// /de/... node would shadow ginko-content's /:locale/llms.txt routes.
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
8
|
const locale = getRouterParam(event, "locale");
|
|
9
|
-
|
|
9
|
+
const configuredPrimaryLocale = useRuntimeConfig(event).public.ginkoDocs?.primaryLocale;
|
|
10
|
+
const primaryLocale =
|
|
11
|
+
typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
|
|
12
|
+
? configuredPrimaryLocale
|
|
13
|
+
: defaultLocale;
|
|
14
|
+
if (!locale || !isLocaleCode(locale) || locale === primaryLocale) {
|
|
10
15
|
throw createError({ statusCode: 404, statusMessage: "Page not found" });
|
|
11
16
|
}
|
|
12
17
|
return serveBlogFeed(event, locale);
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { defineEventHandler } from "h3";
|
|
2
|
-
import { defaultLocale } from "../../../i18n/locales";
|
|
2
|
+
import { defaultLocale, isLocaleCode } from "../../../i18n/locales";
|
|
3
3
|
import { serveBlogFeed } from "../../utils/blog-feed";
|
|
4
4
|
|
|
5
|
-
export default defineEventHandler((event) =>
|
|
5
|
+
export default defineEventHandler((event) => {
|
|
6
|
+
const configuredPrimaryLocale = useRuntimeConfig(event).public.ginkoDocs?.primaryLocale;
|
|
7
|
+
return serveBlogFeed(
|
|
8
|
+
event,
|
|
9
|
+
typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
|
|
10
|
+
? configuredPrimaryLocale
|
|
11
|
+
: defaultLocale,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useAppConfig } from "#imports";
|
|
2
|
+
import packageMetadata from "../../../package.json" with { type: "json" };
|
|
3
|
+
import { createGinkoDocsServerCard } from "../../utils/mcp-server-card";
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler((event) => {
|
|
6
|
+
event.node.res.setHeader("access-control-allow-origin", "*");
|
|
7
|
+
event.node.res.setHeader("cache-control", "public, max-age=3600");
|
|
8
|
+
event.node.res.setHeader("content-type", "application/mcp-server-card+json; charset=utf-8");
|
|
9
|
+
|
|
10
|
+
return createGinkoDocsServerCard({
|
|
11
|
+
origin: useAppConfig().ginkoDocs.site.url,
|
|
12
|
+
version: packageMetadata.version,
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -3,21 +3,34 @@ import { createError, setHeader } from "h3";
|
|
|
3
3
|
import { many } from "@lupinum/ginko-content/server";
|
|
4
4
|
import { useAppConfig, useRuntimeConfig } from "#imports";
|
|
5
5
|
import { blog } from "../../i18n/messages/global/blog";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
defaultLocale,
|
|
8
|
+
isLocaleCode,
|
|
9
|
+
locales,
|
|
10
|
+
localizedPath,
|
|
11
|
+
type LocaleCode,
|
|
12
|
+
} from "../../i18n/locales";
|
|
7
13
|
import { routeSlugs } from "../../shared/route-slugs";
|
|
8
14
|
import { getLocalizedSiteText } from "../../app/config/site.utils";
|
|
9
15
|
import { createGinkoDocsCollections } from "../../content-collections";
|
|
10
16
|
import { buildRssFeed } from "./feed";
|
|
11
17
|
|
|
12
18
|
export const MAX_FEED_POSTS = 50;
|
|
13
|
-
const { blog: blogCollection, authors: authorsCollection } = createGinkoDocsCollections(
|
|
19
|
+
const { blog: blogCollection, authors: authorsCollection } = createGinkoDocsCollections([
|
|
20
|
+
"en",
|
|
21
|
+
"de",
|
|
22
|
+
]);
|
|
14
23
|
|
|
15
|
-
export function blogFeedPath(
|
|
16
|
-
|
|
24
|
+
export function blogFeedPath(
|
|
25
|
+
locale: LocaleCode,
|
|
26
|
+
primaryLocale: LocaleCode = defaultLocale,
|
|
27
|
+
): string {
|
|
28
|
+
return `${localizedPath(locale, routeSlugs.blog[locale], primaryLocale)}/rss.xml`;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
export async function serveBlogFeed(event: H3Event, locale: LocaleCode) {
|
|
20
|
-
const
|
|
32
|
+
const publicRuntime = useRuntimeConfig(event).public;
|
|
33
|
+
const contentRuntime = publicRuntime.content as
|
|
21
34
|
| { collections?: Record<string, unknown> }
|
|
22
35
|
| undefined;
|
|
23
36
|
if (!contentRuntime?.collections?.blog) {
|
|
@@ -25,6 +38,11 @@ export async function serveBlogFeed(event: H3Event, locale: LocaleCode) {
|
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
const site = useAppConfig().ginkoDocs.site;
|
|
41
|
+
const configuredPrimaryLocale = publicRuntime.ginkoDocs?.primaryLocale;
|
|
42
|
+
const primaryLocale =
|
|
43
|
+
typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
|
|
44
|
+
? configuredPrimaryLocale
|
|
45
|
+
: defaultLocale;
|
|
28
46
|
const posts = await many(event, blogCollection, {
|
|
29
47
|
locale,
|
|
30
48
|
fallback: true,
|
|
@@ -38,7 +56,7 @@ export async function serveBlogFeed(event: H3Event, locale: LocaleCode) {
|
|
|
38
56
|
title: `${blog.title[locale]} - ${getLocalizedSiteText(site.name, locale)}`,
|
|
39
57
|
description: blog.description[locale],
|
|
40
58
|
siteUrl: site.url,
|
|
41
|
-
feedPath: blogFeedPath(locale),
|
|
59
|
+
feedPath: blogFeedPath(locale, primaryLocale),
|
|
42
60
|
language: locales.find((entry) => entry.code === locale)?.language ?? locale,
|
|
43
61
|
items: posts.map((post) => ({
|
|
44
62
|
title: post.title,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface GinkoDocsServerCardOptions {
|
|
2
|
+
origin: string;
|
|
3
|
+
version: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function createGinkoDocsServerCard({ origin, version }: GinkoDocsServerCardOptions) {
|
|
7
|
+
const baseUrl = new URL(origin);
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
$schema: "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
|
|
11
|
+
name: "dev.lupinum/ginko-docs",
|
|
12
|
+
title: "Ginko Docs",
|
|
13
|
+
version,
|
|
14
|
+
description: "Read published Ginko Docs pages through its public MCP tools.",
|
|
15
|
+
websiteUrl: new URL("/docs/features/agent-readable-output", baseUrl).toString(),
|
|
16
|
+
repository: {
|
|
17
|
+
source: "github",
|
|
18
|
+
url: "https://github.com/lupinum-dev/ginko-docs",
|
|
19
|
+
},
|
|
20
|
+
remotes: [
|
|
21
|
+
{
|
|
22
|
+
type: "streamable-http",
|
|
23
|
+
url: new URL("/mcp", baseUrl).toString(),
|
|
24
|
+
supportedProtocolVersions: ["2025-11-25"],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
} as const;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createGinkoDocsAiCatalog(origin: string) {
|
|
31
|
+
const baseUrl = new URL(origin);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
specVersion: "1.0",
|
|
35
|
+
entries: [
|
|
36
|
+
{
|
|
37
|
+
identifier: "urn:air:ginko-docs.lupinum.com:mcp:ginko-docs",
|
|
38
|
+
displayName: "Ginko Docs MCP Server",
|
|
39
|
+
type: "application/mcp-server-card+json",
|
|
40
|
+
description: "Read the public Ginko Docs documentation through MCP.",
|
|
41
|
+
url: new URL("/mcp/server-card", baseUrl).toString(),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
} as const;
|
|
45
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { H3Event } from "h3";
|
|
2
2
|
import { many } from "@lupinum/ginko-content/server";
|
|
3
3
|
import { useRuntimeConfig } from "#imports";
|
|
4
|
-
import { localeCodes } from "../../i18n/locales";
|
|
4
|
+
import { defaultLocale, isLocaleCode, localeCodes } from "../../i18n/locales";
|
|
5
5
|
import { buildRedirectMap, type RedirectSourceDocument } from "./redirects.utils";
|
|
6
6
|
|
|
7
7
|
async function queryRedirectDocuments(event: H3Event): Promise<RedirectSourceDocument[]> {
|
|
@@ -25,11 +25,18 @@ async function queryRedirectDocuments(event: H3Event): Promise<RedirectSourceDoc
|
|
|
25
25
|
let cachedMap: Promise<Map<string, string>> | undefined;
|
|
26
26
|
|
|
27
27
|
export function loadRedirectMap(event: H3Event): Promise<Map<string, string>> {
|
|
28
|
+
const configuredPrimaryLocale = useRuntimeConfig(event).public.ginkoDocs?.primaryLocale;
|
|
29
|
+
const primaryLocale =
|
|
30
|
+
typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
|
|
31
|
+
? configuredPrimaryLocale
|
|
32
|
+
: defaultLocale;
|
|
28
33
|
if (import.meta.dev) {
|
|
29
|
-
return queryRedirectDocuments(event).then(
|
|
34
|
+
return queryRedirectDocuments(event).then((documents) =>
|
|
35
|
+
buildRedirectMap(documents, primaryLocale),
|
|
36
|
+
);
|
|
30
37
|
}
|
|
31
38
|
cachedMap ??= queryRedirectDocuments(event)
|
|
32
|
-
.then(buildRedirectMap)
|
|
39
|
+
.then((documents) => buildRedirectMap(documents, primaryLocale))
|
|
33
40
|
.catch((error) => {
|
|
34
41
|
cachedMap = undefined;
|
|
35
42
|
throw error;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { localeCodes, localizedPath } from "../../i18n/locales";
|
|
1
|
+
import { defaultLocale, localeCodes, localizedPath, type LocaleCode } from "../../i18n/locales";
|
|
2
2
|
import { routeSlugs } from "../../shared/route-slugs";
|
|
3
3
|
|
|
4
4
|
export interface RedirectSourceDocument {
|
|
@@ -13,11 +13,11 @@ export function normalizeRedirectPath(path: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** Routes the theme itself owns; a redirect must never shadow one of them. */
|
|
16
|
-
export function themeStaticRoutes(): string[] {
|
|
16
|
+
export function themeStaticRoutes(primaryLocale: LocaleCode = defaultLocale): string[] {
|
|
17
17
|
return localeCodes.flatMap((locale) => [
|
|
18
|
-
localizedPath(locale, routeSlugs.home[locale]),
|
|
19
|
-
localizedPath(locale, routeSlugs.docs[locale]),
|
|
20
|
-
localizedPath(locale, routeSlugs.blog[locale]),
|
|
18
|
+
localizedPath(locale, routeSlugs.home[locale], primaryLocale),
|
|
19
|
+
localizedPath(locale, routeSlugs.docs[locale], primaryLocale),
|
|
20
|
+
localizedPath(locale, routeSlugs.blog[locale], primaryLocale),
|
|
21
21
|
]);
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -26,11 +26,14 @@ export function themeStaticRoutes(): string[] {
|
|
|
26
26
|
* Conflicts throw so `failOnError` stops the build instead of shipping a
|
|
27
27
|
* redirect that shadows a live page.
|
|
28
28
|
*/
|
|
29
|
-
export function buildRedirectMap(
|
|
29
|
+
export function buildRedirectMap(
|
|
30
|
+
documents: RedirectSourceDocument[],
|
|
31
|
+
primaryLocale: LocaleCode = defaultLocale,
|
|
32
|
+
): Map<string, string> {
|
|
30
33
|
const livePaths = new Set(
|
|
31
34
|
documents.map((document) => normalizeRedirectPath(document.route.resolvedPath)),
|
|
32
35
|
);
|
|
33
|
-
const reserved = new Set(themeStaticRoutes().map(normalizeRedirectPath));
|
|
36
|
+
const reserved = new Set(themeStaticRoutes(primaryLocale).map(normalizeRedirectPath));
|
|
34
37
|
const map = new Map<string, string>();
|
|
35
38
|
const problems: string[] = [];
|
|
36
39
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defaultLocale, localeCodes, localizedPath, type LocaleCode } from "../../i18n/locales";
|
|
2
|
+
import { routeSlugs } from "../../shared/route-slugs";
|
|
3
|
+
|
|
4
|
+
interface SitemapAlternative {
|
|
5
|
+
href: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SitemapEntry {
|
|
9
|
+
loc: string;
|
|
10
|
+
alternatives?: SitemapAlternative[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SitemapContentPolicy {
|
|
14
|
+
locales: readonly string[];
|
|
15
|
+
blogEnabled: boolean;
|
|
16
|
+
primaryLocale?: LocaleCode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pathname = (url: string) => new URL(url, "https://ginko.invalid").pathname;
|
|
20
|
+
|
|
21
|
+
export function filterSitemapEntries<Entry extends SitemapEntry>(
|
|
22
|
+
entries: Entry[],
|
|
23
|
+
policy: SitemapContentPolicy,
|
|
24
|
+
): Entry[] {
|
|
25
|
+
const requestedPrimaryLocale = policy.primaryLocale ?? defaultLocale;
|
|
26
|
+
const primaryLocale = policy.locales.includes(requestedPrimaryLocale)
|
|
27
|
+
? requestedPrimaryLocale
|
|
28
|
+
: (localeCodes.find((locale) => policy.locales.includes(locale)) ?? defaultLocale);
|
|
29
|
+
const disabledLocaleRoots = localeCodes
|
|
30
|
+
.filter((locale) => !policy.locales.includes(locale))
|
|
31
|
+
.map((locale) => (locale === primaryLocale ? "/" : `/${locale}`));
|
|
32
|
+
const disabledBlogRoots = policy.blogEnabled
|
|
33
|
+
? []
|
|
34
|
+
: localeCodes.map((locale) => localizedPath(locale, routeSlugs.blog[locale], primaryLocale));
|
|
35
|
+
const excludedRoots = [...disabledLocaleRoots, ...disabledBlogRoots];
|
|
36
|
+
const isExcluded = (url: string) => {
|
|
37
|
+
const path = pathname(url);
|
|
38
|
+
return excludedRoots.some((root) => path === root || path.startsWith(`${root}/`));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return entries
|
|
42
|
+
.filter((entry) => !isExcluded(entry.loc))
|
|
43
|
+
.map((entry) => {
|
|
44
|
+
if (!entry.alternatives) return entry;
|
|
45
|
+
return {
|
|
46
|
+
...entry,
|
|
47
|
+
alternatives: entry.alternatives.filter((alternative) => !isExcluded(alternative.href)),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|