@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.
Files changed (50) hide show
  1. package/README.md +12 -4
  2. package/app/app.config.ts +2 -0
  3. package/app/app.vue +26 -2
  4. package/app/assets/css/tailwind.css +24 -0
  5. package/app/assets/css/theme-presets.css +141 -0
  6. package/app/components/OgImage/GinkoDocs.satori.vue +131 -10
  7. package/app/components/site/SiteBanner.vue +2 -2
  8. package/app/components/site/SiteFooter.vue +1 -1
  9. package/app/components/site/SiteHeader.vue +1 -1
  10. package/app/components/site/SiteSocialLinks.vue +9 -1
  11. package/app/components/ui/badge/index.ts +1 -1
  12. package/app/components/ui/button/index.ts +1 -1
  13. package/app/composables/site-navigation.utils.ts +3 -7
  14. package/app/composables/useLocalizedPath.ts +3 -2
  15. package/app/error.vue +18 -8
  16. package/app/features/docs/components/DocsPageContent.vue +2 -2
  17. package/app/features/docs/components/DocsSidebarItem.vue +2 -2
  18. package/app/features/docs/components/DocsSidebarList.vue +1 -1
  19. package/app/features/docs/components/DocsToc.vue +2 -2
  20. package/app/features/search/command-center.ts +33 -0
  21. package/app/features/search/components/SiteCommandCenter.vue +25 -9
  22. package/app/features/search/components/SiteSearchPageHighlight.vue +4 -2
  23. package/app/features/search/useCommandCenter.ts +2 -2
  24. package/app/layouts/docs.vue +1 -1
  25. package/app/pages/index.vue +22 -3
  26. package/app/plugins/ginko-docs-theme.ts +1 -0
  27. package/content-collections.ts +13 -5
  28. package/content.js +16 -10
  29. package/content.ts +25 -8
  30. package/i18n/messages/global/command.ts +1 -0
  31. package/icon-bundle.ts +24 -4
  32. package/index.d.ts +1 -0
  33. package/modules/feature-routing.ts +47 -22
  34. package/modules/syntax-highlighting.ts +5 -0
  35. package/nuxt.config.ts +7 -7
  36. package/package.json +9 -4
  37. package/runtime/generate-favicons.mjs +124 -0
  38. package/server/plugins/sitemap-policy.ts +19 -0
  39. package/server/routes/.well-known/ai-catalog.json.get.ts +10 -0
  40. package/server/routes/[locale]/blog/rss.xml.get.ts +6 -1
  41. package/server/routes/blog/rss.xml.get.ts +10 -2
  42. package/server/routes/mcp/server-card.get.ts +14 -0
  43. package/server/utils/blog-feed.ts +24 -6
  44. package/server/utils/mcp-server-card.ts +45 -0
  45. package/server/utils/redirects.ts +10 -3
  46. package/server/utils/redirects.utils.ts +10 -7
  47. package/server/utils/sitemap.ts +50 -0
  48. package/shared/theme-presets.ts +3 -0
  49. package/shared/types/app-config.ts +20 -2
  50. 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
- export const blogFeedRoutes = localeCodes.map(
11
- (locale) => `${localizedPath(locale, routeSlugs.blog[locale])}/rss.xml`,
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 fallback = locales.find((locale) => locale.code === defaultLocale);
29
- if (!fallback) throw new Error(`Missing locale definition for ${defaultLocale}.`);
30
- const configuredLocales = nuxt.options.i18n?.locales;
31
- if (!Array.isArray(configuredLocales) || configuredLocales.length === 0) {
32
- nuxt.options.i18n = {
33
- ...nuxt.options.i18n,
34
- locales: [{ code: fallback.code, language: fallback.language, name: fallback.name }],
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 { defaultLocale, localeCodes, locales, localizedPath } from "./i18n/locales";
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: "0.3.0",
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, markdownNegotiation: true, prerender: 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.map((locale) => localizedPath(locale, routeSlugs.docs[locale])),
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.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": "^4.4.3"
91
+ "zod": "4.4.3"
87
92
  },
88
93
  "peerDependencies": {
89
- "@lupinum/ginko-content": ">=0.4.0-rc.2 <0.5.0",
94
+ "@lupinum/ginko-content": ">=1.0.0-beta.7 <2.0.0",
90
95
  "nuxt": ">=4.5.1 <5",
91
- "vue": "^3.5.35",
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
- if (!locale || !isLocaleCode(locale) || locale === defaultLocale) {
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) => serveBlogFeed(event, defaultLocale));
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 { locales, localizedPath, type LocaleCode } from "../../i18n/locales";
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(true);
19
+ const { blog: blogCollection, authors: authorsCollection } = createGinkoDocsCollections([
20
+ "en",
21
+ "de",
22
+ ]);
14
23
 
15
- export function blogFeedPath(locale: LocaleCode): string {
16
- return `${localizedPath(locale, routeSlugs.blog[locale])}/rss.xml`;
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 contentRuntime = useRuntimeConfig(event).public.content as
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(buildRedirectMap);
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(documents: RedirectSourceDocument[]): Map<string, string> {
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
+ }
@@ -0,0 +1,3 @@
1
+ export const GINKO_DOCS_THEME_PRESETS = ["default", "nuxt"] as const;
2
+
3
+ export type GinkoDocsThemePreset = (typeof GINKO_DOCS_THEME_PRESETS)[number];