@lupinum/ginko-docs 0.3.0 → 0.4.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +12 -4
  2. package/app/app.config.ts +2 -0
  3. package/app/app.vue +26 -2
  4. package/app/assets/css/tailwind.css +24 -0
  5. package/app/assets/css/theme-presets.css +141 -0
  6. package/app/components/OgImage/GinkoDocs.satori.vue +131 -10
  7. package/app/components/site/SiteBanner.vue +2 -2
  8. package/app/components/site/SiteFooter.vue +1 -1
  9. package/app/components/site/SiteHeader.vue +1 -1
  10. package/app/components/site/SiteSocialLinks.vue +9 -1
  11. package/app/components/ui/badge/index.ts +1 -1
  12. package/app/components/ui/button/index.ts +1 -1
  13. package/app/composables/site-navigation.utils.ts +3 -7
  14. package/app/composables/useLocalizedPath.ts +3 -2
  15. package/app/error.vue +18 -8
  16. package/app/features/docs/components/DocsPageContent.vue +2 -2
  17. package/app/features/docs/components/DocsSidebarItem.vue +2 -2
  18. package/app/features/docs/components/DocsSidebarList.vue +1 -1
  19. package/app/features/docs/components/DocsToc.vue +2 -2
  20. package/app/features/search/command-center.ts +33 -0
  21. package/app/features/search/components/SiteCommandCenter.vue +25 -9
  22. package/app/features/search/components/SiteSearchPageHighlight.vue +4 -2
  23. package/app/features/search/useCommandCenter.ts +2 -2
  24. package/app/layouts/docs.vue +1 -1
  25. package/app/pages/index.vue +22 -3
  26. package/app/plugins/ginko-docs-theme.ts +1 -0
  27. package/content-collections.ts +13 -5
  28. package/content.js +16 -10
  29. package/content.ts +25 -8
  30. package/i18n/messages/global/command.ts +1 -0
  31. package/icon-bundle.ts +24 -4
  32. package/index.d.ts +1 -0
  33. package/modules/feature-routing.ts +47 -22
  34. package/modules/syntax-highlighting.ts +5 -0
  35. package/nuxt.config.ts +7 -7
  36. package/package.json +9 -4
  37. package/runtime/generate-favicons.mjs +124 -0
  38. package/server/plugins/sitemap-policy.ts +19 -0
  39. package/server/routes/.well-known/ai-catalog.json.get.ts +10 -0
  40. package/server/routes/[locale]/blog/rss.xml.get.ts +6 -1
  41. package/server/routes/blog/rss.xml.get.ts +10 -2
  42. package/server/routes/mcp/server-card.get.ts +14 -0
  43. package/server/utils/blog-feed.ts +24 -6
  44. package/server/utils/mcp-server-card.ts +45 -0
  45. package/server/utils/redirects.ts +10 -3
  46. package/server/utils/redirects.utils.ts +10 -7
  47. package/server/utils/sitemap.ts +50 -0
  48. package/shared/theme-presets.ts +3 -0
  49. package/shared/types/app-config.ts +20 -2
  50. package/shared/types/nuxt-config.ts +3 -0
package/app/error.vue CHANGED
@@ -3,8 +3,8 @@ import { localizedRoutes } from "../i18n/routes";
3
3
  import { messages } from "../i18n/messages/index";
4
4
  import { normalizeAppError } from "./lib/errors";
5
5
  import { computed } from "vue";
6
- import { clearError, useHead } from "#imports";
7
- import { localeFromPath, localizedPath } from "../i18n/locales";
6
+ import { clearError, useHead, useRequestURL, useRuntimeConfig } from "#imports";
7
+ import { defaultLocale, isLocaleCode, localeFromPath, localizedPath } from "../i18n/locales";
8
8
 
