@lupinum/ginko-docs 0.4.0-rc.9 → 0.5.0
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/CHANGELOG.md +141 -0
- package/LICENSE +1 -1
- package/README.md +300 -53
- package/app/app.config.ts +44 -55
- package/app/assets/docs.css +619 -0
- package/app/components/DocsBanner.vue +29 -0
- package/app/components/DocsExample.vue +43 -0
- package/app/components/DocsFooter.vue +19 -0
- package/app/components/DocsHeader.vue +113 -0
- package/app/components/DocsIcon.vue +12 -0
- package/app/components/DocsLanding.vue +58 -0
- package/app/components/DocsLanguage.vue +40 -0
- package/app/components/DocsNavSheet.vue +37 -0
- package/app/components/DocsNavTree.vue +38 -0
- package/app/components/DocsPage.vue +88 -0
- package/app/components/DocsPageActions.vue +47 -0
- package/app/components/DocsPageEnd.ts +12 -0
- package/app/components/DocsPost.vue +58 -0
- package/app/components/DocsSearch.vue +136 -0
- package/app/components/DocsShell.vue +36 -0
- package/app/components/DocsSidebar.vue +19 -0
- package/app/components/DocsSidebarEnd.ts +4 -0
- package/app/components/DocsTable.vue +11 -0
- package/app/components/DocsThemeToggle.vue +45 -0
- package/app/components/DocsToc.vue +73 -0
- package/app/composables/useDocs.ts +53 -0
- package/app/composables/useDocsBlog.ts +33 -0
- package/app/composables/useDocsCopy.ts +17 -0
- package/app/composables/useDocsFrame.ts +11 -0
- package/app/composables/useDocsPopover.ts +36 -0
- package/app/composables/useDocsSite.ts +31 -0
- package/app/docs-pages/blog.vue +55 -0
- package/app/docs-pages/changelog.vue +35 -0
- package/app/docs-pages/doc.vue +16 -0
- package/app/docs-pages/docs.vue +9 -0
- package/app/docs-pages/index.vue +12 -0
- package/app/docs-pages/post.vue +8 -0
- package/app/error.vue +38 -115
- package/app/layouts/default.vue +29 -7
- package/app/lib/blogTypes.ts +8 -0
- package/app/lib/docsFirst.ts +11 -0
- package/app/lib/docsInit.ts +18 -0
- package/app/lib/docsNav.ts +36 -0
- package/app/lib/docsPage.ts +84 -0
- package/app/lib/markdownComponents.ts +15 -0
- package/app/lib/previewToc.ts +18 -0
- package/dist/config.d.mts +252 -0
- package/dist/config.d.ts +252 -0
- package/dist/config.mjs +83 -0
- package/dist/icons.d.mts +38 -0
- package/dist/icons.d.ts +38 -0
- package/dist/icons.mjs +175 -0
- package/dist/pages.d.mts +5 -0
- package/dist/pages.d.ts +5 -0
- package/dist/pages.mjs +68 -0
- package/i18n/de.json +1 -0
- package/i18n/en.json +1 -0
- package/i18n/fr.json +1 -0
- package/icons/README.md +2 -0
- package/icons/bluesky.svg +1 -0
- package/icons/claude.svg +1 -0
- package/icons/discord.svg +1 -0
- package/icons/github.svg +1 -0
- package/icons/linkedin.svg +1 -0
- package/icons/mastodon.svg +1 -0
- package/icons/openai.svg +1 -0
- package/icons/x.svg +1 -0
- package/icons/youtube.svg +1 -0
- package/nuxt.config.ts +22 -212
- package/package.json +44 -76
- package/public/_docs/fonts/LICENSE +188 -0
- package/public/_docs/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/public/_docs/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/_docs/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/server/feeds/blog-locale.get.ts +9 -0
- package/server/feeds/blog.get.ts +4 -0
- package/server/lib/blogFeed.ts +35 -0
- package/server/mcp/handler.ts +108 -0
- package/server/routes/__ginko/docs-mcp.ts +35 -0
- package/shared/layerRoutes.ts +66 -0
- package/shared/paths.ts +4 -0
- package/app/app.vue +0 -98
- package/app/assets/css/prose.css +0 -3240
- package/app/assets/css/tailwind.css +0 -394
- package/app/assets/css/theme-palettes.css +0 -309
- package/app/assets/css/theme-presets.css +0 -141
- package/app/components/OgImage/GinkoDocs.satori.vue +0 -238
- package/app/components/content/DocumentPageShell.vue +0 -55
- package/app/components/content/Feedback.vue +0 -102
- package/app/components/content/ImageZoomDialog.vue +0 -84
- package/app/components/content/PageMarkdownCopy.vue +0 -104
- package/app/components/content/imageZoom.ts +0 -25
- package/app/components/mdc/MdcAccordion.vue +0 -67
- package/app/components/mdc/MdcAccordionItem.vue +0 -42
- package/app/components/mdc/MdcApi.vue +0 -159
- package/app/components/mdc/MdcAside.vue +0 -23
- package/app/components/mdc/MdcCallout.vue +0 -61
- package/app/components/mdc/MdcCard.vue +0 -175
- package/app/components/mdc/MdcCards.vue +0 -30
- package/app/components/mdc/MdcCenter.vue +0 -71
- package/app/components/mdc/MdcCodeGroup.vue +0 -197
- package/app/components/mdc/MdcCodeTree.vue +0 -234
- package/app/components/mdc/MdcCollapse.vue +0 -61
- package/app/components/mdc/MdcColumn.vue +0 -70
- package/app/components/mdc/MdcDropcap.vue +0 -84
- package/app/components/mdc/MdcError.vue +0 -23
- package/app/components/mdc/MdcExcerpt.vue +0 -26
- package/app/components/mdc/MdcFigure.vue +0 -85
- package/app/components/mdc/MdcFiles.vue +0 -120
- package/app/components/mdc/MdcIdea.vue +0 -17
- package/app/components/mdc/MdcInfo.vue +0 -17
- package/app/components/mdc/MdcInlineToc.vue +0 -51
- package/app/components/mdc/MdcKbd.vue +0 -10
- package/app/components/mdc/MdcLayout.vue +0 -57
- package/app/components/mdc/MdcNote.vue +0 -17
- package/app/components/mdc/MdcQuiz.vue +0 -241
- package/app/components/mdc/MdcQuizQuestion.vue +0 -221
- package/app/components/mdc/MdcReadMore.vue +0 -70
- package/app/components/mdc/MdcSteps.vue +0 -59
- package/app/components/mdc/MdcSuccess.vue +0 -23
- package/app/components/mdc/MdcTab.vue +0 -17
- package/app/components/mdc/MdcTabs.vue +0 -150
- package/app/components/mdc/MdcTimeline.vue +0 -17
- package/app/components/mdc/MdcTimelineItem.vue +0 -40
- package/app/components/mdc/MdcWarning.vue +0 -23
- package/app/components/mdc/api.utils.ts +0 -132
- package/app/components/mdc/code-tree.utils.ts +0 -22
- package/app/components/mdc/icons.ts +0 -10
- package/app/components/mdc/quiz.utils.ts +0 -38
- package/app/components/prose/ProseA.vue +0 -27
- package/app/components/prose/ProseBlockquote.vue +0 -30
- package/app/components/prose/ProseCode.vue +0 -14
- package/app/components/prose/ProseEm.vue +0 -14
- package/app/components/prose/ProseH1.vue +0 -18
- package/app/components/prose/ProseH2.vue +0 -29
- package/app/components/prose/ProseH3.vue +0 -29
- package/app/components/prose/ProseH4.vue +0 -29
- package/app/components/prose/ProseH5.vue +0 -15
- package/app/components/prose/ProseH6.vue +0 -15
- package/app/components/prose/ProseHr.vue +0 -12
- package/app/components/prose/ProseImg.vue +0 -55
- package/app/components/prose/ProseLi.vue +0 -14
- package/app/components/prose/ProseOl.vue +0 -14
- package/app/components/prose/ProseP.vue +0 -14
- package/app/components/prose/ProsePre.vue +0 -180
- package/app/components/prose/ProseStrong.vue +0 -14
- package/app/components/prose/ProseTable.vue +0 -18
- package/app/components/prose/ProseTbody.vue +0 -14
- package/app/components/prose/ProseTd.vue +0 -14
- package/app/components/prose/ProseTh.vue +0 -14
- package/app/components/prose/ProseThead.vue +0 -14
- package/app/components/prose/ProseTr.vue +0 -14
- package/app/components/prose/ProseUl.vue +0 -14
- package/app/components/site/ModeToggle.vue +0 -81
- package/app/components/site/SiteBanner.vue +0 -97
- package/app/components/site/SiteFooter.vue +0 -90
- package/app/components/site/SiteHeader.vue +0 -371
- package/app/components/site/SiteHeroCode.vue +0 -173
- package/app/components/site/SiteInteractionLayer.vue +0 -42
- package/app/components/site/SiteLocaleSwitcher.vue +0 -190
- package/app/components/site/SiteLogoMark.vue +0 -12
- package/app/components/site/SiteSkipLink.vue +0 -14
- package/app/components/site/SiteSocialLinks.vue +0 -33
- package/app/components/site/header-utils.ts +0 -3
- package/app/components/site/mode-toggle.utils.ts +0 -18
- package/app/components/ui/badge/Badge.vue +0 -28
- package/app/components/ui/badge/index.ts +0 -24
- package/app/components/ui/button/Button.vue +0 -31
- package/app/components/ui/button/index.ts +0 -35
- package/app/components/ui/collapsible/Collapsible.vue +0 -15
- package/app/components/ui/collapsible/CollapsibleContent.vue +0 -25
- package/app/components/ui/collapsible/CollapsibleTrigger.vue +0 -12
- package/app/components/ui/collapsible/index.ts +0 -3
- package/app/components/ui/dropdown-menu/DropdownMenu.vue +0 -15
- package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +0 -50
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +0 -41
- package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +0 -15
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +0 -37
- package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +0 -23
- package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +0 -14
- package/app/components/ui/dropdown-menu/index.ts +0 -7
- package/app/components/ui/kbd/Kbd.vue +0 -23
- package/app/components/ui/kbd/index.ts +0 -21
- package/app/components/ui/scroll-area/ScrollArea.vue +0 -39
- package/app/components/ui/scroll-area/ScrollBar.vue +0 -33
- package/app/components/ui/scroll-area/index.ts +0 -2
- package/app/components/ui/separator/Separator.vue +0 -27
- package/app/components/ui/separator/index.ts +0 -1
- package/app/components/ui/sheet/Sheet.vue +0 -15
- package/app/components/ui/sheet/SheetContent.vue +0 -59
- package/app/components/ui/sheet/SheetDescription.vue +0 -21
- package/app/components/ui/sheet/SheetOverlay.vue +0 -26
- package/app/components/ui/sheet/SheetTitle.vue +0 -21
- package/app/components/ui/sheet/SheetTrigger.vue +0 -12
- package/app/components/ui/sheet/index.ts +0 -5
- package/app/components/ui/tabs/Tabs.vue +0 -24
- package/app/components/ui/tabs/TabsList.vue +0 -26
- package/app/components/ui/tabs/TabsTrigger.vue +0 -28
- package/app/components/ui/tabs/index.ts +0 -3
- package/app/composables/site-navigation.utils.ts +0 -133
- package/app/composables/useCanonicalUrl.ts +0 -9
- package/app/composables/useContentRouteAlternates.ts +0 -35
- package/app/composables/useGinkoAnalytics.ts +0 -52
- package/app/composables/useGinkoDocsConfig.ts +0 -6
- package/app/composables/useGinkoOgImage.ts +0 -20
- package/app/composables/useLocalizedPath.ts +0 -12
- package/app/composables/useLocalizedRouteSwitch.ts +0 -31
- package/app/composables/useMetaKey.ts +0 -18
- package/app/composables/usePageMarkdownActions.ts +0 -79
- package/app/composables/useProseAppearance.ts +0 -24
- package/app/composables/useSchemaJsonLd.ts +0 -25
- package/app/composables/useSiteNavigation.ts +0 -64
- package/app/config/site.utils.ts +0 -36
- package/app/features/docs/components/DocsBreadcrumb.vue +0 -37
- package/app/features/docs/components/DocsContributeLinks.vue +0 -75
- package/app/features/docs/components/DocsMobileToc.vue +0 -108
- package/app/features/docs/components/DocsPageContent.vue +0 -234
- package/app/features/docs/components/DocsPageNav.vue +0 -57
- package/app/features/docs/components/DocsSidebar.vue +0 -145
- package/app/features/docs/components/DocsSidebarDropdown.vue +0 -70
- package/app/features/docs/components/DocsSidebarItem.vue +0 -122
- package/app/features/docs/components/DocsSidebarList.vue +0 -35
- package/app/features/docs/components/DocsSidebarRow.vue +0 -27
- package/app/features/docs/components/DocsSidebarTabs.vue +0 -59
- package/app/features/docs/components/DocsToc.vue +0 -106
- package/app/features/docs/composables/useDocsNavigation.ts +0 -29
- package/app/features/docs/composables/useDocsNavigationData.ts +0 -18
- package/app/features/docs/composables/useRevealActive.ts +0 -58
- package/app/features/docs/composables/useScrollspy.ts +0 -135
- package/app/features/docs/docs-navigation.ts +0 -157
- package/app/features/docs/toc-context.ts +0 -6
- package/app/features/search/command-center.ts +0 -110
- package/app/features/search/components/SiteCommandCenter.vue +0 -359
- package/app/features/search/components/SiteSearchPageHighlight.vue +0 -126
- package/app/features/search/useCommandCenter.ts +0 -292
- package/app/layouts/blog.vue +0 -15
- package/app/layouts/docs.vue +0 -20
- package/app/lib/errors.ts +0 -37
- package/app/lib/schema-org.ts +0 -38
- package/app/pages/blog/[slug].vue +0 -228
- package/app/pages/blog/index.vue +0 -130
- package/app/pages/docs/[...slug].vue +0 -10
- package/app/pages/docs/index.vue +0 -19
- package/app/pages/index.vue +0 -278
- package/app/plugins/ginko-docs-theme.ts +0 -14
- package/app/plugins/ssr-width.ts +0 -6
- package/app/utils/content.ts +0 -67
- package/app/utils/file-icons.ts +0 -90
- package/app/utils/index.ts +0 -7
- package/app/utils/repository.ts +0 -20
- package/components.ts +0 -9
- package/content-collections.ts +0 -89
- package/content.js +0 -200
- package/content.ts +0 -104
- package/i18n/i18n.config.ts +0 -10
- package/i18n/locales.ts +0 -81
- package/i18n/messages/global/banner.ts +0 -8
- package/i18n/messages/global/blog.ts +0 -18
- package/i18n/messages/global/command.ts +0 -49
- package/i18n/messages/global/docs.ts +0 -36
- package/i18n/messages/global/errors.ts +0 -36
- package/i18n/messages/global/feedback.ts +0 -11
- package/i18n/messages/global/index.ts +0 -21
- package/i18n/messages/global/nav.ts +0 -26
- package/i18n/messages/global/site.ts +0 -4
- package/i18n/messages/global/theme.ts +0 -9
- package/i18n/messages/index.ts +0 -20
- package/i18n/messages/localize.ts +0 -35
- package/i18n/routes.ts +0 -33
- package/icon-bundle.ts +0 -116
- package/index.d.ts +0 -37
- package/modules/analytics-boundary.ts +0 -23
- package/modules/feature-routing.ts +0 -68
- package/modules/syntax-highlighting.ts +0 -120
- package/public/fonts/public-sans/LICENSE +0 -93
- package/public/fonts/public-sans/README.md +0 -12
- package/public/fonts/public-sans/public-sans-400-normal-latin.woff +0 -0
- package/public/fonts/public-sans/public-sans-500-normal-latin.woff +0 -0
- package/public/fonts/public-sans/public-sans-600-normal-latin.woff +0 -0
- package/public/fonts/public-sans/public-sans-700-normal-latin.woff +0 -0
- package/runtime/generate-favicons.mjs +0 -124
- package/runtime/server/agent-markdown.ts +0 -19
- package/server/api/_ginko-docs/redirects.get.ts +0 -28
- package/server/mcp/tools/get-page.ts +0 -14
- package/server/mcp/tools/list-pages.ts +0 -10
- package/server/middleware/redirects.ts +0 -22
- package/server/plugins/agent-markdown.ts +0 -6
- package/server/plugins/sitemap-policy.ts +0 -12
- package/server/routes/.well-known/ai-catalog.json.get.ts +0 -10
- package/server/routes/[locale]/blog/rss.xml.get.ts +0 -13
- package/server/routes/blog/rss.xml.get.ts +0 -5
- package/server/routes/mcp/server-card.get.ts +0 -14
- package/server/utils/blog-feed.ts +0 -51
- package/server/utils/feed.ts +0 -70
- package/server/utils/mcp-server-card.ts +0 -45
- package/server/utils/redirects.ts +0 -38
- package/server/utils/redirects.utils.ts +0 -58
- package/server/utils/sitemap.ts +0 -45
- package/shared/route-slugs.ts +0 -5
- package/shared/theme-palettes.ts +0 -37
- package/shared/theme-presets.ts +0 -3
- package/shared/types/app-config.ts +0 -172
- package/shared/types/nuxt-config.ts +0 -14
- package/tags.ts +0 -192
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Search over /__ginko/search/<locale>.json (built at build time), loaded when the dialog first opens.
|
|
3
|
+
// Hits are grouped by page: the page itself first, then its sections. Arrow keys move, Enter opens.
|
|
4
|
+
import type { SearchHit, SearchIndex, SearchIndexJson } from '@lupinum/ginko/search'
|
|
5
|
+
|
|
6
|
+
// `shortcut` (default true): `/` and Ctrl/⌘ K open this search. A second trigger on the same page sets it to false.
|
|
7
|
+
const { shortcut = true } = defineProps<{ shortcut?: boolean }>()
|
|
8
|
+
const { t, locale } = useI18n()
|
|
9
|
+
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
|
|
10
|
+
const input = useTemplateRef<HTMLInputElement>('input')
|
|
11
|
+
const query = ref('')
|
|
12
|
+
const active = ref(0)
|
|
13
|
+
// The engine is imported on first open, so MiniSearch stays out of the page's entry chunk.
|
|
14
|
+
const engine = shallowRef<{ index: SearchIndex; search: typeof import('@lupinum/ginko/search').search }>()
|
|
15
|
+
const mac = ref(true)
|
|
16
|
+
|
|
17
|
+
async function open() {
|
|
18
|
+
dialog.value?.showModal()
|
|
19
|
+
input.value?.select()
|
|
20
|
+
if (engine.value) return
|
|
21
|
+
try {
|
|
22
|
+
const { loadIndex, search } = await import('@lupinum/ginko/search')
|
|
23
|
+
const url: string = `/__ginko/search/${locale.value}.json` // a plain string: the typed route union is too deep to compare
|
|
24
|
+
engine.value = { index: await loadIndex(await $fetch<SearchIndexJson>(url)), search }
|
|
25
|
+
} catch { /* no index: the dialog shows "no results" */ }
|
|
26
|
+
}
|
|
27
|
+
watch(locale, () => { engine.value = undefined })
|
|
28
|
+
|
|
29
|
+
const hits = computed<SearchHit[]>(() => {
|
|
30
|
+
const q = query.value.trim()
|
|
31
|
+
return q && engine.value ? engine.value.search(engine.value.index, q, { limit: 12 }) : []
|
|
32
|
+
})
|
|
33
|
+
// One group per page, in the order of its best hit; the page hit (no heading) leads its group and its sections
|
|
34
|
+
// are indented under it. Pages then sit under their area (Docs, Blog, Changelog), again in the order of the best
|
|
35
|
+
// hit; the area labels show only when hits come from more than one area.
|
|
36
|
+
const AREAS = { docs: 'docs.docs', blog: 'docs.blog', changelog: 'docs.changelog' } as const
|
|
37
|
+
function areaOf(url: string): keyof typeof AREAS | undefined {
|
|
38
|
+
const local = url.startsWith(`/${locale.value}/`) ? url.slice(locale.value.length + 1) : url
|
|
39
|
+
const first = local.split(/[/#?]/)[1] ?? ''
|
|
40
|
+
return Object.hasOwn(AREAS, first) ? (first as keyof typeof AREAS) : undefined
|
|
41
|
+
}
|
|
42
|
+
const groups = computed(() => {
|
|
43
|
+
const byPage = new Map<string, SearchHit[]>()
|
|
44
|
+
for (const hit of hits.value) {
|
|
45
|
+
const page = hit.url.split('#')[0]!
|
|
46
|
+
byPage.set(page, [...(byPage.get(page) ?? []), hit])
|
|
47
|
+
}
|
|
48
|
+
const byArea = new Map<string, { label?: string; rows: { hit: SearchHit; indent: boolean }[] }>()
|
|
49
|
+
for (const list of byPage.values()) {
|
|
50
|
+
const area = areaOf(list[0]!.url)
|
|
51
|
+
const group = byArea.get(area ?? '') ?? { label: area ? t(AREAS[area]) : undefined, rows: [] }
|
|
52
|
+
byArea.set(area ?? '', group)
|
|
53
|
+
const hasPage = list.some((h) => !h.heading)
|
|
54
|
+
group.rows.push(...list.sort((a, b) => Number(!!a.heading) - Number(!!b.heading)).map((hit) => ({ hit, indent: hasPage && !!hit.heading })))
|
|
55
|
+
}
|
|
56
|
+
const list = [...byArea.values()]
|
|
57
|
+
return list.length > 1 ? list : list.map((g) => ({ ...g, label: undefined }))
|
|
58
|
+
})
|
|
59
|
+
const flat = computed(() => groups.value.flatMap((g) => g.rows))
|
|
60
|
+
const indexOf = (hit: SearchHit) => flat.value.findIndex((r) => r.hit.id === hit.id)
|
|
61
|
+
watch(query, () => { active.value = 0 })
|
|
62
|
+
|
|
63
|
+
function move(step: number) {
|
|
64
|
+
if (!flat.value.length) return
|
|
65
|
+
active.value = (active.value + step + flat.value.length) % flat.value.length
|
|
66
|
+
nextTick(() => document.getElementById(`docs-hit-${active.value}`)?.scrollIntoView({ block: 'nearest' }))
|
|
67
|
+
}
|
|
68
|
+
function go() {
|
|
69
|
+
const row = flat.value[active.value]
|
|
70
|
+
if (row) navigateTo(row.hit.url)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const route = useRoute()
|
|
74
|
+
watch(() => route.fullPath, () => dialog.value?.close())
|
|
75
|
+
function onKey(e: KeyboardEvent) {
|
|
76
|
+
const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable
|
|
77
|
+
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
open()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
onMounted(() => {
|
|
83
|
+
mac.value = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
84
|
+
if (shortcut) window.addEventListener('keydown', onKey)
|
|
85
|
+
})
|
|
86
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<button type="button" class="docs-search-trigger" :aria-label="t('docs.search')" @click="open">
|
|
91
|
+
<DocsIcon name="lucide:search" class="docs-icon" />
|
|
92
|
+
<span class="docs-search-label">{{ t('docs.searchPlaceholder') }}</span>
|
|
93
|
+
<kbd class="docs-kbd">{{ mac ? '⌘' : 'Ctrl' }} K</kbd>
|
|
94
|
+
</button>
|
|
95
|
+
<Teleport to="body">
|
|
96
|
+
<dialog ref="dialog" class="docs-search-dialog" :aria-label="t('docs.search')" @click.self="dialog?.close()">
|
|
97
|
+
<div class="docs-search-field">
|
|
98
|
+
<DocsIcon name="lucide:search" class="docs-icon" />
|
|
99
|
+
<input
|
|
100
|
+
ref="input" v-model="query" type="search" role="combobox" aria-autocomplete="list" aria-controls="docs-search-list"
|
|
101
|
+
:aria-expanded="flat.length > 0" :aria-activedescendant="flat.length ? `docs-hit-${active}` : undefined"
|
|
102
|
+
:placeholder="t('docs.searchPlaceholder')" :aria-label="t('docs.searchPlaceholder')" autocomplete="off" spellcheck="false"
|
|
103
|
+
@keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="go" @keydown.esc.prevent="dialog?.close()"
|
|
104
|
+
>
|
|
105
|
+
<button type="button" class="docs-kbd" @click="dialog?.close()">Esc</button>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="docs-search-body" tabindex="0" role="region" :aria-label="t('docs.searchResults')">
|
|
108
|
+
<ul v-if="flat.length" id="docs-search-list" class="docs-search-results" role="listbox" :aria-label="t('docs.search')">
|
|
109
|
+
<li v-for="(g, n) in groups" :key="n" role="none">
|
|
110
|
+
<p v-if="g.label" :id="`docs-hit-group-${n}`" class="docs-search-group">{{ g.label }}</p>
|
|
111
|
+
<ul role="group" :aria-labelledby="g.label ? `docs-hit-group-${n}` : undefined">
|
|
112
|
+
<li
|
|
113
|
+
v-for="{ hit, indent } in g.rows" :id="`docs-hit-${indexOf(hit)}`" :key="hit.id" role="option" :aria-selected="indexOf(hit) === active"
|
|
114
|
+
class="docs-search-hit" :class="{ 'is-section': indent }" @mousemove="active = indexOf(hit)" @click="active = indexOf(hit); go()"
|
|
115
|
+
>
|
|
116
|
+
<span class="docs-search-tile"><DocsIcon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
|
|
117
|
+
<span class="docs-search-text">
|
|
118
|
+
<strong>{{ hit.heading ?? hit.title }}</strong>
|
|
119
|
+
<span>{{ indent ? hit.excerpt : hit.heading ? hit.title : hit.excerpt }}</span>
|
|
120
|
+
</span>
|
|
121
|
+
<DocsIcon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
|
|
122
|
+
</li>
|
|
123
|
+
</ul>
|
|
124
|
+
</li>
|
|
125
|
+
</ul>
|
|
126
|
+
<p v-else-if="query.trim()" class="docs-search-empty">{{ t('docs.noResults', { query: query.trim() }) }}</p>
|
|
127
|
+
<p v-else class="docs-search-empty">{{ t('docs.searchHint') }}</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="docs-search-foot" aria-hidden="true">
|
|
130
|
+
<span><kbd class="docs-kbd">↑</kbd><kbd class="docs-kbd">↓</kbd> {{ t('docs.searchMove') }}</span>
|
|
131
|
+
<span><kbd class="docs-kbd">↵</kbd> {{ t('docs.searchOpen') }}</span>
|
|
132
|
+
<span><kbd class="docs-kbd">Esc</kbd> {{ t('docs.close') }}</span>
|
|
133
|
+
</div>
|
|
134
|
+
</dialog>
|
|
135
|
+
</Teleport>
|
|
136
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The docs body: the navigation on the side and the page next to it. Public: a site builds its own pages inside the
|
|
3
|
+
// docs frame with it (slots: default, `bar`). In the layer's own frame the header holds the search and, on phones, the
|
|
4
|
+
// navigation. Inside a site's layout the shell brings both: the search above the navigation, and on phones a bar with
|
|
5
|
+
// a button that opens the navigation as a sheet, the search, and the page's own controls (slot `bar`, the table of
|
|
6
|
+
// contents). `docs.search: false` in app.config leaves the search out (a site with <DocsSearch /> in its own header).
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const standalone = useDocsStandalone()
|
|
9
|
+
const search = !standalone && useAppConfig().docs?.search !== false
|
|
10
|
+
const nav = ref(false)
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div class="docs-shell">
|
|
15
|
+
<nav class="docs-sidebar" :aria-label="t('docs.nav')">
|
|
16
|
+
<div v-if="search" class="docs-sidebar-search">
|
|
17
|
+
<DocsSearch />
|
|
18
|
+
</div>
|
|
19
|
+
<DocsSidebar />
|
|
20
|
+
</nav>
|
|
21
|
+
<div id="docs-content" class="docs-content" tabindex="-1">
|
|
22
|
+
<div class="docs-bar">
|
|
23
|
+
<div v-if="!standalone" class="docs-bar-start">
|
|
24
|
+
<button type="button" class="docs-bar-nav" aria-haspopup="dialog" :aria-expanded="nav" @click="nav = true">
|
|
25
|
+
<DocsIcon name="lucide:panel-left-open" class="docs-icon" />{{ t('docs.contents') }}
|
|
26
|
+
</button>
|
|
27
|
+
<!-- A second trigger for phones; the one above the navigation keeps the keyboard shortcut. -->
|
|
28
|
+
<DocsSearch v-if="search" :shortcut="false" />
|
|
29
|
+
</div>
|
|
30
|
+
<slot name="bar" />
|
|
31
|
+
</div>
|
|
32
|
+
<slot />
|
|
33
|
+
</div>
|
|
34
|
+
<DocsNavSheet v-if="!standalone" v-model:open="nav" />
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The side navigation: the section tabs (when the docs have several), then the pages of the active tab in their
|
|
3
|
+
// sections, then the site's DocsSidebarEnd. Used in the sidebar, the phone sheet and the header menu.
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const { tabs, activeTab, sections } = useDocs()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div>
|
|
10
|
+
<nav v-if="tabs.length" class="docs-tabs" :aria-label="t('docs.sections')">
|
|
11
|
+
<NuxtLink v-for="tab in tabs" :key="tab.title" :to="tab.path" :aria-current="tab.title === activeTab ? 'true' : undefined">{{ tab.title }}</NuxtLink>
|
|
12
|
+
</nav>
|
|
13
|
+
<section v-for="(s, i) in sections" :key="i" class="docs-nav-section">
|
|
14
|
+
<h2 v-if="s.title" class="docs-nav-heading">{{ s.title }}</h2>
|
|
15
|
+
<DocsNavTree :nodes="s.nodes" />
|
|
16
|
+
</section>
|
|
17
|
+
<DocsSidebarEnd />
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Override point: below the docs navigation (sidebar, phone sheet and the header menu). Renders nothing here; a site
|
|
2
|
+
// puts its own `components/DocsSidebarEnd.vue` in its app to add links or a contact line. A render function, not an
|
|
3
|
+
// empty <template>: server and browser then agree on the empty placeholder.
|
|
4
|
+
export default defineComponent({ name: 'DocsSidebarEnd', render: () => null })
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Markdown tables scroll sideways on a phone. The wrapper is the scroll box: keyboard users can focus it and scroll with the arrow keys.
|
|
3
|
+
// role=group, not region: a page can hold several tables, and landmarks with the same name are an error.
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="docs-table" role="group" tabindex="0" :aria-label="t('docs.table')">
|
|
9
|
+
<table><slot /></table>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Auto, light or dark. Auto follows the system and is the default; a choice lives in localStorage and
|
|
3
|
+
// <html data-theme>, and the head script applies it before paint. Both icons are in the HTML and CSS shows the
|
|
4
|
+
// right one, so the server render never guesses the reader's scheme.
|
|
5
|
+
import { COLOR_KEY, colorInitScript } from '../lib/docsInit'
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
useHead({ script: [{ key: 'docs-color-init', innerHTML: colorInitScript(), tagPriority: 'critical' }] })
|
|
9
|
+
|
|
10
|
+
// `segmented` is the phone menu's Auto/Light/Dark switch: the same states and the same storage.
|
|
11
|
+
const props = defineProps<{ segmented?: boolean }>()
|
|
12
|
+
type Choice = 'auto' | 'light' | 'dark'
|
|
13
|
+
const system = (): 'light' | 'dark' => (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
14
|
+
const current = (): Choice => (document.documentElement.getAttribute('data-theme') as Choice | null) ?? 'auto'
|
|
15
|
+
// Only read when the switch is on screen (the phone menu renders on the client), so SSR never guesses.
|
|
16
|
+
const choice = ref<Choice>(props.segmented && import.meta.client ? current() : 'auto')
|
|
17
|
+
|
|
18
|
+
function choose(next: Choice) {
|
|
19
|
+
const html = document.documentElement
|
|
20
|
+
if (next === 'auto') html.removeAttribute('data-theme')
|
|
21
|
+
else html.setAttribute('data-theme', next)
|
|
22
|
+
choice.value = next
|
|
23
|
+
try {
|
|
24
|
+
if (next === 'auto') localStorage.removeItem(COLOR_KEY)
|
|
25
|
+
else localStorage.setItem(COLOR_KEY, next)
|
|
26
|
+
} catch { /* storage blocked: the choice lasts until reload */ }
|
|
27
|
+
}
|
|
28
|
+
// The header button flips what the reader sees; flipping back to the system's scheme returns to Auto.
|
|
29
|
+
function toggle() {
|
|
30
|
+
const shown = current() === 'auto' ? system() : current()
|
|
31
|
+
const next = shown === 'dark' ? 'light' : 'dark'
|
|
32
|
+
choose(next === system() ? 'auto' : next)
|
|
33
|
+
}
|
|
34
|
+
const OPTIONS = ['auto', 'light', 'dark'] as const
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div v-if="props.segmented" class="docs-segmented" role="group" :aria-label="t('docs.theme.label')">
|
|
39
|
+
<button v-for="o in OPTIONS" :key="o" type="button" :aria-pressed="choice === o" @click="choose(o)">{{ t(`docs.theme.${o}`) }}</button>
|
|
40
|
+
</div>
|
|
41
|
+
<button v-else type="button" class="docs-icon-button docs-theme-toggle" :aria-label="t('docs.theme.toggle')" :title="t('docs.theme.toggle')" @click="toggle">
|
|
42
|
+
<DocsIcon name="lucide:moon" class="docs-icon docs-when-light" />
|
|
43
|
+
<DocsIcon name="lucide:sun" class="docs-icon docs-when-dark" />
|
|
44
|
+
</button>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The table of contents of a page: a rail beside the text on wide screens (`aside`), a menu in the docs bar
|
|
3
|
+
// on narrower ones (`menu`). The current section is the last heading that has passed the top of the screen.
|
|
4
|
+
interface TocLink { id: string, text: string, children?: TocLink[] }
|
|
5
|
+
const props = defineProps<{ links: TocLink[], variant: 'aside' | 'menu' }>()
|
|
6
|
+
const { t } = useI18n()
|
|
7
|
+
const { open, toggle } = useDocsPopover()
|
|
8
|
+
|
|
9
|
+
const flat = computed(() => props.links.flatMap((l) => [{ ...l, sub: false }, ...(l.children ?? []).map((c) => ({ ...c, sub: true }))]))
|
|
10
|
+
const current = ref<string>()
|
|
11
|
+
|
|
12
|
+
// A scroll listener instead of an IntersectionObserver: the text may render after this component on client
|
|
13
|
+
// navigation, and looking the headings up on each (throttled) scroll needs no bookkeeping for that.
|
|
14
|
+
let frame = 0
|
|
15
|
+
function spy() {
|
|
16
|
+
cancelAnimationFrame(frame)
|
|
17
|
+
frame = requestAnimationFrame(() => {
|
|
18
|
+
const top = Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 96
|
|
19
|
+
let id: string | undefined
|
|
20
|
+
for (const l of flat.value) {
|
|
21
|
+
const el = document.getElementById(l.id)
|
|
22
|
+
if (el && el.getBoundingClientRect().top <= top + 8) id = l.id
|
|
23
|
+
}
|
|
24
|
+
current.value = id ?? flat.value[0]?.id
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
onMounted(() => {
|
|
28
|
+
spy()
|
|
29
|
+
window.addEventListener('scroll', spy, { passive: true })
|
|
30
|
+
})
|
|
31
|
+
onBeforeUnmount(() => {
|
|
32
|
+
cancelAnimationFrame(frame)
|
|
33
|
+
window.removeEventListener('scroll', spy)
|
|
34
|
+
})
|
|
35
|
+
watch(() => props.links, () => nextTick(spy))
|
|
36
|
+
// A long table of contents scrolls in its own box: keep the current entry in view (the box only, not the page).
|
|
37
|
+
const aside = ref<HTMLElement | null>(null)
|
|
38
|
+
watch(current, (id) => {
|
|
39
|
+
const box = aside.value
|
|
40
|
+
const link = id ? box?.querySelector<HTMLElement>(`a[href="#${CSS.escape(id)}"]`) : undefined
|
|
41
|
+
if (!box || !link || box.scrollHeight <= box.clientHeight) return
|
|
42
|
+
const top = link.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop
|
|
43
|
+
if (top < box.scrollTop + 32 || top > box.scrollTop + box.clientHeight - 48) box.scrollTop = top - box.clientHeight / 3
|
|
44
|
+
})
|
|
45
|
+
const title = computed(() => flat.value.find((l) => l.id === current.value)?.text)
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<nav v-if="props.variant === 'aside'" ref="aside" class="docs-toc" :aria-label="t('docs.onThisPage')">
|
|
50
|
+
<p class="docs-toc-title">
|
|
51
|
+
<DocsIcon name="lucide:text-align-start" class="docs-icon" />{{ t('docs.onThisPage') }}
|
|
52
|
+
</p>
|
|
53
|
+
<ul class="docs-toc-list">
|
|
54
|
+
<li v-for="l in flat" :key="l.id">
|
|
55
|
+
<a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined">{{ l.text }}</a>
|
|
56
|
+
</li>
|
|
57
|
+
</ul>
|
|
58
|
+
<slot />
|
|
59
|
+
</nav>
|
|
60
|
+
<div v-else ref="popover" class="docs-toc-menu">
|
|
61
|
+
<button type="button" :aria-expanded="open" aria-controls="docs-toc-menu" @click="toggle">
|
|
62
|
+
<span>{{ title && flat.length > 1 ? title : t('docs.onThisPage') }}</span>
|
|
63
|
+
<DocsIcon name="lucide:chevron-down" class="docs-icon" />
|
|
64
|
+
</button>
|
|
65
|
+
<nav v-if="open" id="docs-toc-menu" class="docs-popover" :aria-label="t('docs.onThisPage')">
|
|
66
|
+
<ul class="docs-toc-list">
|
|
67
|
+
<li v-for="l in flat" :key="l.id">
|
|
68
|
+
<a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined" @click="toggle">{{ l.text }}</a>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
</nav>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What is docs-specific on top of Ginko's navigation: the tree comes from `useGinkoNav`, the
|
|
2
|
+
// breadcrumb chain from `useGinkoBreadcrumbs`; this adds tabs and sections from the entries' fields.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import { enrichNav, type DocNode, type DocTab } from '../lib/docsNav'
|
|
5
|
+
|
|
6
|
+
export function useDocs() {
|
|
7
|
+
const items = useGinko('docs')
|
|
8
|
+
const tree = useGinkoNav(['docs'])
|
|
9
|
+
const chain = useGinkoBreadcrumbs(['docs'])
|
|
10
|
+
|
|
11
|
+
// Ginko's nodes carry title and path; badge, icon, section and tab live on the entry (rules: enrichNav).
|
|
12
|
+
const nodes = computed<DocNode[]>(() => {
|
|
13
|
+
const byId = new Map(items.value.items.map((d) => [d.id, d.data]))
|
|
14
|
+
return enrichNav(tree.value, (id) => byId.get(id))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const tabs = computed<DocTab[]>(() => {
|
|
18
|
+
const out = new Map<string, DocTab>()
|
|
19
|
+
for (const n of nodes.value) if (n.tab && !out.has(n.tab)) out.set(n.tab, { title: n.tab, path: n.path })
|
|
20
|
+
return out.size > 1 ? [...out.values()] : []
|
|
21
|
+
})
|
|
22
|
+
const activeTab = computed(() => (tabs.value.length ? nodes.value.find((n) => n.path === chain.value[0]?.path)?.tab ?? tabs.value[0]!.title : undefined))
|
|
23
|
+
|
|
24
|
+
// Roots of the active tab, grouped by `section` (a section is a run of neighbours with the same one).
|
|
25
|
+
const sections = computed(() => {
|
|
26
|
+
const out: { title?: string; nodes: DocNode[] }[] = []
|
|
27
|
+
for (const node of nodes.value) {
|
|
28
|
+
if (tabs.value.length && (node.tab ?? tabs.value[0]!.title) !== activeTab.value) continue
|
|
29
|
+
const last = out.at(-1)
|
|
30
|
+
if (last && last.title === node.section) last.nodes.push(node)
|
|
31
|
+
else out.push({ title: node.section, nodes: [node] })
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Tab (when tabs are shown), then the parent chain down to this page.
|
|
37
|
+
const crumbs = computed<{ title: string; path: string }[]>(() => {
|
|
38
|
+
const tab = tabs.value.find((t) => t.title === activeTab.value)
|
|
39
|
+
return [...(tab ? [tab] : []), ...chain.value.map(({ title, path }) => ({ title, path }))]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** The first page of the docs in this language: where `/docs` (and `/` without a start page) lead. */
|
|
43
|
+
const first = computed(() => nodes.value[0]?.path)
|
|
44
|
+
|
|
45
|
+
/** Where the docs live in this language (docsCollection's `base`, `/docs` by default), e.g. `/de/docs`. */
|
|
46
|
+
const { localePath } = useI18n()
|
|
47
|
+
const { mainLocale } = useGinkoSite()
|
|
48
|
+
const route = (schema.bricks.docs as { route?: Record<string, string> } | undefined)?.route?.[mainLocale]
|
|
49
|
+
const mainBase = route?.replace(/\/\[slug\]$/, '') ?? '/docs'
|
|
50
|
+
const base = computed(() => localePath(mainBase))
|
|
51
|
+
|
|
52
|
+
return { nodes, tabs, activeTab, sections, crumbs, first, base, mainBase }
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The optional blog and changelog: whether the site has them, where they live in this language, and typed
|
|
2
|
+
// reads. The layer cannot know the site's bricks at compile time, so the reads go through one cast here.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import type { Author, ChangelogEntry, Post } from '../lib/blogTypes'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const has = (brick: string) => Object.hasOwn(schema.bricks, brick)
|
|
8
|
+
const read = <T>(brick: string) => (useGinko as unknown as (b: string) => ComputedRef<{ items: T[] }>)(brick)
|
|
9
|
+
|
|
10
|
+
export function useDocsBlog() {
|
|
11
|
+
const { localePath } = useI18n()
|
|
12
|
+
// The addresses of the bricks' lists in the main language, written by modules/pages.ts.
|
|
13
|
+
const address = useRuntimeConfig().public.ginkoDocs
|
|
14
|
+
const hasPosts = has('posts') && !!address.blog
|
|
15
|
+
const hasChangelog = has('changelog') && !!address.changelog
|
|
16
|
+
|
|
17
|
+
const authors = hasPosts && has('authors') ? read<Author>('authors') : undefined
|
|
18
|
+
const authorsOf = (post: Post) => (post.data.authors ?? []).flatMap((id) => authors?.value.items.find((a) => a.id === id) ?? [])
|
|
19
|
+
const { format } = useI18n()
|
|
20
|
+
const formatDate = (iso: string) => format.date(iso, 'long')
|
|
21
|
+
|
|
22
|
+
// `draft` is a reserved content key, no schema field; it reaches the bundle only in dev, where drafts show.
|
|
23
|
+
const isDraft = (post: Post) => 'draft' in post.data && post.data.draft === true
|
|
24
|
+
return {
|
|
25
|
+
hasPosts, hasChangelog, authorsOf, isDraft, formatDate,
|
|
26
|
+
blogPath: computed(() => localePath(address.blog)),
|
|
27
|
+
feedPath: computed(() => localePath(`${address.blog}/rss.xml`)),
|
|
28
|
+
changelogPath: computed(() => localePath(address.changelog)),
|
|
29
|
+
posts: () => read<Post>('posts'),
|
|
30
|
+
changelog: () => read<ChangelogEntry>('changelog'),
|
|
31
|
+
post: (slug: string) => (useGinkoEntry as unknown as (b: string, s: string) => Readonly<Ref<Post>>)('posts', slug),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copies text and shows the result for two seconds: `copied`, or `failed` when the clipboard is blocked.
|
|
2
|
+
export function useDocsCopy() {
|
|
3
|
+
const state = ref<'idle' | 'copied' | 'failed'>('idle')
|
|
4
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
5
|
+
async function copy(text: string | (() => Promise<string>)) {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(typeof text === 'string' ? text : await text())
|
|
8
|
+
state.value = 'copied'
|
|
9
|
+
} catch {
|
|
10
|
+
state.value = 'failed'
|
|
11
|
+
}
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
timer = setTimeout(() => { state.value = 'idle' }, 2000)
|
|
14
|
+
}
|
|
15
|
+
onBeforeUnmount(() => clearTimeout(timer))
|
|
16
|
+
return { state, copy }
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Whether the docs render in the layer's own frame (layouts/default.vue) or inside a site's layout. Standalone, the
|
|
4
|
+
// header holds the search and, on phones, the navigation; embedded, DocsShell brings both itself.
|
|
5
|
+
const STANDALONE: InjectionKey<boolean> = Symbol('docs-standalone')
|
|
6
|
+
|
|
7
|
+
/** Called by the layer's own layout. */
|
|
8
|
+
export const provideDocsStandalone = () => provide(STANDALONE, true)
|
|
9
|
+
|
|
10
|
+
/** True inside the layer's own frame, false when a site's layout holds the docs. */
|
|
11
|
+
export const useDocsStandalone = () => inject(STANDALONE, false)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A small disclosure popover (language menu, page actions, phone table of contents): a button toggles a panel;
|
|
2
|
+
// a click outside, Escape or a page change closes it, and Escape returns focus to the button.
|
|
3
|
+
// The component puts `ref="popover"` on the element that holds both the button and the panel, and binds `toggle`
|
|
4
|
+
// to the button's click (Escape returns focus to that button).
|
|
5
|
+
export function useDocsPopover() {
|
|
6
|
+
const open = ref(false)
|
|
7
|
+
const root = useTemplateRef<HTMLElement>('popover')
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
let trigger: HTMLElement | undefined
|
|
10
|
+
|
|
11
|
+
function close(refocus = false) {
|
|
12
|
+
if (!open.value) return
|
|
13
|
+
open.value = false
|
|
14
|
+
if (refocus) (trigger ?? root.value?.querySelector<HTMLElement>('[aria-expanded]'))?.focus()
|
|
15
|
+
}
|
|
16
|
+
const onClick = (e: MouseEvent) => { if (!root.value?.contains(e.target as Node)) close() }
|
|
17
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(true) }
|
|
18
|
+
function listen(on: boolean) {
|
|
19
|
+
if (on) {
|
|
20
|
+
document.addEventListener('click', onClick, true)
|
|
21
|
+
document.addEventListener('keydown', onKey)
|
|
22
|
+
} else {
|
|
23
|
+
document.removeEventListener('click', onClick, true)
|
|
24
|
+
document.removeEventListener('keydown', onKey)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
watch(open, (value) => listen(value))
|
|
29
|
+
watch(() => route.fullPath, () => close())
|
|
30
|
+
onBeforeUnmount(() => listen(false))
|
|
31
|
+
function toggle(event?: Event) {
|
|
32
|
+
if (event?.currentTarget instanceof HTMLElement) trigger = event.currentTarget
|
|
33
|
+
open.value = !open.value
|
|
34
|
+
}
|
|
35
|
+
return { open, toggle, close }
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The site's own links outside Markdown (app.config nav, footer and banner, the start page's links and cards): their
|
|
2
|
+
// texts and their addresses.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import { docsMainPath, docsRouteLink } from '../lib/docsPage'
|
|
5
|
+
|
|
6
|
+
export function useDocsSite() {
|
|
7
|
+
const { t, localePath } = useI18n()
|
|
8
|
+
const docs = useGinko('docs')
|
|
9
|
+
const locale = useGinkoLocale()
|
|
10
|
+
const { mainLocale } = useGinkoSite()
|
|
11
|
+
const pages = schema.pages as Record<string, Partial<Record<string, string>> | undefined>
|
|
12
|
+
const docsRoute = (schema.bricks as Record<string, { route?: Partial<Record<string, string>> } | undefined>).docs?.route
|
|
13
|
+
const find = {
|
|
14
|
+
// A page the current language lacks leads to its main-language page.
|
|
15
|
+
doc: (ref: string) => docs.value.items.find((d) => d.id === `docs/${ref}` || d.data.slug === ref)?.url ?? docsMainPath(docsRoute, mainLocale, ref),
|
|
16
|
+
page: (key: string) => pages[key]?.[locale.value] ?? pages[key]?.[mainLocale],
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
/** A message key the site defines (`docs.nav.<id>`, `docs.footer.<id>`, `docs.banner.<id>`); the layer's types do not know them. */
|
|
20
|
+
text: t as (key: string) => string,
|
|
21
|
+
/**
|
|
22
|
+
* An address in the reader's language: a route link (`$docs.<page>`, `$page.<key>`) becomes the page's path, an
|
|
23
|
+
* internal path gets the language prefix; an absolute URL, a mail link or an anchor stays as written.
|
|
24
|
+
*/
|
|
25
|
+
href: (raw: string) => {
|
|
26
|
+
// A route link that names nothing never becomes an address: it would be a relative link to a 404.
|
|
27
|
+
if (raw.startsWith('$')) return docsRouteLink(raw, find) ?? localePath('/')
|
|
28
|
+
return raw.startsWith('/') && !raw.startsWith('//') ? localePath(raw) : raw
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The blog: all posts, newest first (the collection's order).
|
|
3
|
+
const { t, language } = useI18n()
|
|
4
|
+
const blog = useDocsBlog()
|
|
5
|
+
const langOf = useGinkoLang()
|
|
6
|
+
const posts = blog.posts()
|
|
7
|
+
// A list page: CollectionPage JSON-LD, so search engines read it as the index of its entries.
|
|
8
|
+
const site = useGinkoSite()
|
|
9
|
+
const route = useRoute()
|
|
10
|
+
useGinkoSeo(() => ({
|
|
11
|
+
title: t('docs.blog'),
|
|
12
|
+
description: t('docs.blogLead'),
|
|
13
|
+
jsonLd: [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: t('docs.blog'), url: new URL(route.path, site.url).href, inLanguage: language.value }],
|
|
14
|
+
}))
|
|
15
|
+
useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
|
|
16
|
+
// Nitro's crawler only follows links to pages (no `.xml`), but it also reads this header, so `nuxt generate` writes the feed.
|
|
17
|
+
if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feedPath.value
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="docs-blog">
|
|
22
|
+
<div class="docs-blog-panel">
|
|
23
|
+
<header class="docs-blog-head">
|
|
24
|
+
<div>
|
|
25
|
+
<h1>{{ t('docs.blog') }}</h1>
|
|
26
|
+
<p class="docs-lead">{{ t('docs.blogLead') }}</p>
|
|
27
|
+
</div>
|
|
28
|
+
<a :href="blog.feedPath.value" type="application/rss+xml"><DocsIcon name="lucide:rss" class="docs-icon" />{{ t('docs.rss') }}</a>
|
|
29
|
+
</header>
|
|
30
|
+
<ul class="docs-posts">
|
|
31
|
+
<li v-for="post in posts.items" :key="post.id">
|
|
32
|
+
<div class="docs-post-side">
|
|
33
|
+
<p class="docs-meta"><time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time></p>
|
|
34
|
+
<p v-if="blog.authorsOf(post).length" class="docs-authors">
|
|
35
|
+
<template v-for="a in blog.authorsOf(post)" :key="a.id">
|
|
36
|
+
<GinkoImage v-if="a.data.avatar" :image="a.data.avatar" class="docs-avatar" />
|
|
37
|
+
</template>
|
|
38
|
+
{{ blog.authorsOf(post).map((a) => a.data.name).join(', ') }}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="docs-post-main">
|
|
42
|
+
<h2>
|
|
43
|
+
<NuxtLink :to="post.url">{{ post.data.title }}</NuxtLink>
|
|
44
|
+
<span v-if="blog.isDraft(post)" class="docs-tag docs-tag-draft">{{ t('docs.draft') }}</span>
|
|
45
|
+
</h2>
|
|
46
|
+
<p v-if="post.data.description" class="docs-post-teaser" :lang="langOf(post, 'description')">{{ post.data.description }}</p>
|
|
47
|
+
<p v-if="post.data.tags?.length" class="docs-tags">
|
|
48
|
+
<span v-for="tag in post.data.tags" :key="tag.tag" class="docs-tag">{{ tag.tag }}</span>
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
</li>
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { releaseAnchor as anchor } from '@lupinum/ginko/basics'
|
|
3
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
4
|
+
// The changelog: one page, one section per release, newest first (the collection's order). Each version has an anchor.
|
|
5
|
+
const { t, language } = useI18n()
|
|
6
|
+
const blog = useDocsBlog()
|
|
7
|
+
const langOf = useGinkoLang()
|
|
8
|
+
const entries = blog.changelog()
|
|
9
|
+
// A list page: CollectionPage JSON-LD, so search engines read it as the index of its entries.
|
|
10
|
+
const site = useGinkoSite()
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
useGinkoSeo(() => ({
|
|
13
|
+
title: t('docs.changelog'),
|
|
14
|
+
description: t('docs.changelogLead'),
|
|
15
|
+
jsonLd: [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: t('docs.changelog'), url: new URL(route.path, site.url).href, inLanguage: language.value }],
|
|
16
|
+
}))
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="docs-blog">
|
|
21
|
+
<div class="docs-blog-panel">
|
|
22
|
+
<header class="docs-blog-head">
|
|
23
|
+
<h1>{{ t('docs.changelog') }}</h1>
|
|
24
|
+
</header>
|
|
25
|
+
<p v-if="!entries.items.length" class="docs-meta">{{ t('docs.changelogEmpty') }}</p>
|
|
26
|
+
<section v-for="entry in entries.items" :id="anchor(entry.data.version)" :key="entry.id" class="docs-release">
|
|
27
|
+
<div class="docs-release-meta">
|
|
28
|
+
<h2><a :href="`#${anchor(entry.data.version)}`">{{ entry.data.version }}</a></h2>
|
|
29
|
+
<p class="docs-meta"><time :datetime="entry.data.date">{{ blog.formatDate(entry.data.date) }}</time></p>
|
|
30
|
+
</div>
|
|
31
|
+
<GinkoMarkdown :value="entry.data.body" :components="markdownComponents" class="docs-prose" :lang="langOf(entry, 'body')" />
|
|
32
|
+
</section>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const doc = useGinkoEntry('docs', () => route.params.slug)
|
|
4
|
+
// The toc comes with the text (page state holds only its ref); the same request as the page's <GinkoMarkdown>.
|
|
5
|
+
const tree = await useGinkoMarkdown(() => doc.value.data.body)
|
|
6
|
+
const toc = computed(() => tree.value?.toc?.links ?? [])
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<DocsShell>
|
|
11
|
+
<template v-if="toc.length" #bar>
|
|
12
|
+
<DocsToc :links="toc" variant="menu" />
|
|
13
|
+
</template>
|
|
14
|
+
<DocsPage :doc="doc" />
|
|
15
|
+
</DocsShell>
|
|
16
|
+
</template>
|