@lupinum/ginko-docs 0.2.3 → 0.2.5
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 +2 -2
- package/app/app.config.ts +1 -1
- package/app/app.vue +2 -0
- package/app/components/prose/ProseImg.vue +39 -11
- package/app/components/site/SiteHeader.vue +12 -1
- package/app/components/site/SiteLocaleSwitcher.vue +3 -5
- package/app/components/site/SiteSocialLinks.vue +31 -0
- package/app/composables/site-navigation.utils.ts +40 -1
- package/app/composables/useSiteNavigation.ts +7 -22
- package/app/features/docs/components/DocsPageContent.vue +10 -1
- package/app/features/docs/components/DocsSidebar.vue +8 -2
- package/app/features/search/useCommandCenter.ts +2 -2
- package/app/pages/blog/[slug].vue +10 -1
- package/app/pages/blog/index.vue +12 -1
- package/app/pages/index.vue +6 -2
- package/content.js +48 -27
- package/content.ts +21 -4
- package/i18n/messages/global/docs.ts +1 -0
- package/i18n/messages/global/nav.ts +0 -1
- package/icon-bundle.ts +4 -56
- package/modules/feature-routing.ts +15 -0
- package/nuxt.config.ts +20 -6
- package/package.json +23 -23
- package/server/api/_ginko-docs/redirects.get.ts +28 -0
- package/server/middleware/redirects.ts +22 -0
- package/server/routes/[locale]/blog/rss.xml.get.ts +13 -0
- package/server/routes/blog/rss.xml.get.ts +5 -0
- package/server/utils/blog-feed.ts +49 -0
- package/server/utils/feed.ts +70 -0
- package/server/utils/redirects.ts +38 -0
- package/server/utils/redirects.utils.ts +58 -0
- package/shared/types/app-config.ts +12 -4
package/README.md
CHANGED
|
@@ -8,14 +8,14 @@ Ginko Docs is a Nuxt layer for focused documentation sites. It combines Ginko Co
|
|
|
8
8
|
- Nuxt `>=4.4.7 <5`
|
|
9
9
|
- Vue `^3.5.35`
|
|
10
10
|
- Vue Router `^5.1.0`
|
|
11
|
-
- Ginko Content `>=0.3.
|
|
11
|
+
- Ginko Content `>=0.3.5 <0.4.0`
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
15
|
Install the layer and its Ginko Content peer:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pnpm add -D @lupinum/ginko-docs @lupinum/ginko-content@0.3.
|
|
18
|
+
pnpm add -D @lupinum/ginko-docs @lupinum/ginko-content@0.3.5
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
Keep the public identity in one shared value:
|
package/app/app.config.ts
CHANGED
package/app/app.vue
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { getLocalizedSiteText } from "#ginko-docs/config/site.utils";
|
|
3
|
+
import { computed } from "vue";
|
|
3
4
|
import { useAppConfig, useHead, useI18n, useRoute, useSeoMeta } from "#imports";
|
|
4
5
|
import { useLocalizedRouteSwitch } from "#ginko-docs/composables/useLocalizedRouteSwitch";
|
|
5
6
|
import { useCanonicalUrl } from "#ginko-docs/composables/useCanonicalUrl";
|
|
@@ -16,6 +17,7 @@ const docsConfig = useAppConfig().ginkoDocs;
|
|
|
16
17
|
const siteUrl = docsConfig.site.url;
|
|
17
18
|
|
|
18
19
|
useSeoMeta({
|
|
20
|
+
ogSiteName: computed(() => getLocalizedSiteText(docsConfig.site.name, locale.value)),
|
|
19
21
|
ogUrl: canonicalUrl,
|
|
20
22
|
twitterCard: "summary_large_image",
|
|
21
23
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HTMLAttributes } from "vue";
|
|
3
|
+
import { computed, resolveComponent } from "vue";
|
|
4
|
+
import { Motion } from "motion-v";
|
|
5
|
+
import { useI18n } from "#imports";
|
|
6
|
+
import ImageZoomDialog from "#ginko-docs/components/content/ImageZoomDialog.vue";
|
|
7
|
+
import { useGinkoDocsConfig } from "#ginko-docs/composables/useGinkoDocsConfig";
|
|
3
8
|
import { cn } from "../../utils";
|
|
4
9
|
|
|
5
10
|
const props = defineProps<{
|
|
@@ -10,18 +15,41 @@ const props = defineProps<{
|
|
|
10
15
|
height?: string | number;
|
|
11
16
|
class?: HTMLAttributes["class"];
|
|
12
17
|
}>();
|
|
18
|
+
|
|
19
|
+
const { t } = useI18n();
|
|
20
|
+
const config = useGinkoDocsConfig();
|
|
21
|
+
const zoomEnabled = computed(() => config.images?.zoom !== false);
|
|
22
|
+
|
|
23
|
+
// External images skip NuxtImg; the layer ships no ipx domain allowlist.
|
|
24
|
+
const isLocalAsset = computed(() => Boolean(props.src?.startsWith("/")));
|
|
25
|
+
const imageComponent = computed(() => (isLocalAsset.value ? resolveComponent("NuxtImg") : "img"));
|
|
26
|
+
const imageAttrs = computed(() => ({
|
|
27
|
+
src: props.src,
|
|
28
|
+
alt: props.alt ?? "",
|
|
29
|
+
title: props.title,
|
|
30
|
+
width: props.width,
|
|
31
|
+
height: props.height,
|
|
32
|
+
loading: "lazy" as const,
|
|
33
|
+
decoding: "async" as const,
|
|
34
|
+
class: cn("content-prose-image", props.class),
|
|
35
|
+
...(isLocalAsset.value ? { sizes: "100vw md:704px" } : {}),
|
|
36
|
+
}));
|
|
13
37
|
</script>
|
|
14
38
|
|
|
15
39
|
<template>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
<ImageZoomDialog v-if="src && zoomEnabled" :src="src" :alt="alt" :label="title">
|
|
41
|
+
<template #trigger="{ layoutId, transition }">
|
|
42
|
+
<!-- A button is phrasing content, so a bare markdown image stays valid inside its paragraph. -->
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="block max-w-full cursor-zoom-in rounded-[var(--radius)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
|
46
|
+
:aria-label="`${t('docs.zoomImage')}: ${alt ?? ''}`"
|
|
47
|
+
>
|
|
48
|
+
<Motion as-child :layout-id="layoutId" :transition="transition">
|
|
49
|
+
<component :is="imageComponent" v-bind="imageAttrs" />
|
|
50
|
+
</Motion>
|
|
51
|
+
</button>
|
|
52
|
+
</template>
|
|
53
|
+
</ImageZoomDialog>
|
|
54
|
+
<component :is="imageComponent" v-else-if="src" v-bind="imageAttrs" />
|
|
27
55
|
</template>
|
|
@@ -26,9 +26,11 @@ import {
|
|
|
26
26
|
normalizeDocsNavigationItem,
|
|
27
27
|
} from "#ginko-docs/features/docs/docs-navigation";
|
|
28
28
|
import ModeToggle from "#ginko-docs/components/site/ModeToggle.vue";
|
|
29
|
+
import { useGinkoDocsConfig } from "#ginko-docs/composables/useGinkoDocsConfig";
|
|
29
30
|
|
|
30
31
|
const { mainNav, socialLinks } = useSiteNavigation();
|
|
31
32
|
const { openCommandCenter } = useCommandCenterState();
|
|
33
|
+
const config = useGinkoDocsConfig();
|
|
32
34
|
const { t } = useI18n();
|
|
33
35
|
const route = useRoute();
|
|
34
36
|
const isMobileMenuOpen = ref(false);
|
|
@@ -192,6 +194,10 @@ watch(
|
|
|
192
194
|
</template>
|
|
193
195
|
</ClientOnly>
|
|
194
196
|
|
|
197
|
+
<div v-if="config.nav.socialIcons" class="hidden md:block">
|
|
198
|
+
<SiteSocialLinks />
|
|
199
|
+
</div>
|
|
200
|
+
|
|
195
201
|
<SiteLocaleSwitcher class="hidden md:flex" />
|
|
196
202
|
<Sheet v-model:open="isMobileMenuOpen">
|
|
197
203
|
<SheetTrigger as-child>
|
|
@@ -348,7 +354,12 @@ watch(
|
|
|
348
354
|
</template>
|
|
349
355
|
</ClientOnly>
|
|
350
356
|
<SiteLocaleSwitcher variant="pill" class="flex" @navigate="closeMenu" />
|
|
351
|
-
<
|
|
357
|
+
<SiteSocialLinks
|
|
358
|
+
v-if="config.nav.socialIcons"
|
|
359
|
+
class="ml-auto"
|
|
360
|
+
@navigate="closeMenu"
|
|
361
|
+
/>
|
|
362
|
+
<span v-else-if="socialLinks.length" class="ml-auto flex items-center gap-2">
|
|
352
363
|
<NuxtLink
|
|
353
364
|
v-for="link in socialLinks"
|
|
354
365
|
:key="link.href"
|
|
@@ -105,7 +105,7 @@ function trackLocaleNavigation(_entry: { code: string; current: boolean; to: unk
|
|
|
105
105
|
<ClientOnly>
|
|
106
106
|
<DropdownMenu>
|
|
107
107
|
<DropdownMenuTrigger as-child>
|
|
108
|
-
<Button variant="
|
|
108
|
+
<Button variant="ghost" class="h-9 gap-1.5 px-2.5" :aria-label="t('nav.language')">
|
|
109
109
|
<Icon
|
|
110
110
|
v-if="currentLocale?.flagIcon"
|
|
111
111
|
:name="currentLocale.flagIcon"
|
|
@@ -151,10 +151,8 @@ function trackLocaleNavigation(_entry: { code: string; current: boolean; to: unk
|
|
|
151
151
|
</DropdownMenu>
|
|
152
152
|
|
|
153
153
|
<template #fallback>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
aria-hidden="true"
|
|
157
|
-
/>
|
|
154
|
+
<!-- Reserves the trigger's box before hydration. -->
|
|
155
|
+
<span class="inline-flex h-9 w-[4.25rem] rounded-md" aria-hidden="true" />
|
|
158
156
|
</template>
|
|
159
157
|
</ClientOnly>
|
|
160
158
|
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from "#ginko-docs/components/ui/button";
|
|
3
|
+
import { useSiteNavigation } from "#ginko-docs/composables/useSiteNavigation";
|
|
4
|
+
|
|
5
|
+
const { socialLinks } = useSiteNavigation();
|
|
6
|
+
|
|
7
|
+
defineEmits<{ navigate: [] }>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div v-if="socialLinks.length" class="flex items-center">
|
|
12
|
+
<Button
|
|
13
|
+
v-for="link in socialLinks"
|
|
14
|
+
:key="link.href"
|
|
15
|
+
as-child
|
|
16
|
+
variant="ghost"
|
|
17
|
+
size="icon"
|
|
18
|
+
class="text-muted-foreground hover:text-foreground"
|
|
19
|
+
>
|
|
20
|
+
<NuxtLink
|
|
21
|
+
:to="link.href"
|
|
22
|
+
target="_blank"
|
|
23
|
+
rel="noopener noreferrer"
|
|
24
|
+
:aria-label="link.label"
|
|
25
|
+
@click="$emit('navigate')"
|
|
26
|
+
>
|
|
27
|
+
<Icon :name="link.icon ?? 'lucide:link'" class="size-[18px]" aria-hidden="true" />
|
|
28
|
+
</NuxtLink>
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GinkoDocsAppConfig,
|
|
3
|
+
GinkoDocsLink,
|
|
4
|
+
GinkoDocsSocialPlatform,
|
|
5
|
+
} from "../../shared/types/app-config";
|
|
2
6
|
import { getLocalizedSiteText } from "../config/site.utils";
|
|
3
7
|
|
|
4
8
|
export interface NavItem {
|
|
@@ -7,6 +11,8 @@ export interface NavItem {
|
|
|
7
11
|
external?: boolean;
|
|
8
12
|
icon?: string;
|
|
9
13
|
description?: string;
|
|
14
|
+
/** Set on social links so features can find a platform without matching its icon. */
|
|
15
|
+
platform?: GinkoDocsSocialPlatform;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export interface MainNavContext {
|
|
@@ -60,6 +66,39 @@ export function resolveMainNav(
|
|
|
60
66
|
return items;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Brand names are not translated, so the labels are literals. The icons are
|
|
71
|
+
* bundled defaults — Lucide has no Discord mark, so chat is the closest generic
|
|
72
|
+
* stand-in and sites that ship the real one override `icon` per entry.
|
|
73
|
+
*/
|
|
74
|
+
const socialDefaults: Record<GinkoDocsSocialPlatform, { label: string; icon: string }> = {
|
|
75
|
+
github: { label: "GitHub", icon: "lucide:github" },
|
|
76
|
+
discord: { label: "Discord", icon: "lucide:message-circle" },
|
|
77
|
+
linkedin: { label: "LinkedIn", icon: "lucide:linkedin" },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function resolveSocialLinks(social: GinkoDocsAppConfig["social"]): NavItem[] {
|
|
81
|
+
// Config order is render order, so a site can list Discord ahead of GitHub.
|
|
82
|
+
return Object.entries(social).flatMap<NavItem>(([key, entry]) => {
|
|
83
|
+
const platform = key as GinkoDocsSocialPlatform;
|
|
84
|
+
const defaults = socialDefaults[platform];
|
|
85
|
+
if (!defaults || !entry) return [];
|
|
86
|
+
|
|
87
|
+
const link = typeof entry === "string" ? { href: entry } : entry;
|
|
88
|
+
if (!link.href) return [];
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
label: link.label ?? defaults.label,
|
|
93
|
+
href: link.href,
|
|
94
|
+
external: true,
|
|
95
|
+
icon: link.icon ?? defaults.icon,
|
|
96
|
+
platform,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
63
102
|
export interface BannerContext {
|
|
64
103
|
locale: string;
|
|
65
104
|
defaultText: string;
|
|
@@ -3,7 +3,12 @@ import { useI18n, useRouter } from "#imports";
|
|
|
3
3
|
import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
|
|
4
4
|
import { computed } from "vue";
|
|
5
5
|
import { useGinkoDocsConfig } from "./useGinkoDocsConfig";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
resolveBanner,
|
|
8
|
+
resolveMainNav,
|
|
9
|
+
resolveSocialLinks,
|
|
10
|
+
type NavItem,
|
|
11
|
+
} from "./site-navigation.utils";
|
|
7
12
|
|
|
8
13
|
export const useSiteNavigation = () => {
|
|
9
14
|
const config = useGinkoDocsConfig();
|
|
@@ -32,27 +37,7 @@ export const useSiteNavigation = () => {
|
|
|
32
37
|
}),
|
|
33
38
|
);
|
|
34
39
|
|
|
35
|
-
const socialLinks = computed<NavItem[]>(
|
|
36
|
-
() =>
|
|
37
|
-
[
|
|
38
|
-
config.social.github
|
|
39
|
-
? {
|
|
40
|
-
label: t("nav.github"),
|
|
41
|
-
href: config.social.github,
|
|
42
|
-
external: true,
|
|
43
|
-
icon: "lucide:github",
|
|
44
|
-
}
|
|
45
|
-
: null,
|
|
46
|
-
config.social.linkedin
|
|
47
|
-
? {
|
|
48
|
-
label: "LinkedIn",
|
|
49
|
-
href: config.social.linkedin,
|
|
50
|
-
external: true,
|
|
51
|
-
icon: "lucide:linkedin",
|
|
52
|
-
}
|
|
53
|
-
: null,
|
|
54
|
-
].filter(Boolean) as NavItem[],
|
|
55
|
-
);
|
|
40
|
+
const socialLinks = computed<NavItem[]>(() => resolveSocialLinks(config.social));
|
|
56
41
|
|
|
57
42
|
const mainNav = computed<NavItem[]>(() =>
|
|
58
43
|
resolveMainNav(config.nav.links, {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
filterTocByDepth,
|
|
4
|
+
flattenTocLinks,
|
|
5
|
+
formatContentDate,
|
|
6
|
+
getMarkdownTocLinks,
|
|
7
|
+
} from "#ginko-docs/utils/content";
|
|
3
8
|
import ContentFeedback from "#ginko-docs/components/content/Feedback.vue";
|
|
4
9
|
import DocsBreadcrumb from "./DocsBreadcrumb.vue";
|
|
5
10
|
import DocsMobileToc from "./DocsMobileToc.vue";
|
|
@@ -182,6 +187,10 @@ function scrollToTop() {
|
|
|
182
187
|
</div>
|
|
183
188
|
|
|
184
189
|
<footer class="mt-12 border-t border-border pt-6">
|
|
190
|
+
<p v-if="page.updated" class="mb-4 text-sm text-muted-foreground">
|
|
191
|
+
{{ t("docs.lastUpdated") }}:
|
|
192
|
+
<time :datetime="page.updated">{{ formatContentDate(page.updated, locale) }}</time>
|
|
193
|
+
</p>
|
|
185
194
|
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
|
186
195
|
<ContentFeedback :label="t('feedback.label')" />
|
|
187
196
|
<DocsContributeLinks
|
|
@@ -64,8 +64,14 @@ function setActiveSection(id: string) {
|
|
|
64
64
|
if (path) void navigateTo(path);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// The reduced top padding only applies under the section switcher; without it
|
|
68
|
+
// the scroll mask would fade into the first group label.
|
|
69
|
+
const scrollViewportClass = computed(() =>
|
|
70
|
+
cn(
|
|
71
|
+
"size-full rounded-[inherit] p-4 overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,white_12px,white_calc(100%-12px),transparent)]",
|
|
72
|
+
switcherSections.value.length > 1 && "pt-2",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
69
75
|
|
|
70
76
|
// Deep links can land on an item far outside the visible band — center it
|
|
71
77
|
// once on mount and on section switches.
|
|
@@ -181,7 +181,7 @@ export async function useCommandCenter() {
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
const actionItems = computed<CommandCenterItem[]>(() => {
|
|
184
|
-
const github = socialLinks.value.find((item) => item.
|
|
184
|
+
const github = socialLinks.value.find((item) => item.platform === "github");
|
|
185
185
|
return github
|
|
186
186
|
? [
|
|
187
187
|
{
|
|
@@ -191,7 +191,7 @@ export async function useCommandCenter() {
|
|
|
191
191
|
href: github.href,
|
|
192
192
|
external: true,
|
|
193
193
|
group: "actions" as const,
|
|
194
|
-
icon: "lucide:github",
|
|
194
|
+
icon: github.icon ?? "lucide:github",
|
|
195
195
|
keywords: ["repo", "source", "code"],
|
|
196
196
|
},
|
|
197
197
|
]
|
|
@@ -80,7 +80,16 @@ useGinkoOgImage({
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
useHead(() => ({
|
|
83
|
-
link: [
|
|
83
|
+
link: [
|
|
84
|
+
{ key: "canonical", rel: "canonical", href: canonicalUrl.value },
|
|
85
|
+
{
|
|
86
|
+
key: "rss",
|
|
87
|
+
rel: "alternate",
|
|
88
|
+
type: "application/rss+xml",
|
|
89
|
+
title: `${t("blog.title")} - ${siteName.value}`,
|
|
90
|
+
href: `${localizedPath("blog")}/rss.xml`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
84
93
|
meta: [{ property: "og:type", content: "article" }],
|
|
85
94
|
}));
|
|
86
95
|
|
package/app/pages/blog/index.vue
CHANGED
|
@@ -6,6 +6,7 @@ import { getLocalizedSiteText } from "#ginko-docs/config/site.utils";
|
|
|
6
6
|
import { computed } from "vue";
|
|
7
7
|
import { definePageMeta, useAppConfig, useAsyncData, useHead, useI18n, useSeoMeta } from "#imports";
|
|
8
8
|
import { useCanonicalUrl } from "#ginko-docs/composables/useCanonicalUrl";
|
|
9
|
+
import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
|
|
9
10
|
|
|
10
11
|
const MAX_BLOG_POSTS = 50;
|
|
11
12
|
|
|
@@ -14,6 +15,7 @@ definePageMeta({ layout: "blog" });
|
|
|
14
15
|
const { locale, t } = useI18n();
|
|
15
16
|
const postsKey = computed(() => `blog-posts:${locale.value}`);
|
|
16
17
|
const canonicalUrl = useCanonicalUrl();
|
|
18
|
+
const localizedPath = useLocalizedPath();
|
|
17
19
|
const config = useAppConfig().ginkoDocs;
|
|
18
20
|
const siteName = computed(() => getLocalizedSiteText(config.site.name, locale.value));
|
|
19
21
|
const fullTitle = computed(() => `${t("blog.pageTitle")} - ${siteName.value}`);
|
|
@@ -28,7 +30,16 @@ useSeoMeta({
|
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
useHead(() => ({
|
|
31
|
-
link: [
|
|
33
|
+
link: [
|
|
34
|
+
{ key: "canonical", rel: "canonical", href: canonicalUrl.value },
|
|
35
|
+
{
|
|
36
|
+
key: "rss",
|
|
37
|
+
rel: "alternate",
|
|
38
|
+
type: "application/rss+xml",
|
|
39
|
+
title: fullTitle.value,
|
|
40
|
+
href: `${localizedPath("blog")}/rss.xml`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
32
43
|
}));
|
|
33
44
|
|
|
34
45
|
const { data: queriedPosts, error } = await useAsyncData(
|
package/app/pages/index.vue
CHANGED
|
@@ -98,9 +98,13 @@ async function copyInstallCommand() {
|
|
|
98
98
|
await copy(landing.value.install?.command ?? "");
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const landingTitle = computed(() => localize(config.site.name));
|
|
102
|
+
const landingDescription = computed(() => localize(config.site.description));
|
|
101
103
|
useSeoMeta({
|
|
102
|
-
title:
|
|
103
|
-
description:
|
|
104
|
+
title: landingTitle,
|
|
105
|
+
description: landingDescription,
|
|
106
|
+
ogTitle: landingTitle,
|
|
107
|
+
ogDescription: landingDescription,
|
|
104
108
|
});
|
|
105
109
|
</script>
|
|
106
110
|
|
package/content.js
CHANGED
|
@@ -25,31 +25,52 @@ const routeSlugs = {
|
|
|
25
25
|
//#region layer/content.ts
|
|
26
26
|
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected an ISO date (YYYY-MM-DD)");
|
|
27
27
|
const nonEmptyString = z.string().trim().min(1);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
28
|
+
/** Former public URLs of a page, as served: locale prefix and translated slugs included. */
|
|
29
|
+
const redirectFrom = z
|
|
30
|
+
.array(nonEmptyString.regex(/^\//, "redirectFrom entries must be absolute site paths"))
|
|
31
|
+
.optional();
|
|
32
|
+
/**
|
|
33
|
+
* Derives the sitemap lastmod from the authored date so it has one source of
|
|
34
|
+
* truth. Route records require normalized UTC ISO values.
|
|
35
|
+
*/
|
|
36
|
+
const withSitemapLastmod = (data, lastmod) =>
|
|
37
|
+
lastmod
|
|
38
|
+
? {
|
|
39
|
+
...data,
|
|
40
|
+
sitemap: { lastmod: `${lastmod}T00:00:00.000Z` },
|
|
41
|
+
}
|
|
42
|
+
: data;
|
|
43
|
+
const docsSchemaWithLastmod = z
|
|
44
|
+
.object({
|
|
45
|
+
title: z.string(),
|
|
46
|
+
description: z.string(),
|
|
47
|
+
icon: z.string().optional(),
|
|
48
|
+
badge: z.string().optional(),
|
|
49
|
+
updated: isoDate.optional(),
|
|
50
|
+
redirectFrom,
|
|
51
|
+
sidebar: z.enum(["section", "group"]).optional(),
|
|
52
|
+
navigation: z
|
|
53
|
+
.object({
|
|
54
|
+
title: z.string().optional(),
|
|
55
|
+
icon: z.string().optional(),
|
|
56
|
+
badge: z.string().optional(),
|
|
57
|
+
sidebar: z.enum(["section", "group"]).optional(),
|
|
58
|
+
})
|
|
59
|
+
.optional(),
|
|
60
|
+
})
|
|
61
|
+
.transform((data) => withSitemapLastmod(data, data.updated));
|
|
62
|
+
const blogSchemaWithLastmod = z
|
|
63
|
+
.object({
|
|
64
|
+
title: z.string(),
|
|
65
|
+
description: z.string(),
|
|
66
|
+
badge: z.string().optional(),
|
|
67
|
+
date: isoDate,
|
|
68
|
+
readingTime: nonEmptyString,
|
|
69
|
+
author: reference("authors"),
|
|
70
|
+
image: z.string().optional(),
|
|
71
|
+
redirectFrom,
|
|
72
|
+
})
|
|
73
|
+
.transform((data) => withSitemapLastmod(data, data.date));
|
|
53
74
|
const authorsSchema = z.object({
|
|
54
75
|
slug: z.string(),
|
|
55
76
|
name: z.string(),
|
|
@@ -95,7 +116,7 @@ function defineGinkoDocsConfig(options) {
|
|
|
95
116
|
markdown: true,
|
|
96
117
|
},
|
|
97
118
|
strict: true,
|
|
98
|
-
schema:
|
|
119
|
+
schema: docsSchemaWithLastmod,
|
|
99
120
|
});
|
|
100
121
|
const blog = defineCollection({
|
|
101
122
|
type: "page",
|
|
@@ -107,7 +128,7 @@ function defineGinkoDocsConfig(options) {
|
|
|
107
128
|
markdown: true,
|
|
108
129
|
},
|
|
109
130
|
strict: true,
|
|
110
|
-
schema:
|
|
131
|
+
schema: blogSchemaWithLastmod,
|
|
111
132
|
});
|
|
112
133
|
const authors = defineCollection({
|
|
113
134
|
type: "data",
|
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
|
|
60
|
-
type BlogCollection = ContentCollectionConfig<typeof
|
|
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:
|
|
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:
|
|
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,
|
|
98
|
-
aliases?: Record<string,
|
|
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
|
|
114
|
-
|
|
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 {
|
|
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.
|
|
46
|
+
version: "0.2.5",
|
|
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,6 +118,9 @@ 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
|
},
|
|
@@ -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: [
|
|
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
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "A Nuxt documentation layer powered by Ginko Content.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"content",
|
|
@@ -52,37 +52,37 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@iconify-json/circle-flags": "^1.2.10",
|
|
55
|
-
"@iconify-json/logos": "^1.2.
|
|
56
|
-
"@iconify-json/lucide": "^1.2.
|
|
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.
|
|
59
|
-
"@nuxt/image": "^2.
|
|
60
|
-
"@nuxt/kit": "^4.
|
|
61
|
-
"@nuxt/scripts": "^
|
|
62
|
-
"@nuxtjs/color-mode": "^4.0.
|
|
63
|
-
"@nuxtjs/i18n": "^10.
|
|
64
|
-
"@nuxtjs/mcp-toolkit": "0.
|
|
65
|
-
"@nuxtjs/robots": "^6.1.
|
|
66
|
-
"@nuxtjs/sitemap": "^8.
|
|
67
|
-
"@resvg/resvg-js": "^2.6.
|
|
68
|
-
"@shikijs/transformers": "^4.
|
|
69
|
-
"@tailwindcss/vite": "^4.3.
|
|
70
|
-
"@vueuse/core": "^14.
|
|
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.
|
|
76
|
-
"reka-ui": "^2.
|
|
77
|
-
"satori": "^0.
|
|
78
|
-
"shiki": "^4.
|
|
79
|
-
"tailwind-merge": "^3.
|
|
80
|
-
"tailwindcss": "^4.
|
|
75
|
+
"nuxt-og-image": "^6.7.7",
|
|
76
|
+
"reka-ui": "^2.10.3",
|
|
77
|
+
"satori": "^0.29.0",
|
|
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.
|
|
85
|
+
"@lupinum/ginko-content": ">=0.3.5 <0.4.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("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll('"', """);
|
|
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,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("&", "&")
|
|
25
|
+
.replaceAll("<", "<")
|
|
26
|
+
.replaceAll(">", ">")
|
|
27
|
+
.replaceAll('"', """)
|
|
28
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|
|
@@ -34,6 +34,15 @@ export interface GinkoDocsLink {
|
|
|
34
34
|
description?: GinkoDocsLocalizedText;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export type GinkoDocsSocialPlatform = "github" | "discord" | "linkedin";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A URL uses the platform's built-in label and icon. Pass an object to override
|
|
41
|
+
* either — `icon` accepts any Iconify name the consuming app has registered, so
|
|
42
|
+
* sites that ship brand marks are not limited to the layer's bundled icons.
|
|
43
|
+
*/
|
|
44
|
+
export type GinkoDocsSocialEntry = string | { href: string; label?: string; icon?: string };
|
|
45
|
+
|
|
37
46
|
export interface GinkoDocsHeroCodeTab {
|
|
38
47
|
label: GinkoDocsLocalizedText;
|
|
39
48
|
/** Iconify icon shown in the tab. */
|
|
@@ -77,6 +86,8 @@ export interface GinkoDocsAppConfig {
|
|
|
77
86
|
nav: {
|
|
78
87
|
/** "auto" derives Docs (+ Blog when blog routes exist); an array overrides entirely. */
|
|
79
88
|
links: "auto" | GinkoDocsLink[];
|
|
89
|
+
/** Render configured social links as icon buttons in the header instead of labelled rows. */
|
|
90
|
+
socialIcons: boolean;
|
|
80
91
|
};
|
|
81
92
|
banner: {
|
|
82
93
|
enabled: boolean;
|
|
@@ -96,10 +107,7 @@ export interface GinkoDocsAppConfig {
|
|
|
96
107
|
extensions?: GinkoDocsPlausibleExtension[];
|
|
97
108
|
};
|
|
98
109
|
};
|
|
99
|
-
social:
|
|
100
|
-
github?: string;
|
|
101
|
-
linkedin?: string;
|
|
102
|
-
};
|
|
110
|
+
social: Partial<Record<GinkoDocsSocialPlatform, GinkoDocsSocialEntry>>;
|
|
103
111
|
feedback: {
|
|
104
112
|
enabled: boolean;
|
|
105
113
|
};
|