@lupinum/ginko-docs 0.2.3 → 0.3.0-rc.1

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 (34) hide show
  1. package/README.md +2 -2
  2. package/app/app.config.ts +1 -1
  3. package/app/app.vue +2 -0
  4. package/app/components/content/Feedback.vue +10 -6
  5. package/app/components/prose/ProseImg.vue +39 -11
  6. package/app/components/site/SiteHeader.vue +12 -1
  7. package/app/components/site/SiteLocaleSwitcher.vue +3 -5
  8. package/app/components/site/SiteSocialLinks.vue +31 -0
  9. package/app/composables/site-navigation.utils.ts +40 -1
  10. package/app/composables/useGinkoAnalytics.ts +12 -8
  11. package/app/composables/useSiteNavigation.ts +7 -22
  12. package/app/features/docs/components/DocsPageContent.vue +10 -1
  13. package/app/features/docs/components/DocsSidebar.vue +8 -2
  14. package/app/features/search/useCommandCenter.ts +2 -2
  15. package/app/pages/blog/[slug].vue +10 -1
  16. package/app/pages/blog/index.vue +12 -1
  17. package/app/pages/index.vue +6 -2
  18. package/content.js +48 -27
  19. package/content.ts +21 -4
  20. package/i18n/messages/global/docs.ts +1 -0
  21. package/i18n/messages/global/nav.ts +0 -1
  22. package/icon-bundle.ts +4 -56
  23. package/modules/feature-routing.ts +15 -0
  24. package/nuxt.config.ts +21 -7
  25. package/package.json +26 -26
  26. package/server/api/_ginko-docs/redirects.get.ts +28 -0
  27. package/server/middleware/redirects.ts +22 -0
  28. package/server/routes/[locale]/blog/rss.xml.get.ts +13 -0
  29. package/server/routes/blog/rss.xml.get.ts +5 -0
  30. package/server/utils/blog-feed.ts +49 -0
  31. package/server/utils/feed.ts +70 -0
  32. package/server/utils/redirects.ts +38 -0
  33. package/server/utils/redirects.utils.ts +58 -0
  34. package/shared/types/app-config.ts +14 -21
package/content.ts CHANGED
@@ -11,6 +11,17 @@ import { routeSlugs } from "./shared/route-slugs";
11
11
 
12
12
  const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected an ISO date (YYYY-MM-DD)");
13
13
  const nonEmptyString = z.string().trim().min(1);
