@lupinum/ginko-docs 0.4.0-rc.5 → 0.4.0-rc.9

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/lupinum-dev/ginko-docs/main/docs/public/web-app-manifest-512x512.png" width="128" alt="Ginko Docs icon">
2
+ <img src="https://raw.githubusercontent.com/lupinum-dev/ginko-docs/main/docs/public/favicon.svg" width="128" alt="Ginko Docs icon">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">@lupinum/ginko-docs</h1>
@@ -23,13 +23,13 @@ Use this package when a Nuxt application needs documentation routes, navigation,
23
23
 
24
24
  - Node.js 22.18, 24.11, or 26 and later maintenance releases
25
25
  - Nuxt 4.5.1 or later in the Nuxt 4 line
26
- - Vue 3.5.35 or later
26
+ - Vue 3.5.40 or later
27
27
  - Ginko Content 0.4 prerelease or later in the 0.4 line
28
28
 
29
29
  ## Installation
30
30
 
31
31
  ```bash
32
- pnpm add -D @lupinum/ginko-docs@0.4.0-rc.5 @lupinum/ginko-content@1.0.0-beta.5
32
+ pnpm add -D @lupinum/ginko-docs@0.4.0-rc.9 @lupinum/ginko-content@1.0.0-beta.5
33
33
  ```
34
34
 
