@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/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.2 <0.4.0`
11
+ - Ginko Content `>=0.4.0-rc.1 <0.5.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.2
18
+ pnpm add -D @lupinum/ginko-docs@0.3.0-rc.1 @lupinum/ginko-content@0.4.0-rc.1
19
19
  ```
20
20
 
21
21
  Keep the public identity in one shared value:
package/app/app.config.ts CHANGED
@@ -19,7 +19,7 @@ export default {
19
19
  docsSidebarSwitcher: "tabs",
20
20
  lupinumAttribution: true,
21
21
  },
22
- nav: { links: "auto" },
22
+ nav: { links: "auto", socialIcons: false },
23
23
  banner: {
24
24
  enabled: false,
25
25
  id: "default",
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,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { Button } from "#ginko-docs/components/ui/button";
3
- import { computed, ref } from "vue";
4
- import { useI18n, useRoute } from "#imports";
3
+ import { computed, ref, watch } from "vue";
4
+ import { useI18n, useRouter } from "#imports";
5
5
  import { useGinkoAnalytics } from "#ginko-docs/composables/useGinkoAnalytics";
6
6
  import { useGinkoDocsConfig } from "#ginko-docs/composables/useGinkoDocsConfig";
7
7
  import { buildRepoIssueUrl } from "#ginko-docs/utils/repository";
@@ -15,7 +15,7 @@ withDefaults(
15
15
  },
16
16
  );
17
17
  const { t, locale } = useI18n();
18
- const route = useRoute();
18
+ const router = useRouter();
19
19
 
20
20
  const config = useGinkoDocsConfig();
21
21
  const analytics = useGinkoAnalytics();
@@ -24,20 +24,24 @@ const enabled = config.feedback.enabled && analytics.enabled;
24
24
  type Sentiment = "positive" | "negative";
25
25
 
26
26
  const sentiment = ref<Sentiment | null>(null);
27
+ const routePath = computed(() => router.currentRoute.value.path);
28
+ watch(routePath, () => {
29
+ sentiment.value = null;
30
+ });
27
31
 
28
32
  const issueUrl = computed(() => {
29
33
  const repository = config.repository;
30
34
  if (!repository) return null;
31
35
  return buildRepoIssueUrl(repository, {
32
- title: t("feedback.issueTitle", { path: route.path }),
33
- body: t("docs.issueBody", { path: route.path }),
36
+ title: t("feedback.issueTitle", { path: routePath.value }),
37
+ body: t("docs.issueBody", { path: routePath.value }),
34
38
  });
35
39
  });
36
40
 
37
41
  function selectSentiment(s: Sentiment) {
38
42
  if (sentiment.value !== null) return;
39
43
  analytics.track("docs-feedback", {
40
- path: route.path,
44
+ path: routePath.value,
41
45
  helpful: s === "positive" ? "yes" : "no",
42
46
  locale: locale.value,
43
47
  });
@@ -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
- <img
17
- v-if="src"
18
- :src="src"
19
- :alt="alt ?? ''"
20
- :title="title"
21
- :width="width"
22
- :height="height"
23
- loading="lazy"
24
- decoding="async"
25
- :class="cn('content-prose-image', props.class)"
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
- <span v-if="socialLinks.length" class="ml-auto flex items-center gap-2">
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="outline" class="h-9 gap-1.5 px-2.5" :aria-label="t('nav.language')">
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
- <span
155
- class="inline-flex h-9 w-[4.25rem] rounded-md border border-border bg-background"
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 { GinkoDocsAppConfig, GinkoDocsLink } from "../../shared/types/app-config";
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;
@@ -10,28 +10,32 @@ export interface GinkoAnalytics {
10
10
 
11
11
  /**
12
12
  * Plausible analytics via the Nuxt Scripts registry script. Fully disabled
13
- * unless `ginkoDocs.analytics.plausible.domain` is configured without a
14
- * domain nothing loads and `track()` is a no-op, so callers never guard.
13
+ * unless `ginkoDocs.analytics.plausible.scriptId` is configured. Without an
14
+ * ID, no script loads and `track()` is a no-op, so callers never guard.
15
15
  */
16
16
  export function useGinkoAnalytics(): GinkoAnalytics {
17
17
  const config = useGinkoDocsConfig();
18
- const plausible = config.analytics?.plausible;
18
+ const scriptId = config.analytics?.plausible?.scriptId?.trim();
19
19
 
20
- if (!plausible?.domain) {
20
+ if (!scriptId) {
21
21
  return { enabled: false, track: () => {} };
22
22
  }
23
23
 
24
24
  const script = useScriptPlausibleAnalytics({
25
- domain: plausible.domain,
26
- extension: plausible.extensions ?? ["outbound-links"],
27
- ...(plausible.scriptSrc ? { scriptInput: { src: plausible.scriptSrc } } : {}),
25
+ scriptId,
26
+ // The site ID comes from app.config at runtime. Keep the vendor script
27
+ // external so Nuxt Scripts does not bundle its legacy fallback URL during
28
+ // the build-time transform.
29
+ scriptOptions: { bundle: false },
28
30
  });
29
31
 
30
32
  return {
31
33
  enabled: true,
32
34
  track: (event, props) => {
33
35
  if (!import.meta.client) return;
34
- script.proxy.plausible(event, props ? { props } : undefined);
36
+ const plausible = script.proxy.plausible;
37
+ if (typeof plausible !== "function") return;
38
+ plausible(event, props ? { props } : undefined);
35
39
  },
36
40
  };
37
41
  }
@@ -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 { resolveBanner, resolveMainNav, type NavItem } from "./site-navigation.utils";
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 { filterTocByDepth, flattenTocLinks, getMarkdownTocLinks } from "#ginko-docs/utils/content";
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
- const scrollViewportClass =
68
- "size-full rounded-[inherit] p-4 pt-2 overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,white_12px,white_calc(100%-12px),transparent)]";
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.icon === "lucide:github");
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: [{ key: "canonical", rel: "canonical", href: canonicalUrl.value }],
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
 
@@ -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: [{ key: "canonical", rel: "canonical", href: canonicalUrl.value }],
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(
@@ -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: computed(() => localize(config.site.name)),
103
- description: computed(() => localize(config.site.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
- const docsSchema = z.object({
29
- title: z.string(),
30
- description: z.string(),
31
- icon: z.string().optional(),
32
- badge: z.string().optional(),
33
- updated: isoDate.optional(),
34
- sidebar: z.enum(["section", "group"]).optional(),
35
- navigation: z
36
- .object({
37
- title: z.string().optional(),
38
- icon: z.string().optional(),
39
- badge: z.string().optional(),
40
- sidebar: z.enum(["section", "group"]).optional(),
41
- })
42
- .optional(),
43
- });
44
- const blogSchema = z.object({
45
- title: z.string(),
46
- description: z.string(),
47
- badge: z.string().optional(),
48
- date: isoDate,
49
- readingTime: nonEmptyString,
50
- author: reference("authors"),
51
- image: z.string().optional(),
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: docsSchema,
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: blogSchema,
131
+ schema: blogSchemaWithLastmod,
111
132
  });
112
133
  const authors = defineCollection({
113
134
  type: "data",