14
+ /** Former public URLs of a page, as served: locale prefix and translated slugs included. */
15
+ const redirectFrom = z
16
+ .array(nonEmptyString.regex(/^\//, "redirectFrom entries must be absolute site paths"))
17
+ .optional();
18
+
19
+ /**
20
+ * Derives the sitemap lastmod from the authored date so it has one source of
21
+ * truth. Route records require normalized UTC ISO values.
22
+ */
23
+ const withSitemapLastmod = <T extends object>(data: T, lastmod: string | undefined) =>
24
+ lastmod ? { ...data, sitemap: { lastmod: `${lastmod}T00:00:00.000Z` } } : data;
14
25
 
15
26
  export interface GinkoDocsContentOptions {
16
27
  site: {
@@ -28,6 +39,7 @@ const docsSchema = z.object({
28
39
  icon: z.string().optional(),
29
40
  badge: z.string().optional(),
30
41
  updated: isoDate.optional(),
42
+ redirectFrom,
31
43
  sidebar: z.enum(["section", "group"]).optional(),
32
44
  navigation: z
33
45
  .object({
@@ -38,6 +50,9 @@ const docsSchema = z.object({
38
50
  })
39
51
  .optional(),
40
52
  });
53
+ const docsSchemaWithLastmod = docsSchema.transform((data) =>
54
+ withSitemapLastmod(data, data.updated),
55
+ );
41
56
  const blogSchema = z.object({
42
57
  title: z.string(),
43
58
  description: z.string(),
@@ -46,7 +61,9 @@ const blogSchema = z.object({
46
61
  readingTime: nonEmptyString,
47
62
  author: reference("authors"),
48
63
  image: z.string().optional(),
64
+ redirectFrom,
49
65
  });
66
+ const blogSchemaWithLastmod = blogSchema.transform((data) => withSitemapLastmod(data, data.date));
50
67
  const authorsSchema = z.object({
51
68
  slug: z.string(),
52
69
  name: z.string(),
@@ -56,8 +73,8 @@ const authorsSchema = z.object({
56
73
  links: z.array(z.object({ label: z.string(), href: z.string() })).optional(),
57
74
  });
58
75
 
59
- type DocsCollection = ContentCollectionConfig<typeof docsSchema>;
60
- type BlogCollection = ContentCollectionConfig<typeof blogSchema>;
76
+ type DocsCollection = ContentCollectionConfig<typeof docsSchemaWithLastmod>;
77
+ type BlogCollection = ContentCollectionConfig<typeof blogSchemaWithLastmod>;
61
78
  type AuthorsCollection = ContentCollectionConfig<typeof authorsSchema>;
62
79
  type DocsContentConfig = ContentConfig<{ docs: DocsCollection }>;
63
80
  type DocsBlogContentConfig = ContentConfig<{
@@ -100,7 +117,7 @@ export function defineGinkoDocsConfig(
100
117
  route: i18n ? routeSlugs.docs : routeSlugs.docs.en,
101
118
  agent: { section: "optional", markdown: true },
102
119
  strict: true,
103
- schema: docsSchema,
120
+ schema: docsSchemaWithLastmod,
104
121
  });
105
122
  const blog = defineCollection({
106
123
  type: "page",
@@ -109,7 +126,7 @@ export function defineGinkoDocsConfig(
109
126
  route: i18n ? routeSlugs.blog : routeSlugs.blog.en,
110
127
  agent: { section: "blog", markdown: true },
111
128
  strict: true,
112
- schema: blogSchema,
129
+ schema: blogSchemaWithLastmod,
113
130
  });
114
131
  const authors = defineCollection({
115
132
  type: "data",
@@ -17,6 +17,7 @@ export const docs = {
17
17
  showAllLines: { de: "Alle {count} Zeilen anzeigen", en: "Show all {count} lines" },
18
18
  showMore: { de: "Mehr anzeigen", en: "Show more" },
19
19
  zoomImage: { de: "Bild vergrößern", en: "Zoom image" },
20
+ lastUpdated: { de: "Zuletzt aktualisiert", en: "Last updated" },
20
21
  moreActions: { de: "Weitere Seitenaktionen", en: "More page actions" },
21
22
  copyLink: { de: "Markdown-Link kopieren", en: "Copy Markdown link" },
22
23
  viewMarkdown: { de: "Als Markdown anzeigen", en: "View as Markdown" },
@@ -17,6 +17,5 @@ export const nav = {
17
17
  company: { de: "Unternehmen", en: "Company" },
18
18
  documentation: { de: "Dokumentation", en: "Documentation" },
19
19
  blog: { de: "Blog", en: "Blog" },
20
- github: { de: "GitHub", en: "GitHub" },
21
20
  externalLink: { de: "externer Link", en: "external link" },
22
21
  } as const;
package/icon-bundle.ts CHANGED
@@ -79,68 +79,16 @@ export const layerIconNames = [
79
79
  "lucide:zap",
80
80
  ] as const;
81
81
 
82
- type IconTransform = {
83
- width?: number;
84
- height?: number;
85
- left?: number;
86
- top?: number;
87
- rotate?: number;
88
- hFlip?: boolean;
89
- vFlip?: boolean;
90
- };
91
- type IconData = IconTransform & { body: string };
92
- type IconAlias = IconTransform & { parent: string };
93
82
  type IconCollection = {
94
83
  prefix: string;
95
84
  width?: number;
96
85
  height?: number;
97
- icons: Record<string, IconData>;
98
- aliases?: Record<string, IconAlias>;
86
+ icons: Record<string, { body: string }>;
87
+ aliases?: Record<string, { parent: string }>;
99
88
  };
100
89
 
101
90
  const require = createRequire(import.meta.url);
102
- const sourceCollections = new Map<string, IconCollection>(
103
- ["circle-flags", "logos", "lucide"].map((prefix) => [
104
- prefix,
105
- require(`@iconify-json/${prefix}/icons.json`) as IconCollection,
106
- ]),
107
- );
108
-
109
- export const layerIconCollections: IconCollection[] = [...sourceCollections.values()].map(
110
- ({ prefix, width, height }) => ({ prefix, width, height, icons: {}, aliases: {} }),
111
- );
112
91
 
113
- const bundledCollections = new Map(
114
- layerIconCollections.map((collection) => [collection.prefix, collection]),
92
+ export const layerIconCollections = ["circle-flags", "logos", "lucide"].map(
93
+ (prefix) => require(`@iconify-json/${prefix}/icons.json`) as IconCollection,
115
94
  );
116
-
117
- function includeIcon(prefix: string, name: string, seen = new Set<string>()) {
118
- const key = `${prefix}:${name}`;
119
- if (seen.has(key)) return;
120
- seen.add(key);
121
-
122
- const source = sourceCollections.get(prefix);
123
- const target = bundledCollections.get(prefix);
124
- if (!source || !target) return;
125
-
126
- const icon = source.icons[name];
127
- if (icon) {
128
- target.icons[name] = icon;
129
- return;
130
- }
131
-
132
- const alias = source.aliases?.[name];
133
- if (!alias) return;
134
- target.aliases![name] = alias;
135
- includeIcon(prefix, alias.parent, seen);
136
- }
137
-
138
- export function includeIconNames(names: Iterable<string>) {
139
- for (const icon of names) {
140
- const separator = icon.indexOf(":");
141
- if (separator < 1) continue;
142
- includeIcon(icon.slice(0, separator), icon.slice(separator + 1));
143
- }
144
- }
145
-
146
- includeIconNames(layerIconNames);
@@ -1,10 +1,16 @@
1
1
  import { defineNuxtModule } from "@nuxt/kit";
2
2
  import type {} from "@lupinum/ginko-content";
3
+ import { localeCodes, localizedPath } from "../i18n/locales";
4
+ import { routeSlugs } from "../shared/route-slugs";
3
5
 
4
6
  interface PageRoute {
5
7
  path: string;
6
8
  }
7
9
 
10
+ export const blogFeedRoutes = localeCodes.map(
11
+ (locale) => `${localizedPath(locale, routeSlugs.blog[locale])}/rss.xml`,
12
+ );
13
+
8
14
  export function removeBlogPages(pages: PageRoute[], blogEnabled: boolean) {
9
15
  if (blogEnabled) return;
10
16
 
@@ -28,5 +34,14 @@ export default defineNuxtModule({
28
34
  nuxt.hook("pages:extend", (pages) => {
29
35
  removeBlogPages(pages, blogEnabled);
30
36
  });
37
+
38
+ // content:context runs before Nitro config is finalized, so the feed
39
+ // routes only prerender when the consuming app enables the blog.
40
+ nuxt.hook("nitro:config", (nitroConfig) => {
41
+ if (!blogEnabled) return;
42
+ nitroConfig.prerender ??= {};
43
+ nitroConfig.prerender.routes ??= [];
44
+ nitroConfig.prerender.routes.push(...blogFeedRoutes);
45
+ });
31
46
  },
32
47
  });
package/nuxt.config.ts CHANGED
@@ -6,7 +6,9 @@ import darkPlus from "shiki/dist/themes/dark-plus.mjs";
6
6
  import lightPlus from "shiki/dist/themes/light-plus.mjs";
7
7
  import { contentComponentPolicy, contentComponentTags } from "./tags";
8
8
  import { i18nPages } from "./i18n/routes";
9
- import { includeIconNames, layerIconCollections, layerIconNames } from "./icon-bundle";
9
+ import { localeCodes, localizedPath } from "./i18n/locales";
10
+ import { routeSlugs } from "./shared/route-slugs";
11
+ import { layerIconCollections, layerIconNames } from "./icon-bundle";
10
12
 
11
13
  const root = dirname(fileURLToPath(import.meta.url));
12
14
  const app = join(root, "app");
@@ -41,7 +43,7 @@ export default defineNuxtConfig({
41
43
  },
42
44
  mcp: {
43
45
  name: "Ginko Docs",
44
- version: "0.2.3",
46
+ version: "0.3.0-rc.1",
45
47
  },
46
48
  components: {
47
49
  dirs: [
@@ -57,6 +59,7 @@ export default defineNuxtConfig({
57
59
  "SiteLocaleSwitcher.vue",
58
60
  "SiteLogoMark.vue",
59
61
  "SiteSkipLink.vue",
62
+ "SiteSocialLinks.vue",
60
63
  ],
61
64
  },
62
65
  {
@@ -115,13 +118,16 @@ export default defineNuxtConfig({
115
118
  },
116
119
  content: {
117
120
  componentPolicy: contentComponentPolicy,
121
+ // Broken internal links and missing #anchors fail the build instead of
122
+ // only landing in the validation report.
123
+ validation: "error",
118
124
  i18n: {
119
125
  translatedSlugs: true,
120
126
  },
121
127
  markdown: {
122
128
  plugins: [
123
129
  [
124
- "highlight",
130
+ "shiki",
125
131
  {
126
132
  preStyles: false,
127
133
  transformers: [transformerNotationDiff(), transformerNotationHighlight()],
@@ -141,6 +147,9 @@ export default defineNuxtConfig({
141
147
  },
142
148
  sitemap: {
143
149
  excludeAppSources: ["nuxt:prerender"],
150
+ // The docs roots prerender as redirects to the first docs page; a sitemap
151
+ // must not list redirecting URLs.
152
+ exclude: localeCodes.map((locale) => localizedPath(locale, routeSlugs.docs[locale])),
144
153
  },
145
154
  app: {
146
155
  head: {
@@ -151,9 +160,6 @@ export default defineNuxtConfig({
151
160
  },
152
161
  },
153
162
  hooks: {
154
- "icon:clientBundleIcons"(icons) {
155
- includeIconNames(icons);
156
- },
157
163
  "components:dirs"(dirs) {
158
164
  const defaultComponentsDir = join(app, "components").replaceAll("\\", "/");
159
165
  const filtered = dirs.filter((entry) => {
@@ -171,7 +177,15 @@ export default defineNuxtConfig({
171
177
  concurrency: 1,
172
178
  crawlLinks: true,
173
179
  failOnError: true,
174
- routes: ["/llms.txt", "/llms-full.txt", "/sitemap.xml", "/robots.txt"],
180
+ routes: [
181
+ "/llms.txt",
182
+ "/llms-full.txt",
183
+ "/sitemap.xml",
184
+ "/robots.txt",
185
+ // Link page over every authored redirectFrom source; crawling it
186
+ // materializes the redirect stubs.
187
+ "/api/_ginko-docs/redirects",
188
+ ],
175
189
  },
176
190
  },
177
191
  vite: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-docs",
3
- "version": "0.2.3",
3
+ "version": "0.3.0-rc.1",
4
4
  "description": "A Nuxt documentation layer powered by Ginko Content.",
5
5
  "keywords": [
6
6
  "content",
@@ -9,14 +9,14 @@
9
9
  "markdown",
10
10
  "nuxt"
11
11
  ],
12
- "homepage": "https://github.com/Mat4m0/lupinum-docs-shadcn#readme",
12
+ "homepage": "https://ginko-docs.lupinum.com",
13
13
  "bugs": {
14
- "url": "https://github.com/Mat4m0/lupinum-docs-shadcn/issues"
14
+ "url": "https://github.com/lupinum-dev/ginko-docs/issues"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/Mat4m0/lupinum-docs-shadcn.git",
19
+ "url": "git+https://github.com/lupinum-dev/ginko-docs.git",
20
20
  "directory": "layer"
21
21
  },
22
22
  "files": [
@@ -52,37 +52,37 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@iconify-json/circle-flags": "^1.2.10",
55
- "@iconify-json/logos": "^1.2.11",
56
- "@iconify-json/lucide": "^1.2.111",
55
+ "@iconify-json/logos": "^1.2.12",
56
+ "@iconify-json/lucide": "^1.2.123",
57
57
  "@nuxt/fonts": "^0.14.0",
58
- "@nuxt/icon": "^2.2.3",
59
- "@nuxt/image": "^2.0.0",
60
- "@nuxt/kit": "^4.4.7",
61
- "@nuxt/scripts": "^0.13.2",
62
- "@nuxtjs/color-mode": "^4.0.0",
63
- "@nuxtjs/i18n": "^10.4.0",
64
- "@nuxtjs/mcp-toolkit": "0.17.2",
65
- "@nuxtjs/robots": "^6.1.2",
66
- "@nuxtjs/sitemap": "^8.2.2",
67
- "@resvg/resvg-js": "^2.6.0",
68
- "@shikijs/transformers": "^4.2.0",
69
- "@tailwindcss/vite": "^4.3.0",
70
- "@vueuse/core": "^14.2.1",
58
+ "@nuxt/icon": "^2.5.0",
59
+ "@nuxt/image": "^2.1.0",
60
+ "@nuxt/kit": "^4.5.2",
61
+ "@nuxt/scripts": "^1.3.3",
62
+ "@nuxtjs/color-mode": "^4.0.1",
63
+ "@nuxtjs/i18n": "^10.6.0",
64
+ "@nuxtjs/mcp-toolkit": "0.18.1",
65
+ "@nuxtjs/robots": "^6.1.4",
66
+ "@nuxtjs/sitemap": "^8.3.4",
67
+ "@resvg/resvg-js": "^2.6.2",
68
+ "@shikijs/transformers": "^4.4.3",
69
+ "@tailwindcss/vite": "^4.3.3",
70
+ "@vueuse/core": "^14.4.0",
71
71
  "class-variance-authority": "^0.7.1",
72
72
  "clsx": "^2.1.1",
73
73
  "motion-v": "^2.3.0",
74
74
  "nitropack": "^2.13.4",
75
- "nuxt-og-image": "^6.7.2",
76
- "reka-ui": "^2.9.2",
77
- "satori": "^0.19.2",
78
- "shiki": "^4.0.2",
79
- "tailwind-merge": "^3.5.0",
80
- "tailwindcss": "^4.2.2",
75
+ "nuxt-og-image": "^6.7.8",
76
+ "reka-ui": "^2.10.3",
77
+ "satori": "^0.19.3",
78
+ "shiki": "^4.4.3",
79
+ "tailwind-merge": "^3.6.0",
80
+ "tailwindcss": "^4.3.3",
81
81
  "tw-animate-css": "^1.4.0",
82
82
  "zod": "^4.4.3"
83
83
  },
84
84
  "peerDependencies": {
85
- "@lupinum/ginko-content": ">=0.3.2 <0.4.0",
85
+ "@lupinum/ginko-content": ">=0.4.0-rc.1 <0.5.0",
86
86
  "nuxt": ">=4.4.7 <5",
87
87
  "vue": "^3.5.35",
88
88
  "vue-router": "^5.1.0"
@@ -0,0 +1,28 @@
1
+ import { defineEventHandler, setHeader } from "h3";
2
+ import { loadRedirectMap } from "../../utils/redirects";
3
+
4
+ const escapeHtml = (value: string) =>
5
+ value
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll('"', "&quot;");
10
+
11
+ /**
12
+ * Prerender seed: returns a link page of every `redirectFrom` source so the
13
+ * crawler visits each old path and materializes its redirect stub. Conflict
14
+ * validation throws here, which fails the build via `failOnError`.
15
+ */
16
+ export default defineEventHandler(async (event) => {
17
+ const redirects = await loadRedirectMap(event);
18
+
19
+ if (import.meta.prerender) {
20
+ setHeader(event, "content-type", "text/html; charset=utf-8");
21
+ const links = Array.from(redirects.keys())
22
+ .map((source) => `<a href="${escapeHtml(source)}"></a>`)
23
+ .join("");
24
+ return `<!doctype html><html><head><meta charset="utf-8"></head><body>${links}</body></html>`;
25
+ }
26
+
27
+ return { count: redirects.size, redirects: Object.fromEntries(redirects) };
28
+ });
@@ -0,0 +1,22 @@
1
+ import { defineEventHandler, sendRedirect } from "h3";
2
+ import { loadRedirectMap } from "../utils/redirects";
3
+ import { normalizeRedirectPath } from "../utils/redirects.utils";
4
+
5
+ /**
6
+ * Serves authored `redirectFrom` moves as 301s. During prerender this runs
7
+ * before the catch-all page would SSR a 404 for the crawled old path, so the
8
+ * static build materializes the same meta-refresh stub the docs root uses.
9
+ */
10
+ export default defineEventHandler(async (event) => {
11
+ if (event.method !== "GET" && event.method !== "HEAD") return;
12
+
13
+ const path = normalizeRedirectPath(event.path);
14
+ const lastSegment = path.slice(path.lastIndexOf("/") + 1);
15
+ if (path.startsWith("/api/") || path.startsWith("/_") || lastSegment.includes(".")) return;
16
+
17
+ const redirects = await loadRedirectMap(event);
18
+ const target = redirects.get(path);
19
+ if (target) {
20
+ return sendRedirect(event, target, 301);
21
+ }
22
+ });
@@ -0,0 +1,13 @@
1
+ import { createError, defineEventHandler, getRouterParam } from "h3";
2
+ import { defaultLocale, isLocaleCode } from "../../../../i18n/locales";
3
+ import { serveBlogFeed } from "../../../utils/blog-feed";
4
+
5
+ // A param route keeps the router free of static locale segments; a static
6
+ // /de/... node would shadow ginko-content's /:locale/llms.txt routes.
7
+ export default defineEventHandler(async (event) => {
8
+ const locale = getRouterParam(event, "locale");
9
+ if (!locale || !isLocaleCode(locale) || locale === defaultLocale) {
10
+ throw createError({ statusCode: 404, statusMessage: "Page not found" });
11
+ }
12
+ return serveBlogFeed(event, locale);
13
+ });
@@ -0,0 +1,5 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { defaultLocale } from "../../../i18n/locales";
3
+ import { serveBlogFeed } from "../../utils/blog-feed";
4
+
5
+ export default defineEventHandler((event) => serveBlogFeed(event, defaultLocale));
@@ -0,0 +1,49 @@
1
+ import type { H3Event } from "h3";
2
+ import { createError, setHeader } from "h3";
3
+ import { many } from "@lupinum/ginko-content/server";
4
+ import { useAppConfig, useRuntimeConfig } from "#imports";
5
+ import { blog } from "../../i18n/messages/global/blog";
6
+ import { locales, localizedPath, type LocaleCode } from "../../i18n/locales";
7
+ import { routeSlugs } from "../../shared/route-slugs";
8
+ import { getLocalizedSiteText } from "../../app/config/site.utils";
9
+ import { buildRssFeed } from "./feed";
10
+
11
+ export const MAX_FEED_POSTS = 50;
12
+
13
+ export function blogFeedPath(locale: LocaleCode): string {
14
+ return `${localizedPath(locale, routeSlugs.blog[locale])}/rss.xml`;
15
+ }
16
+
17
+ export async function serveBlogFeed(event: H3Event, locale: LocaleCode) {
18
+ const contentRuntime = useRuntimeConfig(event).public.content as
19
+ | { collections?: Record<string, unknown> }
20
+ | undefined;
21
+ if (!contentRuntime?.collections?.blog) {
22
+ throw createError({ statusCode: 404, statusMessage: "Blog is not enabled" });
23
+ }
24
+
25
+ const site = useAppConfig().ginkoDocs.site;
26
+ const posts = await many(event, "blog", {
27
+ locale,
28
+ fallback: true,
29
+ populate: { author: "authors" },
30
+ sort: { date: "desc" },
31
+ limit: MAX_FEED_POSTS,
32
+ });
33
+
34
+ setHeader(event, "content-type", "application/rss+xml; charset=utf-8");
35
+ return buildRssFeed({
36
+ title: `${blog.title[locale]} - ${getLocalizedSiteText(site.name, locale)}`,
37
+ description: blog.description[locale],
38
+ siteUrl: site.url,
39
+ feedPath: blogFeedPath(locale),
40
+ language: locales.find((entry) => entry.code === locale)?.language ?? locale,
41
+ items: posts.map((post) => ({
42
+ title: post.title,
43
+ path: post.route.resolvedPath,
44
+ description: post.description,
45
+ date: post.date,
46
+ authorName: post.author?.name,
47
+ })),
48
+ });
49
+ }
@@ -0,0 +1,70 @@
1
+ export interface RssFeedItem {
2
+ title: string;
3
+ /** Site-relative path of the post, e.g. "/blog/my-post". */
4
+ path: string;
5
+ description: string;
6
+ /** Authored publication date, YYYY-MM-DD. */
7
+ date: string;
8
+ authorName?: string;
9
+ }
10
+
11
+ export interface RssFeedInput {
12
+ title: string;
13
+ description: string;
14
+ /** Absolute site origin, e.g. "https://docs.example.com". */
15
+ siteUrl: string;
16
+ /** Site-relative path of the feed itself, e.g. "/blog/rss.xml". */
17
+ feedPath: string;
18
+ language: string;
19
+ items: RssFeedItem[];
20
+ }
21
+
22
+ function escapeXml(value: string): string {
23
+ return value
24
+ .replaceAll("&", "&amp;")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;")
27
+ .replaceAll('"', "&quot;")
28
+ .replaceAll("'", "&apos;");
29
+ }
30
+
31
+ function toRfc822(date: string): string {
32
+ return new Date(`${date}T00:00:00Z`).toUTCString();
33
+ }
34
+
35
+ export function buildRssFeed(feed: RssFeedInput): string {
36
+ const base = feed.siteUrl.replace(/\/$/, "");
37
+ const newestDate = feed.items
38
+ .map((item) => item.date)
39
+ .sort()
40
+ .at(-1);
41
+
42
+ const items = feed.items
43
+ .map((item) => {
44
+ const link = `${base}${item.path}`;
45
+ const author = item.authorName
46
+ ? `\n <dc:creator>${escapeXml(item.authorName)}</dc:creator>`
47
+ : "";
48
+ return ` <item>
49
+ <title>${escapeXml(item.title)}</title>
50
+ <link>${escapeXml(link)}</link>
51
+ <guid>${escapeXml(link)}</guid>
52
+ <pubDate>${toRfc822(item.date)}</pubDate>
53
+ <description>${escapeXml(item.description)}</description>${author}
54
+ </item>`;
55
+ })
56
+ .join("\n");
57
+
58
+ return `<?xml version="1.0" encoding="UTF-8"?>
59
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
60
+ <channel>
61
+ <title>${escapeXml(feed.title)}</title>
62
+ <link>${escapeXml(`${base}${feed.feedPath.replace(/rss\.xml$/, "")}`)}</link>
63
+ <atom:link href="${escapeXml(`${base}${feed.feedPath}`)}" rel="self" type="application/rss+xml"/>
64
+ <description>${escapeXml(feed.description)}</description>
65
+ <language>${escapeXml(feed.language)}</language>${newestDate ? `\n <lastBuildDate>${toRfc822(newestDate)}</lastBuildDate>` : ""}
66
+ ${items}
67
+ </channel>
68
+ </rss>
69
+ `;
70
+ }
@@ -0,0 +1,38 @@
1
+ import type { H3Event } from "h3";
2
+ import { many } from "@lupinum/ginko-content/server";
3
+ import { useRuntimeConfig } from "#imports";
4
+ import { localeCodes } from "../../i18n/locales";
5
+ import { buildRedirectMap, type RedirectSourceDocument } from "./redirects.utils";
6
+
7
+ async function queryRedirectDocuments(event: H3Event): Promise<RedirectSourceDocument[]> {
8
+ const contentRuntime = useRuntimeConfig(event).public.content as
9
+ | { collections?: Record<string, unknown> }
10
+ | undefined;
11
+ const collections = ["docs", ...(contentRuntime?.collections?.blog ? ["blog"] : [])];
12
+
13
+ const results = await Promise.all(
14
+ collections.flatMap((collection) =>
15
+ localeCodes.map((locale) =>
16
+ // fallback: false — an English page resolved into a German route would
17
+ // otherwise register its redirectFrom entries twice.
18
+ many(event, collection, { locale, fallback: false }),
19
+ ),
20
+ ),
21
+ );
22
+ return results.flat() as RedirectSourceDocument[];
23
+ }
24
+
25
+ let cachedMap: Promise<Map<string, string>> | undefined;
26
+
27
+ export function loadRedirectMap(event: H3Event): Promise<Map<string, string>> {
28
+ if (import.meta.dev) {
29
+ return queryRedirectDocuments(event).then(buildRedirectMap);
30
+ }
31
+ cachedMap ??= queryRedirectDocuments(event)
32
+ .then(buildRedirectMap)
33
+ .catch((error) => {
34
+ cachedMap = undefined;
35
+ throw error;
36
+ });
37
+ return cachedMap;
38
+ }
@@ -0,0 +1,58 @@
1
+ import { localeCodes, localizedPath } from "../../i18n/locales";
2
+ import { routeSlugs } from "../../shared/route-slugs";
3
+
4
+ export interface RedirectSourceDocument {
5
+ route: { resolvedPath: string };
6
+ redirectFrom?: string[];
7
+ }
8
+
9
+ export function normalizeRedirectPath(path: string): string {
10
+ const trimmed = path.split("?")[0] ?? path;
11
+ if (trimmed === "/") return "/";
12
+ return trimmed.replace(/\/+$/, "");
13
+ }
14
+
15
+ /** Routes the theme itself owns; a redirect must never shadow one of them. */
16
+ export function themeStaticRoutes(): string[] {
17
+ return localeCodes.flatMap((locale) => [
18
+ localizedPath(locale, routeSlugs.home[locale]),
19
+ localizedPath(locale, routeSlugs.docs[locale]),
20
+ localizedPath(locale, routeSlugs.blog[locale]),
21
+ ]);
22
+ }
23
+
24
+ /**
25
+ * Builds old-path → live-path pairs from every document's `redirectFrom`.
26
+ * Conflicts throw so `failOnError` stops the build instead of shipping a
27
+ * redirect that shadows a live page.
28
+ */
29
+ export function buildRedirectMap(documents: RedirectSourceDocument[]): Map<string, string> {
30
+ const livePaths = new Set(
31
+ documents.map((document) => normalizeRedirectPath(document.route.resolvedPath)),
32
+ );
33
+ const reserved = new Set(themeStaticRoutes().map(normalizeRedirectPath));
34
+ const map = new Map<string, string>();
35
+ const problems: string[] = [];
36
+
37
+ for (const document of documents) {
38
+ const target = normalizeRedirectPath(document.route.resolvedPath);
39
+ for (const source of document.redirectFrom ?? []) {
40
+ const from = normalizeRedirectPath(source);
41
+ if (livePaths.has(from)) {
42
+ problems.push(`"${from}" redirects to "${target}" but is also a live page`);
43
+ } else if (reserved.has(from)) {
44
+ problems.push(`"${from}" redirects to "${target}" but is a theme route`);
45
+ } else if (map.has(from) && map.get(from) !== target) {
46
+ problems.push(`"${from}" is claimed by both "${map.get(from)}" and "${target}"`);
47
+ } else {
48
+ map.set(from, target);
49
+ }
50
+ }
51
+ }
52
+
53
+ if (problems.length) {
54
+ throw new Error(`Invalid redirectFrom entries:\n${problems.map((p) => `- ${p}`).join("\n")}`);
55
+ }
56
+
57
+ return map;
58
+ }