35
35
  ```ts
@@ -58,6 +58,14 @@ export default defineGinkoDocsConfig({
58
58
 
59
59
  Put Markdown files in `content/docs`. Open `/docs` after the Nuxt server starts.
60
60
 
61
+ Generate the browser icon set from one square SVG or raster source:
62
+
63
+ ```bash
64
+ pnpm exec ginko-docs-favicons --source public/product-icon.svg --out public
65
+ ```
66
+
67
+ The command writes `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`, and `favicon.ico`. Ginko Docs adds the matching head links and uses the generated 96px PNG in Nuxt-preset social images so every supported source format renders consistently.
68
+
61
69
  ## Exports
62
70
 
63
71
  - `@lupinum/ginko-docs` is the Nuxt layer entry.
package/app/app.config.ts CHANGED
@@ -17,6 +17,7 @@ export default {
17
17
  de: "Dokumentations-Sites auf Basis von Nuxt und Ginko Content.",
18
18
  },
19
19
  url: "http://localhost:3000",
20
+ icon: "/favicon.svg",
20
21
  logo: { light: "/logo.svg", dark: "/logo-dark.svg" },
21
22
  docsSidebarSwitcher: "tabs",
22
23
  lupinumAttribution: true,
package/app/app.vue CHANGED
@@ -52,6 +52,21 @@ useHead(() => ({
52
52
  lang: locale.value,
53
53
  },
54
54
  link: [
55
+ {
56
+ key: "favicon-svg",
57
+ rel: "icon",
58
+ type: "image/svg+xml",
59
+ href: docsConfig.site.icon,
60
+ },
61
+ {
62
+ key: "favicon-png",
63
+ rel: "icon",
64
+ type: "image/png",
65
+ sizes: "96x96",
66
+ href: "/favicon-96x96.png",
67
+ },
68
+ { key: "favicon-ico", rel: "icon", sizes: "any", href: "/favicon.ico" },
69
+ { key: "apple-touch-icon", rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
55
70
  { key: "canonical", rel: "canonical", href: canonicalUrl.value },
56
71
  ...locales.value.map((entry) => {
57
72
  const code = typeof entry === "string" ? entry : entry.code;
@@ -28,10 +28,10 @@ html[data-theme-preset="nuxt"] {
28
28
  --theme-neutral-900: var(--color-slate-900);
29
29
  --theme-neutral-950: var(--color-slate-950);
30
30
 
31
- /* Normal-size text needs the darker green in light mode. */
32
- --theme-primary-light: var(--nuxt-green-700);
33
- --theme-primary-light-foreground: #ffffff;
34
- --theme-primary-light-ring: var(--nuxt-green-600);
31
+ /* Nuxt uses one recognizable brand green across interactive surfaces. */
32
+ --theme-primary-light: var(--nuxt-green-400);
33
+ --theme-primary-light-foreground: #020420;
34
+ --theme-primary-light-ring: var(--nuxt-green-400);
35
35
  --theme-primary-dark: var(--nuxt-green-400);
36
36
  --theme-primary-dark-foreground: var(--nuxt-green-950);
37
37
  --theme-primary-dark-ring: var(--nuxt-green-400);
@@ -22,25 +22,146 @@ const props = withDefaults(
22
22
  siteName: undefined,
23
23
  logo: undefined,
24
24
  locale: "en",
25
- colorBg: "#ffffff",
26
- colorFg: "#0f172a",
27
- colorMuted: "#64748b",
28
- colorAccent: "#2563eb",
29
25
  },
30
26
  );
31
27
 
32
28
  const config = useAppConfig().ginkoDocs as GinkoDocsAppConfig;
29
+ const isNuxtPreset = computed(() => config.theme.preset === "nuxt");
33
30
 
34
31
  const resolvedSiteName = computed(
35
32
  () => props.siteName ?? getLocalizedSiteText(config.site.name, props.locale),
36
33
  );
37
34
  const resolvedLogo = computed(() => props.logo ?? config.site.logo?.light);
35
+ const resolvedNuxtIcon = computed(() => props.logo ?? "/favicon-96x96.png");
36
+ const resolvedWordmark = computed(() =>
37
+ isNuxtPreset.value ? config.site.logo?.dark : config.site.logo?.light,
38
+ );
39
+ const resolvedColorBg = computed(
40
+ () => props.colorBg ?? (isNuxtPreset.value ? "#07111f" : "#ffffff"),
41
+ );
42
+ const resolvedColorFg = computed(
43
+ () => props.colorFg ?? (isNuxtPreset.value ? "#ffffff" : "#0f172a"),
44
+ );
45
+ const resolvedColorMuted = computed(
46
+ () => props.colorMuted ?? (isNuxtPreset.value ? "#aab8ca" : "#64748b"),
47
+ );
48
+ const resolvedColorAccent = computed(
49
+ () => props.colorAccent ?? (isNuxtPreset.value ? "#00dc82" : "#2563eb"),
50
+ );
38
51
  const displayTitle = computed(() => (props.title || resolvedSiteName.value).slice(0, 120));
39
52
  const displayDescription = computed(() => (props.description ?? "").slice(0, 180));
40
53
  </script>
41
54
 
42
55
  <template>
43
56
  <div
57
+ v-if="isNuxtPreset"
58
+ :style="{
59
+ width: '100%',
60
+ height: '100%',
61
+ display: 'flex',
62
+ flexDirection: 'column',
63
+ backgroundColor: resolvedColorBg,
64
+ fontFamily: 'Public Sans',
65
+ overflow: 'hidden',
66
+ position: 'relative',
67
+ }"
68
+ >
69
+ <div
70
+ :style="{
71
+ position: 'absolute',
72
+ top: '-360px',
73
+ right: '-80px',
74
+ width: '800px',
75
+ height: '800px',
76
+ borderRadius: '9999px',
77
+ backgroundImage:
78
+ 'radial-gradient(circle, rgba(0, 220, 130, 0.58) 0%, rgba(0, 220, 130, 0.08) 48%, rgba(0, 220, 130, 0) 72%)',
79
+ }"
80
+ />
81
+
82
+ <div
83
+ :style="{
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ height: '72px',
87
+ margin: '72px 96px 0',
88
+ }"
89
+ >
90
+ <img :src="resolvedNuxtIcon" :width="72" :height="72" alt="" />
91
+ <div
92
+ :style="{
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ height: '72px',
96
+ marginLeft: '20px',
97
+ color: resolvedColorFg,
98
+ fontSize: '30px',
99
+ fontWeight: 700,
100
+ letterSpacing: '-0.03em',
101
+ }"
102
+ >
103
+ {{ resolvedSiteName }}
104
+ </div>
105
+ </div>
106
+
107
+ <div
108
+ :style="{
109
+ display: 'flex',
110
+ flexDirection: 'column',
111
+ justifyContent: 'flex-end',
112
+ flex: 1,
113
+ margin: '32px 96px 0',
114
+ }"
115
+ >
116
+ <div
117
+ :style="{
118
+ display: 'flex',
119
+ maxWidth: '980px',
120
+ marginBottom: displayDescription ? '22px' : 0,
121
+ fontSize: displayTitle.length > 50 ? '54px' : '72px',
122
+ fontWeight: 700,
123
+ lineHeight: 1.02,
124
+ color: resolvedColorFg,
125
+ letterSpacing: '-0.05em',
126
+ }"
127
+ >
128
+ {{ displayTitle }}
129
+ </div>
130
+ <div
131
+ v-if="displayDescription"
132
+ :style="{
133
+ display: 'flex',
134
+ maxWidth: '900px',
135
+ fontSize: '27px',
136
+ lineHeight: 1.4,
137
+ color: resolvedColorMuted,
138
+ }"
139
+ >
140
+ {{ displayDescription }}
141
+ </div>
142
+ </div>
143
+
144
+ <div
145
+ :style="{
146
+ display: 'flex',
147
+ alignItems: 'center',
148
+ height: '74px',
149
+ margin: '24px 96px 56px',
150
+ }"
151
+ >
152
+ <img
153
+ v-if="resolvedWordmark"
154
+ :src="resolvedWordmark"
155
+ :width="240"
156
+ :height="52"
157
+ alt=""
158
+ :style="{ objectFit: 'contain', objectPosition: 'left center' }"
159
+ />
160
+ </div>
161
+ </div>
162
+
163
+ <div
164
+ v-else
44
165
  :style="{
45
166
  width: '100%',
46
167
  height: '100%',
@@ -48,7 +169,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
48
169
  flexDirection: 'column',
49
170
  justifyContent: 'space-between',
50
171
  padding: '72px 80px',
51
- backgroundColor: colorBg,
172
+ backgroundColor: resolvedColorBg,
52
173
  fontFamily: 'Public Sans',
53
174
  }"
54
175
  >
@@ -59,7 +180,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
59
180
  display: 'flex',
60
181
  fontSize: '30px',
61
182
  fontWeight: 700,
62
- color: colorFg,
183
+ color: resolvedColorFg,
63
184
  }"
64
185
  >
65
186
  {{ resolvedSiteName }}
@@ -73,7 +194,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
73
194
  fontSize: displayTitle.length > 50 ? '56px' : '68px',
74
195
  fontWeight: 700,
75
196
  lineHeight: 1.15,
76
- color: colorFg,
197
+ color: resolvedColorFg,
77
198
  letterSpacing: '-0.02em',
78
199
  }"
79
200
  >
@@ -85,7 +206,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
85
206
  display: 'flex',
86
207
  fontSize: '30px',
87
208
  lineHeight: 1.4,
88
- color: colorMuted,
209
+ color: resolvedColorMuted,
89
210
  }"
90
211
  >
91
212
  {{ displayDescription }}
@@ -99,7 +220,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
99
220
  width: '56px',
100
221
  height: '8px',
101
222
  borderRadius: '9999px',
102
- backgroundColor: colorAccent,
223
+ backgroundColor: resolvedColorAccent,
103
224
  }"
104
225
  />
105
226
  <div
@@ -108,7 +229,7 @@ const displayDescription = computed(() => (props.description ?? "").slice(0, 180
108
229
  width: '20px',
109
230
  height: '8px',
110
231
  borderRadius: '9999px',
111
- backgroundColor: colorMuted,
232
+ backgroundColor: resolvedColorMuted,
112
233
  opacity: 0.4,
113
234
  }"
114
235
  />
@@ -316,7 +316,7 @@ watch(
316
316
  class="flex items-center rounded-md px-2 py-2 text-lg font-semibold transition-colors"
317
317
  :class="
318
318
  link.active
319
- ? 'bg-primary/10 text-primary'
319
+ ? 'bg-muted text-foreground'
320
320
  : 'text-foreground hover:bg-muted/60'
321
321
  "
322
322
  :aria-current="link.active ? 'true' : undefined"
@@ -19,7 +19,15 @@ defineEmits<{ navigate: [] }>();
19
19
  :class="headerUtilityButtonClass"
20
20
  @click="$emit('navigate')"
21
21
  >
22
- <Icon :name="link.icon ?? 'lucide:link'" class="size-[18px]" aria-hidden="true" />
22
+ <Icon :name="link.icon ?? 'lucide:link'" class="social-icon" aria-hidden="true" />
23
23
  </NuxtLink>
24
24
  </div>
25
25
  </template>
26
+
27
+ <style scoped>
28
+ .social-icon {
29
+ width: 1.25rem;
30
+ height: 1.25rem;
31
+ flex: none;
32
+ }
33
+ </style>
@@ -8,7 +8,7 @@ export const badgeVariants = cva(
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
11
+ default: "border-transparent bg-brand text-brand-foreground [a&]:hover:bg-brand/90",
12
12
  secondary:
13
13
  "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
14
14
  destructive:
@@ -8,7 +8,7 @@ export const buttonVariants = cva(
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
11
+ default: "bg-brand text-brand-foreground hover:bg-brand/90",
12
12
  destructive:
13
13
  "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
14
14
  outline:
@@ -66,14 +66,10 @@ export function resolveMainNav(
66
66
  return items;
67
67
  }
68
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
- */
69
+ /** Brand names are not translated, so the labels are literals. */
74
70
  const socialDefaults: Record<GinkoDocsSocialPlatform, { label: string; icon: string }> = {
75
- github: { label: "GitHub", icon: "lucide:github" },
76
- discord: { label: "Discord", icon: "lucide:message-circle" },
71
+ github: { label: "GitHub", icon: "ginko-social:github" },
72
+ discord: { label: "Discord", icon: "ginko-social:discord" },
77
73
  linkedin: { label: "LinkedIn", icon: "lucide:linkedin" },
78
74
  };
79
75
 
@@ -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
  ]
@@ -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)" },
@@ -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>
@@ -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>
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-docs",
3
- "version": "0.4.0-rc.5",
3
+ "version": "0.4.0-rc.9",
4
4
  "description": "A Nuxt documentation layer powered by Ginko Content.",
5
5
  "keywords": [
6
6
  "content",
@@ -20,6 +20,9 @@
20
20
  "url": "git+https://github.com/lupinum-dev/ginko-docs.git",
21
21
  "directory": "layer"
22
22
  },
23
+ "bin": {
24
+ "ginko-docs-favicons": "./runtime/generate-favicons.mjs"
25
+ },
23
26
  "files": [
24
27
  "app",
25
28
  "i18n",
@@ -80,6 +83,7 @@
80
83
  "nuxt-og-image": "^6.7.8",
81
84
  "reka-ui": "^2.10.3",
82
85
  "satori": "^0.19.3",
86
+ "sharp": "^0.35.3",
83
87
  "shiki": "^4.4.3",
84
88
  "tailwind-merge": "^3.6.0",
85
89
  "tailwindcss": "^4.3.3",
@@ -89,7 +93,7 @@
89
93
  "peerDependencies": {
90
94
  "@lupinum/ginko-content": ">=1.0.0-beta.5 <2.0.0",
91
95
  "nuxt": ">=4.5.1 <5",
92
- "vue": "^3.5.35",
96
+ "vue": "^3.5.40",
93
97
  "vue-router": "^5.1.0"
94
98
  },
95
99
  "engines": {
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { extname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import sharp from "sharp";
7
+
8
+ const ICO_SIZES = [16, 32, 48];
9
+
10
+ function createIco(images) {
11
+ const headerSize = 6;
12
+ const entrySize = 16;
13
+ const directory = Buffer.alloc(headerSize + entrySize * images.length);
14
+
15
+ directory.writeUInt16LE(0, 0);
16
+ directory.writeUInt16LE(1, 2);
17
+ directory.writeUInt16LE(images.length, 4);
18
+
19
+ let imageOffset = directory.length;
20
+ images.forEach(({ size, data }, index) => {
21
+ const entryOffset = headerSize + entrySize * index;
22
+ directory.writeUInt8(size === 256 ? 0 : size, entryOffset);
23
+ directory.writeUInt8(size === 256 ? 0 : size, entryOffset + 1);
24
+ directory.writeUInt8(0, entryOffset + 2);
25
+ directory.writeUInt8(0, entryOffset + 3);
26
+ directory.writeUInt16LE(1, entryOffset + 4);
27
+ directory.writeUInt16LE(32, entryOffset + 6);
28
+ directory.writeUInt32LE(data.length, entryOffset + 8);
29
+ directory.writeUInt32LE(imageOffset, entryOffset + 12);
30
+ imageOffset += data.length;
31
+ });
32
+
33
+ return Buffer.concat([directory, ...images.map(({ data }) => data)]);
34
+ }
35
+
36
+ async function renderSquare(source, size) {
37
+ return sharp(source, { density: 512 }).resize(size, size, { fit: "contain" }).png().toBuffer();
38
+ }
39
+
40
+ function embeddedSvg(png) {
41
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img">
42
+ <image width="512" height="512" href="data:image/png;base64,${png.toString("base64")}" />
43
+ </svg>
44
+ `;
45
+ }
46
+
47
+ export async function generateFavicons({ source, outputDirectory }) {
48
+ const sourcePath = resolve(source);
49
+ const outputPath = resolve(outputDirectory);
50
+ const sourceBuffer = await readFile(sourcePath);
51
+ const metadata = await sharp(sourceBuffer, { density: 512 }).metadata();
52
+
53
+ if (!metadata.width || !metadata.height) {
54
+ throw new Error(`Could not read icon dimensions from ${sourcePath}`);
55
+ }
56
+ if (metadata.width !== metadata.height) {
57
+ throw new Error(
58
+ `The favicon source must be square. Received ${metadata.width}x${metadata.height}.`,
59
+ );
60
+ }
61
+
62
+ const [png96, appleTouchIcon, ...icoPngs] = await Promise.all([
63
+ renderSquare(sourceBuffer, 96),
64
+ renderSquare(sourceBuffer, 180),
65
+ ...ICO_SIZES.map(async (size) => ({ size, data: await renderSquare(sourceBuffer, size) })),
66
+ ]);
67
+
68
+ const svg =
69
+ extname(sourcePath).toLowerCase() === ".svg"
70
+ ? sourceBuffer
71
+ : Buffer.from(embeddedSvg(await renderSquare(sourceBuffer, 512)));
72
+
73
+ await mkdir(outputPath, { recursive: true });
74
+ await Promise.all([
75
+ writeFile(join(outputPath, "favicon.svg"), svg),
76
+ writeFile(join(outputPath, "favicon-96x96.png"), png96),
77
+ writeFile(join(outputPath, "apple-touch-icon.png"), appleTouchIcon),
78
+ writeFile(join(outputPath, "favicon.ico"), createIco(icoPngs)),
79
+ ]);
80
+
81
+ return ["favicon.svg", "favicon-96x96.png", "apple-touch-icon.png", "favicon.ico"];
82
+ }
83
+
84
+ function parseArguments(arguments_) {
85
+ const options = {
86
+ source: "public/favicon.svg",
87
+ outputDirectory: "public",
88
+ };
89
+
90
+ for (let index = 0; index < arguments_.length; index += 1) {
91
+ const argument = arguments_[index];
92
+ if (argument === "--source") options.source = arguments_[++index];
93
+ else if (argument === "--out") options.outputDirectory = arguments_[++index];
94
+ else if (argument === "--help" || argument === "-h") return undefined;
95
+ else throw new Error(`Unknown argument: ${argument}`);
96
+ }
97
+
98
+ if (!options.source || !options.outputDirectory) {
99
+ throw new Error("Both --source and --out require a path.");
100
+ }
101
+ return options;
102
+ }
103
+
104
+ async function main() {
105
+ const options = parseArguments(process.argv.slice(2));
106
+ if (!options) {
107
+ console.log(`Usage: ginko-docs-favicons [--source <path>] [--out <directory>]
108
+
109
+ Defaults:
110
+ --source public/favicon.svg
111
+ --out public`);
112
+ return;
113
+ }
114
+
115
+ const files = await generateFavicons(options);
116
+ console.log(`Generated ${files.join(", ")} in ${resolve(options.outputDirectory)}`);
117
+ }
118
+
119
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
120
+ main().catch((error) => {
121
+ console.error(error instanceof Error ? error.message : error);
122
+ process.exitCode = 1;
123
+ });
124
+ }
@@ -58,8 +58,20 @@ export interface GinkoDocsHeroCodeTab {
58
58
 
59
59
  export type GinkoDocsHeroMedia =
60
60
  | { type: "image"; src: string; alt: string }
61
- | { type: "code"; code: string; language?: string; filename?: string }
62
- | { type: "code-tabs"; tabs: GinkoDocsHeroCodeTab[] };
61
+ | {
62
+ type: "code";
63
+ code: string;
64
+ language?: string;
65
+ filename?: string;
66
+ /** Let the code panel use otherwise empty viewport space on wide screens. */
67
+ layout?: "wide";
68
+ }
69
+ | {
70
+ type: "code-tabs";
71
+ tabs: GinkoDocsHeroCodeTab[];
72
+ /** Let the code panel use otherwise empty viewport space on wide screens. */
73
+ layout?: "wide";
74
+ };
63
75
 
64
76
  export interface GinkoDocsAppConfig {
65
77
  theme: {
@@ -74,6 +86,8 @@ export interface GinkoDocsAppConfig {
74
86
  name: GinkoDocsLocalizedText;
75
87
  description: GinkoDocsLocalizedText;
76
88
  url: string;
89
+ /** Square product mark used for browser icons and generated social cards. */
90
+ icon: string;
77
91
  logo: { light: string; dark: string };
78
92
  docsSidebarSwitcher: "dropdown" | "list" | "tabs";
79
93
  lupinumAttribution: boolean;