@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 +11 -3
- package/app/app.config.ts +1 -0
- package/app/app.vue +15 -0
- package/app/assets/css/theme-presets.css +4 -4
- package/app/components/OgImage/GinkoDocs.satori.vue +131 -10
- package/app/components/site/SiteHeader.vue +1 -1
- package/app/components/site/SiteSocialLinks.vue +9 -1
- package/app/components/ui/badge/index.ts +1 -1
- package/app/components/ui/button/index.ts +1 -1
- package/app/composables/site-navigation.utils.ts +3 -7
- package/app/features/docs/components/DocsSidebarItem.vue +2 -2
- package/app/features/docs/components/DocsSidebarList.vue +1 -1
- package/app/features/docs/components/DocsToc.vue +2 -2
- package/app/features/search/command-center.ts +33 -0
- package/app/features/search/components/SiteCommandCenter.vue +25 -9
- package/app/features/search/components/SiteSearchPageHighlight.vue +4 -2
- package/app/features/search/useCommandCenter.ts +2 -2
- package/app/pages/index.vue +20 -1
- package/i18n/messages/global/command.ts +1 -0
- package/icon-bundle.ts +24 -4
- package/package.json +6 -2
- package/runtime/generate-favicons.mjs +124 -0
- package/shared/types/app-config.ts +16 -2
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/
|
|
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.
|
|
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.
|
|
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
|
-
/*
|
|
32
|
-
--theme-primary-light: var(--nuxt-green-
|
|
33
|
-
--theme-primary-light-foreground: #
|
|
34
|
-
--theme-primary-light-ring: var(--nuxt-green-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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-
|
|
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="
|
|
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-
|
|
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-
|
|
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: "
|
|
76
|
-
discord: { label: "Discord", icon: "
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-[
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 =
|
|
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
|
|
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:
|
|
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 ?? "
|
|
193
|
+
icon: github.icon ?? "ginko-social:github",
|
|
194
194
|
keywords: ["repo", "source", "code"],
|
|
195
195
|
},
|
|
196
196
|
]
|
package/app/pages/index.vue
CHANGED
|
@@ -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
|
|
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
|
-
|
|
95
|
-
|
|
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.
|
|
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.
|
|
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
|
-
| {
|
|
62
|
-
|
|
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;
|