9
9
  type NuxtErrorLike = {
10
10
  message?: string;
@@ -19,17 +19,26 @@ const props = defineProps<{
19
19
  error: NuxtErrorLike;
20
20
  }>();
21
21
 
22
+ const configuredPrimaryLocale = useRuntimeConfig().public.ginkoDocs?.primaryLocale;
23
+ const primaryLocale =
24
+ typeof configuredPrimaryLocale === "string" && isLocaleCode(configuredPrimaryLocale)
25
+ ? configuredPrimaryLocale
26
+ : defaultLocale;
27
+
22
28
  const normalized = computed(() => normalizeAppError(props.error));
23
- const requestPath = computed(
24
- () => props.error?.url || (import.meta.client ? window.location.pathname : "/"),
25
- );
26
- const locale = computed(() => localeFromPath(requestPath.value));
29
+ const requestUrl = useRequestURL();
30
+ const requestPath = computed(() => props.error?.url || requestUrl.pathname);
31
+ const locale = computed(() => localeFromPath(requestPath.value, primaryLocale));
27
32
  const copy = computed(() => {
28
33
  const catalog = messages[locale.value].errors;
29
34
  return catalog[normalized.value.kind] ?? catalog.unavailable;
30
35
  });
31
- const homePath = computed(() => localizedPath(locale.value, localizedRoutes[locale.value].home));
32
- const docsPath = computed(() => localizedPath(locale.value, localizedRoutes[locale.value].docs));
36
+ const homePath = computed(() =>
37
+ localizedPath(locale.value, localizedRoutes[locale.value].home, primaryLocale),
38
+ );
39
+ const docsPath = computed(() =>
40
+ localizedPath(locale.value, localizedRoutes[locale.value].docs, primaryLocale),
41
+ );
33
42
  const title = computed(() => `${normalized.value.statusCode} - ${copy.value.title}`);
34
43
  // Keep the full site chrome for content-level errors (404 and friends); hard
35
44
  // server failures fall back to the bare page so the error screen cannot crash.
@@ -37,6 +46,7 @@ const showChrome = computed(() => normalized.value.kind !== "server");
37
46
 
38
47
  useHead(() => ({
39
48
  title: title.value,
49
+ htmlAttrs: { lang: locale.value },
40
50
  meta: [{ name: "robots", content: "noindex, nofollow" }],
41
51
  }));
42
52
 
@@ -165,7 +165,7 @@ function scrollToTop() {
165
165
  <article v-if="page">
166
166
  <div class="flex items-start justify-between gap-4 sm:gap-6">
167
167
  <div class="min-w-0">
168
- <DocsBreadcrumb class="mb-2" :items="visibleTrail" />
168
+ <DocsBreadcrumb v-if="visibleTrail.length > 1" class="mb-2" :items="visibleTrail" />
169
169
  <h1
170
170
  class="font-heading text-[27px] leading-[1.15] font-semibold tracking-[-0.02em] text-balance text-foreground"
171
171
  >
@@ -208,7 +208,7 @@ function scrollToTop() {
208
208
 
209
209
  <aside
210
210
  ref="tocAside"
211
- class="sticky top-[var(--site-header-height)] hidden h-[calc(100dvh-var(--site-header-height)-var(--site-banner-height,0px))] w-[var(--docs-toc-width)] shrink-0 flex-col overflow-y-auto pt-10 pr-4 pb-6 xl:flex"
211
+ class="docs-toc-shell sticky top-[var(--site-header-height)] h-[calc(100dvh-var(--site-header-height)-var(--site-banner-height,0px))] w-[var(--docs-toc-width)] shrink-0 flex-col overflow-y-auto pt-10 pr-4 pb-6"
212
212
  :aria-label="t('docs.toc')"
213
213
  >
214
214
  <DocsToc class="mb-6" :items="tocItems" :active-ids="activeIds" />
@@ -48,13 +48,13 @@ const linkRowClass = computed(() =>
48
48
  "group relative flex w-full flex-row items-center gap-2 text-start [overflow-wrap:anywhere] text-muted-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
49
49
  "[&_svg]:size-4 [&_svg]:shrink-0",
50
50
  "hover:bg-accent/50 hover:text-accent-foreground/80 hover:transition-none",
51
- "data-[active=true]:bg-primary/10 data-[active=true]:text-primary data-[active=true]:font-medium data-[active=true]:hover:transition-colors",
51
+ "data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=true]:font-medium data-[active=true]:hover:bg-muted data-[active=true]:hover:transition-colors",
52
52
  props.depth === 0
53
53
  ? "rounded-[7px] px-2 py-[5.5px] text-[13px]"
54
54
  : cn(
55
55
  "rounded-e-[7px] border-s border-border py-[5px] pe-2 text-[12.5px]",
56
56
  "hover:border-s-muted-foreground/40",
57
- "data-[active=true]:border-s-primary",
57
+ "data-[active=true]:border-s-brand",
58
58
  paddingClass.value,
59
59
  ),
60
60
  ),
@@ -21,7 +21,7 @@ const emit = defineEmits<{
21
21
  class="flex min-h-9 w-full items-center gap-2 rounded-lg px-2 text-start text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
22
22
  :class="
23
23
  activeId === section.id
24
- ? 'bg-accent font-medium text-foreground'
24
+ ? 'bg-muted font-medium text-foreground'
25
25
  : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
26
26
  "
27
27
  :aria-pressed="activeId === section.id"
@@ -76,7 +76,7 @@ function scrollToHeading(id: string) {
76
76
 
77
77
  <div class="relative border-l border-border pl-3">
78
78
  <div
79
- class="pointer-events-none absolute -left-px w-0.5 rounded-full bg-primary transition-all duration-200 ease-out"
79
+ class="pointer-events-none absolute -left-px w-0.5 rounded-full bg-brand transition-all duration-200 ease-out"
80
80
  :style="indicatorStyle"
81
81
  />
82
82
 
@@ -91,7 +91,7 @@ function scrollToHeading(id: string) {
91
91
  cn(
92
92
  item.depth === 3 ? 'pl-3' : (item.depth ?? 2) >= 4 ? 'pl-6' : '',
93
93
  activeIds.includes(item.id)
94
- ? 'font-medium text-primary'
94
+ ? 'font-medium text-foreground'
95
95
  : 'text-muted-foreground hover:text-foreground',
96
96
  )
97
97
  "
@@ -24,6 +24,39 @@ export interface CommandCenterGroupResult {
24
24
  export const MAX_RECENT_ITEMS = 5;
25
25
  export const PAGE_HIGHLIGHT_STORAGE_KEY = "site-command-center-page-highlight";
26
26
 
27
+ const SEARCH_HIGHLIGHT_STOP_WORDS = new Set([
28
+ "a",
29
+ "an",
30
+ "and",
31
+ "der",
32
+ "die",
33
+ "das",
34
+ "ein",
35
+ "eine",
36
+ "for",
37
+ "in",
38
+ "of",
39
+ "or",
40
+ "the",
41
+ "to",
42
+ "und",
43
+ "von",
44
+ "zu",
45
+ ]);
46
+
47
+ export function getSearchHighlightTerms(query: string): string[] {
48
+ const terms = [...new Set(query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean))];
49
+ const meaningfulTerms = terms.filter(
50
+ (term) => term.length > 1 && !SEARCH_HIGHLIGHT_STOP_WORDS.has(term),
51
+ );
52
+
53
+ return meaningfulTerms.length > 0 ? meaningfulTerms : terms;
54
+ }
55
+
56
+ export function shouldShowSearchResultBadges(items: CommandCenterItem[]): boolean {
57
+ return new Set(items.map((item) => item.badge).filter(Boolean)).size > 1;
58
+ }
59
+
27
60
  export function dedupeCommandCenterItems(items: CommandCenterItem[]) {
28
61
  const seen = new Set<string>();
29
62
  return items.filter((item) => {
@@ -2,6 +2,10 @@
2
2
  import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from "vue";
3
3
  import { useI18n, useRoute } from "#imports";
4
4
  import { useCommandCenter } from "#ginko-docs/features/search/useCommandCenter";
5
+ import {
6
+ getSearchHighlightTerms,
7
+ shouldShowSearchResultBadges,
8
+ } from "#ginko-docs/features/search/command-center";
5
9
  import {
6
10
  DialogContent,
7
11
  DialogDescription,
@@ -21,10 +25,13 @@ const { open, query, groupedItems, searchPending, searchError, searchIsEmpty, se
21
25
 
22
26
  const visibleItems = computed(() => groupedItems.value.flatMap((g) => g.items));
23
27
  const isSearching = computed(() => query.value.trim().length > 0);
28
+ const showResultBadges = computed(
29
+ () => !isSearching.value || shouldShowSearchResultBadges(visibleItems.value),
30
+ );
24
31
 
25
32
  // ── text highlighting ──────────────────────────────────────────────────────
26
33
  function highlightTokens(text: string, q: string): { text: string; match: boolean }[] {
27
- const terms = q.trim().split(/\s+/).filter(Boolean);
34
+ const terms = getSearchHighlightTerms(q);
28
35
  if (terms.length === 0) return [{ text, match: false }];
29
36
  const pattern = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
30
37
  return text
@@ -123,7 +130,7 @@ onMounted(async () => {
123
130
  class="fixed inset-0 z-50 bg-overlay backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
124
131
  />
125
132
  <DialogContent
126
- class="fixed top-[8vh] left-1/2 z-50 flex max-h-[84vh] w-[min(94vw,46rem)] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:top-[10vh]"
133
+ class="fixed top-[8vh] left-1/2 z-50 flex max-h-[80vh] w-[min(94vw,44rem)] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:top-[10vh]"
127
134
  @keydown="handleListNavigation"
128
135
  >
129
136
  <VisuallyHidden>
@@ -165,7 +172,9 @@ onMounted(async () => {
165
172
  </div>
166
173
 
167
174
  <!-- Results -->
168
- <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2">
175
+ <div
176
+ class="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2 [scrollbar-color:var(--border)_transparent]"
177
+ >
169
178
  <div
170
179
  v-if="isSearching && searchPending"
171
180
  class="flex items-center justify-center gap-2 px-6 py-12 text-sm text-muted-foreground"
@@ -190,6 +199,13 @@ onMounted(async () => {
190
199
  <div class="flex items-center gap-2 px-2 pt-1.5 pb-1">
191
200
  <span class="text-[11px] font-medium text-muted-foreground">{{ group.title }}</span>
192
201
  <div class="flex-1 border-t border-border/60" />
202
+ <span
203
+ v-if="group.id === 'search'"
204
+ class="text-[11px] tabular-nums text-muted-foreground"
205
+ aria-live="polite"
206
+ >
207
+ {{ t("command.resultCount", { count: group.items.length }) }}
208
+ </span>
193
209
  </div>
194
210
 
195
211
  <button
@@ -198,7 +214,7 @@ onMounted(async () => {
198
214
  type="button"
199
215
  :data-item="item.id"
200
216
  :aria-label="itemLabel(item)"
201
- class="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left transition-colors outline-none"
217
+ class="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-colors outline-none"
202
218
  :class="
203
219
  highlightedId === item.id ? 'bg-accent text-accent-foreground' : 'text-foreground'
204
220
  "
@@ -208,7 +224,7 @@ onMounted(async () => {
208
224
  >
209
225
  <!-- Icon -->
210
226
  <div
211
- class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50 transition-colors"
227
+ class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50 transition-colors"
212
228
  :class="highlightedId === item.id ? 'border-border/80 bg-background' : ''"
213
229
  >
214
230
  <Icon
@@ -235,14 +251,14 @@ onMounted(async () => {
235
251
  >
236
252
  <mark
237
253
  v-if="token.match"
238
- class="rounded-[3px] bg-primary/15 font-medium text-primary not-italic"
254
+ class="rounded-[3px] bg-accent-yellow px-0.5 font-medium text-accent-yellow-foreground not-italic"
239
255
  >{{ token.text }}</mark
240
256
  ><template v-else>{{ token.text }}</template>
241
257
  </template>
242
258
  </span>
243
259
  <span
244
- v-if="item.badge"
245
- class="shrink-0 rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary"
260
+ v-if="item.badge && showResultBadges"
261
+ class="shrink-0 rounded-full bg-muted px-1.5 py-px text-[10px] font-medium text-muted-foreground"
246
262
  >
247
263
  {{ item.badge }}
248
264
  </span>
@@ -263,7 +279,7 @@ onMounted(async () => {
263
279
  >
264
280
  <mark
265
281
  v-if="token.match"
266
- class="rounded-[3px] bg-primary/15 font-medium text-primary not-italic"
282
+ class="rounded-[3px] bg-accent-yellow px-0.5 font-medium text-accent-yellow-foreground not-italic"
267
283
  >{{ token.text }}</mark
268
284
  ><template v-else>{{ token.text }}</template>
269
285
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { nextTick, onBeforeUnmount, watch } from "vue";
3
3
  import { useRoute } from "#imports";
4
+ import { getSearchHighlightTerms } from "#ginko-docs/features/search/command-center";
4
5
 
5
6
  const STORAGE_KEY = "site-command-center-page-highlight";
6
7
  const HIGHLIGHT_SELECTOR = "mark[data-search-page-highlight]";
@@ -65,7 +66,8 @@ function highlightPattern(root: Element, pattern: RegExp) {
65
66
 
66
67
  const mark = document.createElement("mark");
67
68
  mark.dataset.searchPageHighlight = "";
68
- mark.className = "rounded-[3px] bg-primary/15 px-0.5 text-primary";
69
+ mark.className =
70
+ "rounded-[3px] bg-accent-yellow px-0.5 font-medium text-accent-yellow-foreground";
69
71
  mark.textContent = value;
70
72
  fragment.append(mark);
71
73
 
@@ -83,7 +85,7 @@ function escapePattern(value: string) {
83
85
 
84
86
  function buildPatterns(query: string) {
85
87
  const phrase = query.trim().replace(/\s+/g, " ");
86
- const tokens = phrase.split(/\s+/).filter((token) => token.length > 2);
88
+ const tokens = getSearchHighlightTerms(phrase);
87
89
  const phrasePattern = phrase.split(/\s+/).map(escapePattern).join("\\s+");
88
90
 
89
91
  return [
@@ -71,7 +71,7 @@ export async function useCommandCenter() {
71
71
  // Nuxt/i18n composable after an awaited operation loses the setup context.
72
72
  const docsNavigationResult = useDocsNavigation();
73
73
  const contentSearchResult = useContentSearch({
74
- limit: 12,
74
+ limit: 8,
75
75
  locale: () => locale.value,
76
76
  });
77
77
  const [
@@ -190,7 +190,7 @@ export async function useCommandCenter() {
190
190
  href: github.href,
191
191
  external: true,
192
192
  group: "actions" as const,
193
- icon: github.icon ?? "lucide:github",
193
+ icon: github.icon ?? "ginko-social:github",
194
194
  keywords: ["repo", "source", "code"],
195
195
  },
196
196
  ]
@@ -6,7 +6,7 @@
6
6
 
7
7
  <div class="mx-auto flex w-full max-w-screen-2xl min-w-0 flex-1">
8
8
  <div
9
- class="pointer-events-none sticky top-[var(--site-header-height)] z-20 hidden h-[calc(100dvh-var(--site-header-height)-var(--site-banner-height,0px))] w-[var(--docs-sidebar-width)] shrink-0 overflow-hidden *:pointer-events-auto md:block"
9
+ class="docs-sidebar-shell pointer-events-none sticky top-[var(--site-header-height)] z-20 h-[calc(100dvh-var(--site-header-height)-var(--site-banner-height,0px))] w-[var(--docs-sidebar-width)] shrink-0 overflow-hidden *:pointer-events-auto"
10
10
  >
11
11
  <DocsSidebar />
12
12
  </div>
@@ -69,6 +69,11 @@ const heroCodeTabs = computed(() => {
69
69
  return [];
70
70
  });
71
71
 
72
+ const hasWideHeroCode = computed(() => {
73
+ const media = landing.value.heroMedia;
74
+ return media?.type !== "image" && media?.layout === "wide";
75
+ });
76
+
72
77
  // Feature icon tiles cycle through the theme-adaptive hero accent pairs.
73
78
  const FEATURE_TINTS = [
74
79
  { tile: "bg-(--hero-blue-muted)", icon: "text-(--hero-blue-dark)" },
@@ -127,7 +132,7 @@ useSeoMeta({
127
132
  <div class="mt-10 flex flex-wrap items-center gap-3">
128
133
  <NuxtLink
129
134
  :to="landing.primary.to"
130
- class="inline-flex h-11 items-center gap-2 rounded-md bg-primary px-5 text-sm font-semibold text-primary-foreground transition-transform hover:-translate-y-0.5"
135
+ class="inline-flex h-11 items-center gap-2 rounded-md bg-brand px-5 text-sm font-semibold text-brand-foreground transition-transform hover:-translate-y-0.5"
131
136
  >
132
137
  {{ landing.primary.label }}
133
138
  <Icon name="lucide:arrow-right" class="size-4" aria-hidden="true" />
@@ -174,7 +179,11 @@ useSeoMeta({
174
179
  width="576"
175
180
  loading="lazy"
176
181
  />
177
- <SiteHeroCode v-else-if="heroCodeTabs.length" :tabs="heroCodeTabs" />
182
+ <SiteHeroCode
183
+ v-else-if="heroCodeTabs.length"
184
+ :tabs="heroCodeTabs"
185
+ :class="hasWideHeroCode && 'site-hero-code-wide'"
186
+ />
178
187
  </div>
179
188
  </div>
180
189
  </section>
@@ -240,7 +249,7 @@ useSeoMeta({
240
249
  <div class="mt-9 flex flex-wrap items-center justify-center gap-3">
241
250
  <NuxtLink
242
251
  :to="landing.cta.primary.to"
243
- class="inline-flex h-11 items-center gap-2 rounded-md bg-primary px-5 text-sm font-semibold text-primary-foreground transition-transform hover:-translate-y-0.5"
252
+ class="inline-flex h-11 items-center gap-2 rounded-md bg-brand px-5 text-sm font-semibold text-brand-foreground transition-transform hover:-translate-y-0.5"
244
253
  >
245
254
  {{ landing.cta.primary.label }}
246
255
  <Icon name="lucide:arrow-right" class="size-4" aria-hidden="true" />
@@ -257,3 +266,13 @@ useSeoMeta({
257
266
  </section>
258
267
  </div>
259
268
  </template>
269
+
270
+ <style scoped>
271
+ /* Keep the hero grid intact; only use spare space toward the viewport edge. */
272
+ @media (min-width: 80rem) {
273
+ .site-hero-code-wide {
274
+ width: min(48.75rem, calc(50vw - 2rem));
275
+ max-width: none;
276
+ }
277
+ }
278
+ </style>
@@ -5,6 +5,7 @@ export default defineNuxtPlugin(() => {
5
5
 
6
6
  useHead({
7
7
  htmlAttrs: {
8
+ "data-theme-preset": theme.preset ?? "default",
8
9
  "data-neutral": theme.neutral,
9
10
  "data-primary": theme.primary,
10
11
  "data-code-blocks": theme.codeBlocks,
@@ -1,4 +1,5 @@
1
1
  import { defineCollection, defineContentConfig, reference } from "@lupinum/ginko-content/config";
2
+ import type { ContentAgentCollectionConfig } from "@lupinum/ginko-content/config";
2
3
  import { z } from "zod";
3
4
  import { routeSlugs } from "./shared/route-slugs";
4
5
 
@@ -51,15 +52,22 @@ const authorsSchema = z.object({
51
52
  links: z.array(z.object({ label: z.string(), href: z.string() })).optional(),
52
53
  });
53
54
 
54
- export function createGinkoDocsCollections(i18n: boolean) {
55
+ export function createGinkoDocsCollections(
56
+ locales: readonly ["en"] | readonly ["de"] | readonly ["en", "de"] = ["en"],
57
+ docsMarkdown: ContentAgentCollectionConfig["markdown"] = true,
58
+ ) {
59
+ const i18n = locales.length === 2;
60
+ const primaryLocale = locales[0];
55
61
  return defineContentConfig({
56
62
  collections: {
57
63
  docs: defineCollection({
58
64
  type: "page",
59
- source: i18n ? "{1.docs,1.dokumentation}/**/*.md" : "docs/**/*.md",
65
+ source: i18n
66
+ ? "{1.docs,1.dokumentation}/**/*.md"
67
+ : `${routeSlugs.docs[primaryLocale].slice(1)}/**/*.md`,
60
68
  i18n: i18n ? true : undefined,
61
- route: i18n ? routeSlugs.docs : routeSlugs.docs.en,
62
- agent: { section: "optional", markdown: true },
69
+ route: i18n ? routeSlugs.docs : routeSlugs.docs[primaryLocale],
70
+ agent: { section: "optional", markdown: docsMarkdown },
63
71
  strict: true,
64
72
  schema: docsSchemaWithLastmod,
65
73
  }),
@@ -67,7 +75,7 @@ export function createGinkoDocsCollections(i18n: boolean) {
67
75
  type: "page",
68
76
  source: "2.blog/*.md",
69
77
  i18n: i18n ? true : undefined,
70
- route: i18n ? routeSlugs.blog : routeSlugs.blog.en,
78
+ route: i18n ? routeSlugs.blog : routeSlugs.blog[primaryLocale],
71
79
  agent: { section: "blog", markdown: true },
72
80
  strict: true,
73
81
  schema: blogSchemaWithLastmod,
package/content.js CHANGED
@@ -81,17 +81,21 @@ const authorsSchema = z.object({
81
81
  )
82
82
  .optional(),
83
83
  });
84
- function createGinkoDocsCollections(i18n) {
84
+ function createGinkoDocsCollections(locales = ["en"], docsMarkdown = true) {
85
+ const i18n = locales.length === 2;
86
+ const primaryLocale = locales[0];
85
87
  return defineContentConfig({
86
88
  collections: {
87
89
  docs: defineCollection({
88
90
  type: "page",
89
- source: i18n ? "{1.docs,1.dokumentation}/**/*.md" : "docs/**/*.md",
91
+ source: i18n
92
+ ? "{1.docs,1.dokumentation}/**/*.md"
93
+ : `${routeSlugs.docs[primaryLocale].slice(1)}/**/*.md`,
90
94
  i18n: i18n ? true : void 0,
91
- route: i18n ? routeSlugs.docs : routeSlugs.docs.en,
95
+ route: i18n ? routeSlugs.docs : routeSlugs.docs[primaryLocale],
92
96
  agent: {
93
97
  section: "optional",
94
- markdown: true,
98
+ markdown: docsMarkdown,
95
99
  },
96
100
  strict: true,
97
101
  schema: docsSchemaWithLastmod,
@@ -100,7 +104,7 @@ function createGinkoDocsCollections(i18n) {
100
104
  type: "page",
101
105
  source: "2.blog/*.md",
102
106
  i18n: i18n ? true : void 0,
103
- route: i18n ? routeSlugs.blog : routeSlugs.blog.en,
107
+ route: i18n ? routeSlugs.blog : routeSlugs.blog[primaryLocale],
104
108
  agent: {
105
109
  section: "blog",
106
110
  markdown: true,
@@ -125,11 +129,10 @@ function defineGinkoDocsConfig(options) {
125
129
  const locales = options.locales ?? ["en"];
126
130
  if (
127
131
  !(locales.length === 1
128
- ? locales[0] === "en"
132
+ ? locales[0] === "en" || locales[0] === "de"
129
133
  : locales.length === 2 && locales[0] === "en" && locales[1] === "de")
130
134
  )
131
- throw new TypeError('locales must be exactly ["en"] or ["en", "de"]');
132
- const i18n = locales.length === 2;
135
+ throw new TypeError('locales must be exactly ["en"], ["de"], or ["en", "de"]');
133
136
  const metadata = defineAgentMetadataFields([
134
137
  "title",
135
138
  "description",
@@ -141,13 +144,14 @@ function defineGinkoDocsConfig(options) {
141
144
  "source",
142
145
  "updated",
143
146
  ]);
144
- const { docs, blog, authors } = createGinkoDocsCollections(i18n);
147
+ const { docs, blog, authors } = createGinkoDocsCollections(locales, options.agent?.documentation);
145
148
  const config = {
146
149
  agent: {
147
150
  site: {
148
151
  title: options.site.name,
149
152
  description: options.site.description,
150
- url: options.site.url,
153
+ whenToUse: options.site.whenToUse,
154
+ ...(options.site.whenNotToUse ? { whenNotToUse: options.site.whenNotToUse } : {}),
151
155
  },
152
156
  markdown: {
153
157
  metadata: {
@@ -156,6 +160,7 @@ function defineGinkoDocsConfig(options) {
156
160
  },
157
161
  },
158
162
  sections: [
163
+ ...(options.agent?.sections ?? []),
159
164
  ...(options.blog
160
165
  ? [
161
166
  defineAgentSection({
@@ -177,6 +182,7 @@ function defineGinkoDocsConfig(options) {
177
182
  order: 100,
178
183
  }),
179
184
  ],
185
+ ...(options.agent?.pages ? { pages: [...options.agent.pages] } : {}),
180
186
  },
181
187
  };
182
188
  if (options.blog)
package/content.ts CHANGED
@@ -1,18 +1,33 @@
1
1
  import {
2
+ defineAgentAppPage,
2
3
  defineAgentMetadataFields,
3
4
  defineAgentSection,
4
5
  defineContentConfig,
5
6
  } from "@lupinum/ginko-content/config";
6
- import type { ContentConfig } from "@lupinum/ginko-content/config";
7
+ import type { ContentAgentMarkdownOptions, ContentConfig } from "@lupinum/ginko-content/config";
7
8
  import { createGinkoDocsCollections } from "./content-collections";
8
9
 
10
+ type GinkoDocsAgentPage = Parameters<typeof defineAgentAppPage>[0];
11
+ type GinkoDocsAgentSection = Parameters<typeof defineAgentSection>[0];
12
+
13
+ export interface GinkoDocsAgentOptions {
14
+ /** Control whether authored documentation appears in compact and full indexes. */
15
+ documentation?: ContentAgentMarkdownOptions;
16
+ /** Add compact index pages that point agents to canonical raw Markdown. */
17
+ pages?: readonly GinkoDocsAgentPage[];
18
+ /** Add sections before the built-in blog and optional documentation sections. */
19
+ sections?: readonly GinkoDocsAgentSection[];
20
+ }
21
+
9
22
  export interface GinkoDocsContentOptions {
10
23
  site: {
11
24
  name: string | { en: string; de: string };
12
25
  description: string | { en: string; de: string };
13
- url: string;
26
+ whenToUse: string | { en: string; de: string };
27
+ whenNotToUse?: string | { en: string; de: string };
14
28
  };
15
- locales?: readonly ["en"] | readonly ["en", "de"];
29
+ agent?: GinkoDocsAgentOptions;
30
+ locales?: readonly ["en"] | readonly ["de"] | readonly ["en", "de"];
16
31
  blog?: boolean;
17
32
  }
18
33
 
@@ -37,12 +52,11 @@ export function defineGinkoDocsConfig(
37
52
  const locales = options.locales ?? ["en"];
38
53
  const validLocales =
39
54
  locales.length === 1
40
- ? locales[0] === "en"
55
+ ? locales[0] === "en" || locales[0] === "de"
41
56
  : locales.length === 2 && locales[0] === "en" && locales[1] === "de";
42
57
  if (!validLocales) {
43
- throw new TypeError('locales must be exactly ["en"] or ["en", "de"]');
58
+ throw new TypeError('locales must be exactly ["en"], ["de"], or ["en", "de"]');
44
59
  }
45
- const i18n = locales.length === 2;
46
60
  const metadata = defineAgentMetadataFields([
47
61
  "title",
48
62
  "description",
@@ -54,16 +68,18 @@ export function defineGinkoDocsConfig(
54
68
  "source",
55
69
  "updated",
56
70
  ]);
57
- const { docs, blog, authors } = createGinkoDocsCollections(i18n);
71
+ const { docs, blog, authors } = createGinkoDocsCollections(locales, options.agent?.documentation);
58
72
  const config = {
59
73
  agent: {
60
74
  site: {
61
75
  title: options.site.name,
62
76
  description: options.site.description,
63
- url: options.site.url,
77
+ whenToUse: options.site.whenToUse,
78
+ ...(options.site.whenNotToUse ? { whenNotToUse: options.site.whenNotToUse } : {}),
64
79
  },
65
80
  markdown: { metadata: { enabled: true, defaultFields: metadata } },
66
81
  sections: [
82
+ ...(options.agent?.sections ?? []),
67
83
  ...(options.blog
68
84
  ? [defineAgentSection({ id: "blog", title: { en: "Blog", de: "Blog" }, order: 40 })]
69
85
  : []),
@@ -73,6 +89,7 @@ export function defineGinkoDocsConfig(
73
89
  order: 100,
74
90
  }),
75
91
  ],
92
+ ...(options.agent?.pages ? { pages: [...options.agent.pages] } : {}),
76
93
  },
77
94
  };
78
95
 
@@ -23,6 +23,7 @@ export const command = {
23
23
  actions: { de: "Aktionen", en: "Actions" },
24
24
  },
25
25
  loading: { de: "Suche läuft …", en: "Searching…" },
26
+ resultCount: { de: "{count} Treffer", en: "{count} results" },
26
27
  error: {
27
28
  de: "Die Suche ist gerade nicht verfügbar. Versuche es erneut.",
28
29
  en: "Search is temporarily unavailable. Please try again.",
package/icon-bundle.ts CHANGED
@@ -3,8 +3,9 @@ import { createRequire } from "node:module";
3
3
  export const layerIconNames = [
4
4
  "circle-flags:de",
5
5
  "circle-flags:us",
6
+ "ginko-social:discord",
7
+ "ginko-social:github",
6
8
  "logos:bun",
7
- "logos:discord-icon",
8
9
  "logos:npm-icon",
9
10
  "logos:nuxt-icon",
10
11
  "logos:pnpm",
@@ -91,6 +92,25 @@ type IconCollection = {
91
92
 
92
93
  const require = createRequire(import.meta.url);
93
94
 
94
- export const layerIconCollections = ["circle-flags", "logos", "lucide"].map(
95
- (prefix) => require(`@iconify-json/${prefix}/icons.json`) as IconCollection,
96
- );
95
+ const ginkoSocialCollection = {
96
+ prefix: "ginko-social",
97
+ width: 24,
98
+ height: 24,
99
+ icons: {
100
+ // Google Material Icons: https://github.com/material-icons/material-icons/blob/master/LICENSE
101
+ discord: {
102
+ body: '<path fill="currentColor" d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12"/>',
103
+ },
104
+ // Material Design Icons: https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
105
+ github: {
106
+ body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/>',
107
+ },
108
+ },
109
+ } satisfies IconCollection;
110
+
111
+ export const layerIconCollections = [
112
+ ...["circle-flags", "logos", "lucide"].map(
113
+ (prefix) => require(`@iconify-json/${prefix}/icons.json`) as IconCollection,
114
+ ),
115
+ ginkoSocialCollection,
116
+ ];
package/index.d.ts CHANGED
@@ -29,6 +29,7 @@ declare module "nuxt/schema" {
29
29
 
30
30
  interface PublicRuntimeConfig {
31
31
  ginkoDocs?: {
32
+ primaryLocale?: "en" | "de";
32
33
  syntaxHighlighting?: GinkoDocsSyntaxHighlightingConfig;
33
34
  };
34
35
  }