@lupinum/ginko-docs 0.4.0-rc.8 → 0.4.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 +78 -0
- package/LICENSE +1 -1
- package/README.md +206 -54
- package/app/app.config.ts +42 -55
- package/app/assets/docs.css +555 -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 +112 -0
- package/app/components/DocsLanding.vue +55 -0
- package/app/components/DocsLanguage.vue +29 -0
- package/app/components/DocsNavTree.vue +38 -0
- package/app/components/DocsPage.vue +84 -0
- package/app/components/DocsPageActions.vue +47 -0
- package/app/components/DocsPost.vue +58 -0
- package/app/components/DocsSearch.vue +134 -0
- package/app/components/DocsShell.vue +20 -0
- package/app/components/DocsSidebar.vue +18 -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 +52 -0
- package/app/composables/useDocsBlog.ts +32 -0
- package/app/composables/useDocsCopy.ts +17 -0
- package/app/composables/useDocsPopover.ts +36 -0
- package/app/composables/useDocsSite.ts +10 -0
- package/app/docs-pages/blog.vue +47 -0
- package/app/docs-pages/changelog.vue +27 -0
- package/app/docs-pages/doc.vue +14 -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 +23 -115
- package/app/layouts/default.vue +25 -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 +44 -0
- package/app/lib/markdownComponents.ts +16 -0
- package/app/lib/previewToc.ts +18 -0
- package/dist/config.d.mts +232 -0
- package/dist/config.d.ts +232 -0
- package/dist/config.mjs +114 -0
- package/dist/pages.d.mts +5 -0
- package/dist/pages.d.ts +5 -0
- package/dist/pages.mjs +36 -0
- package/dist/shared/ginko-docs.BwbF7JBR.mjs +4 -0
- package/i18n/de.json +1 -0
- package/i18n/en.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 +34 -209
- package/package.json +43 -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/lib/rss.ts +57 -0
- package/server/mcp/handler.ts +108 -0
- package/server/routes/__ginko/docs-mcp.ts +35 -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,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { previewToc } from '../lib/previewToc'
|
|
3
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
4
|
+
// A ```md preview fence (ginko-content parses it into `example`): what the Markdown renders, then the Markdown
|
|
5
|
+
// itself, as two tabs in one frame. The preview arrives as a parsed tree in a prop; the code block is the slot.
|
|
6
|
+
// One copy button serves both tabs (the code block's own is hidden in CSS), so it sits in the same place in each.
|
|
7
|
+
import type { MarkdownTree } from '@lupinum/ginko'
|
|
8
|
+
import { Tab, Tabs } from '@lupinum/ginko-components/components'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ preview: MarkdownTree['nodes'] }>()
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const { t: tc } = useI18n('components') // the code block's own texts: "Copy code", "Copied"
|
|
13
|
+
const tree = computed<MarkdownTree>(() => ({ nodes: props.preview, toc: previewToc(props.preview) }))
|
|
14
|
+
|
|
15
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
16
|
+
const copied = ref(false)
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
18
|
+
async function copy() {
|
|
19
|
+
const text = (root.value?.querySelector('pre')?.textContent ?? '').replace(/\n$/, '')
|
|
20
|
+
try { await navigator.clipboard.writeText(text) } catch { return }
|
|
21
|
+
copied.value = true
|
|
22
|
+
clearTimeout(timer)
|
|
23
|
+
timer = setTimeout(() => { copied.value = false }, 2000)
|
|
24
|
+
}
|
|
25
|
+
onBeforeUnmount(() => clearTimeout(timer))
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div ref="root" class="docs-example">
|
|
30
|
+
<Tabs variant="line">
|
|
31
|
+
<Tab :title="t('docs.example.preview')">
|
|
32
|
+
<GinkoMarkdown :value="tree" :components="markdownComponents" class="docs-example-preview" />
|
|
33
|
+
</Tab>
|
|
34
|
+
<Tab :title="t('docs.example.source')">
|
|
35
|
+
<slot />
|
|
36
|
+
</Tab>
|
|
37
|
+
</Tabs>
|
|
38
|
+
<button type="button" class="ginko-code-copy docs-example-copy" :aria-label="tc('code.copy')" @click="copy">
|
|
39
|
+
<Icon :name="copied ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
40
|
+
<span class="ginko-code-status" aria-live="polite">{{ copied ? tc('code.copied') : '' }}</span>
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Site links (texts: docs.footer.<id>) and the "Built with Ginko" line. Renders nothing when both are off.
|
|
3
|
+
const { t } = useI18n()
|
|
4
|
+
const { text, href } = useDocsSite()
|
|
5
|
+
const footer = useAppConfig().docs?.footer
|
|
6
|
+
const links = footer?.links ?? []
|
|
7
|
+
const attribution = footer?.attribution ?? true
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<footer v-if="links.length || attribution" class="docs-footer">
|
|
12
|
+
<div class="docs-footer-inner">
|
|
13
|
+
<nav v-if="links.length" :aria-label="t('docs.footerNav')">
|
|
14
|
+
<NuxtLink v-for="l in links" :key="l.id" :to="href(l.href)">{{ text(`docs.footer.${l.id}`) }}</NuxtLink>
|
|
15
|
+
</nav>
|
|
16
|
+
<a v-if="attribution" href="https://github.com/lupinum-dev/ginko" rel="noopener">{{ t('docs.builtWith') }}</a>
|
|
17
|
+
</div>
|
|
18
|
+
</footer>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The standalone header: brand, the site's sections (Docs, Blog, Changelog and the site's own links), search,
|
|
3
|
+
// language, light/dark and social links. On phones the menu button opens one full-screen menu: the docs
|
|
4
|
+
// navigation (on docs pages), the site links and social link, the language and the light/dark switch.
|
|
5
|
+
const { t, localePath } = useI18n()
|
|
6
|
+
const cfg = useAppConfig().docs ?? {}
|
|
7
|
+
const title = cfg.title ?? useGinkoSite().name
|
|
8
|
+
const { text, href } = useDocsSite()
|
|
9
|
+
const docs = useDocs()
|
|
10
|
+
const blog = useDocsBlog()
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
|
|
13
|
+
const within = (base: string | undefined) => !!base && (route.path === base || route.path.startsWith(`${base}/`))
|
|
14
|
+
const links = computed(() => [
|
|
15
|
+
...(docs.first.value ? [{ key: 'docs', label: t('docs.docs'), to: docs.first.value, active: within(docs.base.value) }] : []),
|
|
16
|
+
...(blog.hasPosts ? [{ key: 'blog', label: t('docs.blog'), to: blog.blogPath.value, active: within(blog.blogPath.value) }] : []),
|
|
17
|
+
...(blog.hasChangelog ? [{ key: 'changelog', label: t('docs.changelog'), to: blog.changelogPath.value, active: within(blog.changelogPath.value) }] : []),
|
|
18
|
+
...(cfg.nav ?? []).map((l) => ({ key: l.id, label: text(`docs.nav.${l.id}`), to: href(l.href), active: within(href(l.href)) })),
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
// The phone menu. Where the docs navigation shows: on the docs pages only (not the blog or the start page).
|
|
22
|
+
const menu = ref(false)
|
|
23
|
+
const inDocs = computed(() => within(docs.base.value))
|
|
24
|
+
const button = useTemplateRef<HTMLButtonElement>('button')
|
|
25
|
+
const panel = useTemplateRef<HTMLElement>('panel')
|
|
26
|
+
|
|
27
|
+
function close(refocus = false) {
|
|
28
|
+
if (!menu.value) return
|
|
29
|
+
menu.value = false
|
|
30
|
+
if (refocus) button.value?.focus()
|
|
31
|
+
}
|
|
32
|
+
function onKey(e: KeyboardEvent) {
|
|
33
|
+
// An open language popover takes the first Escape.
|
|
34
|
+
if (e.key === 'Escape' && !panel.value?.querySelector('.docs-popover')) close(true)
|
|
35
|
+
}
|
|
36
|
+
const wide = import.meta.client ? matchMedia('(min-width: 64rem)') : undefined
|
|
37
|
+
const onWide = () => { if (wide?.matches) close() }
|
|
38
|
+
|
|
39
|
+
// While open: the page behind does not scroll, Escape closes, and focus moves in (and back to the button on close).
|
|
40
|
+
watch(menu, async (open) => {
|
|
41
|
+
document.documentElement.style.overflow = open ? 'hidden' : ''
|
|
42
|
+
if (open) {
|
|
43
|
+
document.addEventListener('keydown', onKey)
|
|
44
|
+
wide?.addEventListener('change', onWide)
|
|
45
|
+
await nextTick()
|
|
46
|
+
panel.value?.focus()
|
|
47
|
+
} else {
|
|
48
|
+
document.removeEventListener('keydown', onKey)
|
|
49
|
+
wide?.removeEventListener('change', onWide)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
watch(() => route.fullPath, () => close())
|
|
53
|
+
onBeforeUnmount(() => {
|
|
54
|
+
document.documentElement.style.overflow = ''
|
|
55
|
+
document.removeEventListener('keydown', onKey)
|
|
56
|
+
wide?.removeEventListener('change', onWide)
|
|
57
|
+
})
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<header class="docs-header">
|
|
62
|
+
<div class="docs-header-inner">
|
|
63
|
+
<NuxtLink :to="localePath('/')" class="docs-brand">
|
|
64
|
+
<template v-if="cfg.logo">
|
|
65
|
+
<img :class="{ 'docs-when-light': cfg.logo.dark }" :src="cfg.logo.light" :alt="cfg.logo.wordmark ? title : ''">
|
|
66
|
+
<img v-if="cfg.logo.dark" class="docs-when-dark" :src="cfg.logo.dark" alt="">
|
|
67
|
+
</template>
|
|
68
|
+
<span v-if="!cfg.logo?.wordmark">{{ title }}</span>
|
|
69
|
+
</NuxtLink>
|
|
70
|
+
<nav v-if="links.length > 1 || cfg.nav?.length" class="docs-nav-links" :aria-label="t('docs.siteLinks')">
|
|
71
|
+
<NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
|
|
72
|
+
</nav>
|
|
73
|
+
<div class="docs-header-end">
|
|
74
|
+
<DocsSearch />
|
|
75
|
+
<DocsLanguage />
|
|
76
|
+
<DocsThemeToggle />
|
|
77
|
+
<template v-if="cfg.social?.length">
|
|
78
|
+
<span class="docs-header-sep" aria-hidden="true" />
|
|
79
|
+
<div class="docs-social">
|
|
80
|
+
<a v-for="s in cfg.social" :key="s.href" class="docs-icon-button" :href="s.href" target="_blank" rel="noopener" :aria-label="s.label" :title="s.label">
|
|
81
|
+
<Icon :name="s.icon" class="docs-icon" />
|
|
82
|
+
</a>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
<button
|
|
86
|
+
ref="button" type="button" class="docs-icon-button docs-header-menu" :aria-expanded="menu" aria-controls="docs-menu"
|
|
87
|
+
:aria-label="menu ? t('docs.close') : t('docs.siteMenu')" @click="menu = !menu"
|
|
88
|
+
>
|
|
89
|
+
<Icon :name="menu ? 'lucide:x' : 'lucide:menu'" class="docs-icon" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<!-- Teleported: the header's backdrop blur would otherwise make it the containing block of the fixed menu. -->
|
|
94
|
+
<Teleport to="body">
|
|
95
|
+
<div v-if="menu" id="docs-menu" ref="panel" class="docs-menu" role="dialog" tabindex="-1" :aria-label="t('docs.siteMenu')">
|
|
96
|
+
<div v-if="inDocs" class="docs-menu-scroll">
|
|
97
|
+
<DocsSidebar />
|
|
98
|
+
</div>
|
|
99
|
+
<div class="docs-menu-more">
|
|
100
|
+
<nav class="docs-menu-links" :aria-label="t('docs.siteLinks')">
|
|
101
|
+
<NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
|
|
102
|
+
<a v-for="s in cfg.social ?? []" :key="s.href" :href="s.href" target="_blank" rel="noopener">{{ s.label }}</a>
|
|
103
|
+
</nav>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="docs-menu-foot">
|
|
106
|
+
<DocsLanguage />
|
|
107
|
+
<DocsThemeToggle segmented />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</Teleport>
|
|
111
|
+
</header>
|
|
112
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
3
|
+
// The start page from the site's `home` brick (docsHome): hero with title, description, links and an install
|
|
4
|
+
// command, the feature grid, then the free text. Everything is content, so it is translated like the docs.
|
|
5
|
+
import type { ItemOf } from '@lupinum/ginko'
|
|
6
|
+
import type { docsHome } from '@lupinum/ginko-docs/config'
|
|
7
|
+
|
|
8
|
+
type Home = Pick<ItemOf<ReturnType<typeof docsHome>['fields']>, 'data'>
|
|
9
|
+
// The layer cannot know the site's bricks at compile time; index.vue renders this only when `home` exists.
|
|
10
|
+
const home = (useGinko as unknown as (b: string) => ComputedRef<Home>)('home')
|
|
11
|
+
const data = computed(() => home.value.data)
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
useGinkoSeo(() => ({ title: data.value.title, description: data.value.description }))
|
|
14
|
+
|
|
15
|
+
const { state, copy } = useDocsCopy()
|
|
16
|
+
const external = (href: string) => /^https?:\/\//.test(href)
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="docs-landing">
|
|
21
|
+
<div class="docs-landing-inner">
|
|
22
|
+
<section class="docs-hero">
|
|
23
|
+
<h1>{{ data.title }}</h1>
|
|
24
|
+
<p v-if="data.description">{{ data.description }}</p>
|
|
25
|
+
<div v-if="data.links.length" class="docs-hero-links">
|
|
26
|
+
<NuxtLink
|
|
27
|
+
v-for="(l, i) in data.links" :key="l.href" :to="l.href" :target="external(l.href) ? '_blank' : undefined"
|
|
28
|
+
class="docs-button is-large" :class="i === 0 ? 'is-primary' : 'is-soft'"
|
|
29
|
+
>
|
|
30
|
+
{{ l.label }}<Icon v-if="i === 0" name="lucide:arrow-right" class="docs-icon" />
|
|
31
|
+
</NuxtLink>
|
|
32
|
+
</div>
|
|
33
|
+
<div v-if="data.install" class="docs-install">
|
|
34
|
+
<code tabindex="0" role="region" :aria-label="t('docs.installCommand')">{{ data.install }}</code>
|
|
35
|
+
<button type="button" class="docs-icon-button" :aria-label="state === 'copied' ? t('docs.copied') : state === 'failed' ? t('docs.copyFailed') : t('docs.copyCommand')" @click="copy(data.install ?? '')">
|
|
36
|
+
<Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
<ul v-if="data.features.length" class="docs-features">
|
|
41
|
+
<li v-for="f in data.features" :key="f.title" class="docs-feature">
|
|
42
|
+
<div v-if="f.icon" class="docs-feature-icon">
|
|
43
|
+
<Icon :name="f.icon" class="docs-icon" />
|
|
44
|
+
</div>
|
|
45
|
+
<h2>
|
|
46
|
+
<NuxtLink v-if="f.href" :to="f.href">{{ f.title }}</NuxtLink>
|
|
47
|
+
<template v-else>{{ f.title }}</template>
|
|
48
|
+
</h2>
|
|
49
|
+
<p>{{ f.description }}</p>
|
|
50
|
+
</li>
|
|
51
|
+
</ul>
|
|
52
|
+
<GinkoMarkdown v-if="data.body" :value="data.body" :components="markdownComponents" class="docs-prose docs-landing-body" />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The languages of this page (ginko-i18n alternates, so a doc links to its own translation). A menu in the
|
|
3
|
+
// header and in the phone menu. Each language is named in itself ("Deutsch").
|
|
4
|
+
const { t, alternates } = useI18n()
|
|
5
|
+
const { open, toggle } = useDocsPopover()
|
|
6
|
+
const current = computed(() => alternates.value.find((a) => a.current))
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<template v-if="alternates.length > 1">
|
|
11
|
+
<div ref="popover" class="docs-lang">
|
|
12
|
+
<button
|
|
13
|
+
type="button" class="docs-icon-button" :aria-expanded="open" aria-controls="docs-lang-menu"
|
|
14
|
+
:aria-label="`${t('docs.language')}: ${current?.name ?? ''}`" @click="toggle"
|
|
15
|
+
>
|
|
16
|
+
<Icon name="lucide:languages" class="docs-icon" />
|
|
17
|
+
<span aria-hidden="true">{{ current?.code.toUpperCase() }}</span>
|
|
18
|
+
</button>
|
|
19
|
+
<ul v-if="open" id="docs-lang-menu" class="docs-popover">
|
|
20
|
+
<li v-for="a in alternates" :key="a.code">
|
|
21
|
+
<NuxtLink :to="a.path" :hreflang="a.language" :lang="a.language" :aria-current="a.current ? 'page' : undefined">
|
|
22
|
+
{{ a.name }}
|
|
23
|
+
<Icon v-if="a.current" name="lucide:check" class="docs-icon" />
|
|
24
|
+
</NuxtLink>
|
|
25
|
+
</li>
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One level of the side navigation. A node with children gets a toggle button; the branch of the current page
|
|
3
|
+
// starts open, and a reader's own choice holds until the page changes.
|
|
4
|
+
import type { DocNode } from '../lib/docsNav'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ nodes: DocNode[] }>()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const choice = reactive(new Map<string, boolean>())
|
|
10
|
+
watch(() => route.path, () => choice.clear())
|
|
11
|
+
|
|
12
|
+
const holdsCurrent = (node: DocNode): boolean => node.path === route.path || node.children.some(holdsCurrent)
|
|
13
|
+
const isOpen = (node: DocNode) => choice.get(node.path) ?? holdsCurrent(node)
|
|
14
|
+
const toggle = (node: DocNode) => choice.set(node.path, !isOpen(node))
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<ul class="docs-nav-list">
|
|
19
|
+
<li v-for="node in props.nodes" :key="node.path">
|
|
20
|
+
<div class="docs-nav-item">
|
|
21
|
+
<NuxtLink :to="node.path" :aria-current="node.path === route.path ? 'page' : undefined">
|
|
22
|
+
<Icon v-if="node.icon" :name="node.icon" class="docs-icon" />
|
|
23
|
+
<span class="docs-nav-title">{{ node.title }}</span>
|
|
24
|
+
<span v-if="node.badge" class="docs-badge">{{ node.badge }}</span>
|
|
25
|
+
</NuxtLink>
|
|
26
|
+
<button
|
|
27
|
+
v-if="node.children.length" type="button" class="docs-nav-toggle"
|
|
28
|
+
:aria-expanded="isOpen(node)" :aria-label="t('docs.toggle', { title: node.title })" @click="toggle(node)"
|
|
29
|
+
>
|
|
30
|
+
<Icon name="lucide:chevron-right" class="docs-icon" />
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div v-if="node.children.length" v-show="isOpen(node)" class="docs-nav-children">
|
|
34
|
+
<DocsNavTree :nodes="node.children" />
|
|
35
|
+
</div>
|
|
36
|
+
</li>
|
|
37
|
+
</ul>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { docsEditUrl, docsIssueUrl, docsStudioUrl } from '../lib/docsPage'
|
|
3
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
4
|
+
// One doc: breadcrumbs, title, lead and page actions, the text with the layer's Markdown components, the edit
|
|
5
|
+
// link and date, previous/next; the table of contents beside it (or in the docs bar, see doc.vue).
|
|
6
|
+
import type { ItemOf } from '@lupinum/ginko'
|
|
7
|
+
import { articleLd, breadcrumbLd } from '@lupinum/ginko/internal'
|
|
8
|
+
import type { docsCollection } from '@lupinum/ginko-docs/config'
|
|
9
|
+
|
|
10
|
+
// `source` (the page's repository path) is in the bundle because this layer asks for it (hook `ginko:files`, nuxt.config.ts).
|
|
11
|
+
const props = defineProps<{ doc: ItemOf<ReturnType<typeof docsCollection>['fields']> & { source?: string } }>()
|
|
12
|
+
const { t, format, language } = useI18n()
|
|
13
|
+
const { crumbs } = useDocs()
|
|
14
|
+
const neighbors = useGinkoNeighbors('docs', () => props.doc.id)
|
|
15
|
+
const site = useGinkoSite()
|
|
16
|
+
// The Studio link needs the platform address and the site name: internal keys of the Ginko module, read by its own layer.
|
|
17
|
+
const { platform, site: siteName } = useRuntimeConfig().public.ginko as { site: string, platform: string }
|
|
18
|
+
const route = useRoute()
|
|
19
|
+
useGinkoSeo(() => ({
|
|
20
|
+
title: props.doc.data.title,
|
|
21
|
+
description: props.doc.data.description,
|
|
22
|
+
section: props.doc.data.section,
|
|
23
|
+
type: 'article',
|
|
24
|
+
jsonLd: [
|
|
25
|
+
// articleLd fixes the type to Article; docs are TechArticle.
|
|
26
|
+
{ ...articleLd({ title: props.doc.data.title, url: new URL(route.path, site.url).href, description: props.doc.data.description, modified: props.doc.updatedAt, lang: language.value }), '@type': 'TechArticle' },
|
|
27
|
+
...(crumbs.value.length > 1 ? [breadcrumbLd(crumbs.value.map((c) => ({ ...c, children: [] })), new URL(site.url).origin)] : []),
|
|
28
|
+
],
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
const repository = useAppConfig().docs?.repository
|
|
32
|
+
// A page from a file is edited in the repository; one stored on the platform in the Studio.
|
|
33
|
+
const editUrl = computed(() => (props.doc.source ? docsEditUrl(repository, props.doc.source) : docsStudioUrl(platform, siteName, 'docs', props.doc.id)))
|
|
34
|
+
const issueUrl = computed(() => docsIssueUrl(repository, props.doc.data.title, new URL(route.path, site.url).href))
|
|
35
|
+
const updated = computed(() => props.doc.updatedAt && {
|
|
36
|
+
iso: props.doc.updatedAt,
|
|
37
|
+
text: format.date(new Date(props.doc.updatedAt), 'long'),
|
|
38
|
+
})
|
|
39
|
+
const toc = computed(() => props.doc.data.body.toc?.links ?? [])
|
|
40
|
+
const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<div class="docs-page">
|
|
45
|
+
<article class="docs-article">
|
|
46
|
+
<nav v-if="crumbs.length > 1" class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
|
|
47
|
+
<ol>
|
|
48
|
+
<li v-for="(c, i) in crumbs" :key="c.path + i">
|
|
49
|
+
<Icon v-if="i" name="lucide:chevron-right" class="docs-icon" />
|
|
50
|
+
<span v-if="i === crumbs.length - 1" aria-current="page">{{ c.title }}</span>
|
|
51
|
+
<NuxtLink v-else :to="c.path">{{ c.title }}</NuxtLink>
|
|
52
|
+
</li>
|
|
53
|
+
</ol>
|
|
54
|
+
</nav>
|
|
55
|
+
<header class="docs-page-head">
|
|
56
|
+
<h1>{{ doc.data.title }}</h1>
|
|
57
|
+
<p v-if="doc.data.description" class="docs-lead">{{ doc.data.description }}</p>
|
|
58
|
+
<DocsPageActions :path="route.path" :origin="site.url" />
|
|
59
|
+
</header>
|
|
60
|
+
<GinkoMarkdown :value="doc.data.body" :components="markdownComponents" class="docs-prose" />
|
|
61
|
+
<footer>
|
|
62
|
+
<div v-if="editUrl || updated" class="docs-page-foot">
|
|
63
|
+
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
|
|
64
|
+
<span v-if="updated">{{ t('docs.updated') }} <time :datetime="updated.iso">{{ updated.text }}</time></span>
|
|
65
|
+
</div>
|
|
66
|
+
<nav v-if="neighbors.prev || neighbors.next" class="docs-pager" :aria-label="t('docs.pager')">
|
|
67
|
+
<NuxtLink v-if="neighbors.prev" :to="neighbors.prev.url" rel="prev">
|
|
68
|
+
<small><Icon name="lucide:arrow-left" class="docs-icon" />{{ t('docs.previous') }}</small>{{ neighbors.prev.data.title }}
|
|
69
|
+
</NuxtLink>
|
|
70
|
+
<NuxtLink v-if="neighbors.next" :to="neighbors.next.url" rel="next" class="is-next">
|
|
71
|
+
<small>{{ t('docs.next') }}<Icon name="lucide:arrow-right" class="docs-icon" /></small>{{ neighbors.next.data.title }}
|
|
72
|
+
</NuxtLink>
|
|
73
|
+
</nav>
|
|
74
|
+
</footer>
|
|
75
|
+
</article>
|
|
76
|
+
<DocsToc v-if="toc.length" :links="toc" variant="aside">
|
|
77
|
+
<div class="docs-toc-more">
|
|
78
|
+
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
|
|
79
|
+
<a v-if="issueUrl" :href="issueUrl" target="_blank" rel="noopener"><Icon name="lucide:circle-alert" class="docs-icon" />{{ t('docs.issue') }}</a>
|
|
80
|
+
<button type="button" @click="top"><Icon name="lucide:arrow-up" class="docs-icon" />{{ t('docs.top') }}</button>
|
|
81
|
+
</div>
|
|
82
|
+
</DocsToc>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { docsAskLinks, docsMarkdownPath, type DocsAssistant } from '../lib/docsPage'
|
|
3
|
+
// "Copy page" copies the page as Markdown; the menu next to it opens the Markdown twin or a chat that reads it.
|
|
4
|
+
const props = defineProps<{ path: string, origin: string }>()
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
const cfg = useAppConfig().docs?.markdownActions ?? {}
|
|
7
|
+
const assistants = {
|
|
8
|
+
chatgpt: { icon: 'docs:openai', label: 'docs.openChatGpt' },
|
|
9
|
+
claude: { icon: 'docs:claude', label: 'docs.openClaude' },
|
|
10
|
+
} as const satisfies Record<DocsAssistant, { icon: string, label: string }>
|
|
11
|
+
// Unknown names in `openIn` are skipped, so a typo in app.config.ts hides one entry instead of breaking the menu.
|
|
12
|
+
const openIn = (cfg.openIn ?? ['chatgpt', 'claude']).filter((id) => Object.hasOwn(assistants, id))
|
|
13
|
+
const { open, toggle } = useDocsPopover()
|
|
14
|
+
const mdPath = computed(() => docsMarkdownPath(props.path))
|
|
15
|
+
const links = computed(() => docsAskLinks(t('docs.askPrompt', { url: new URL(mdPath.value, props.origin).href })))
|
|
16
|
+
|
|
17
|
+
const { state, copy: copyText } = useDocsCopy()
|
|
18
|
+
const copy = () => copyText(() => $fetch<string>(mdPath.value, { responseType: 'text' }))
|
|
19
|
+
const menu = openIn.length > 0
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div v-if="cfg.copy !== false || menu" ref="popover" class="docs-actions" role="group" :aria-label="t('docs.pageActions')">
|
|
24
|
+
<button v-if="cfg.copy !== false" type="button" class="docs-button" @click="copy">
|
|
25
|
+
<Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
26
|
+
<span aria-live="polite">{{ state === 'copied' ? t('docs.copied') : state === 'failed' ? t('docs.copyFailed') : t('docs.copyPage') }}</span>
|
|
27
|
+
</button>
|
|
28
|
+
<button type="button" class="docs-button" :aria-expanded="open" aria-controls="docs-page-menu" :aria-label="t('docs.moreActions')" @click="toggle">
|
|
29
|
+
<Icon name="lucide:chevron-down" class="docs-icon" />
|
|
30
|
+
</button>
|
|
31
|
+
<ul v-if="open" id="docs-page-menu" class="docs-popover">
|
|
32
|
+
<li>
|
|
33
|
+
<a :href="mdPath" target="_blank">
|
|
34
|
+
<Icon name="lucide:file-text" class="docs-icon" />
|
|
35
|
+
<span>{{ t('docs.viewMarkdown') }}<small>{{ t('docs.viewMarkdownHint') }}</small></span>
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
<li v-for="id in openIn" :key="id">
|
|
39
|
+
<a :href="links[id]" target="_blank" rel="noopener">
|
|
40
|
+
<Icon :name="assistants[id].icon" class="docs-icon" />
|
|
41
|
+
<span>{{ t(assistants[id].label) }}<small>{{ t('docs.askHint') }}</small></span>
|
|
42
|
+
<Icon name="lucide:arrow-up-right" class="docs-icon" />
|
|
43
|
+
</a>
|
|
44
|
+
</li>
|
|
45
|
+
</ul>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
3
|
+
// One blog post: title, date, authors, text. Article JSON-LD names the authors and carries both dates.
|
|
4
|
+
import { articleLd } from '@lupinum/ginko/internal'
|
|
5
|
+
import type { Post } from '../lib/blogTypes'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ post: Post }>()
|
|
8
|
+
const { t, language } = useI18n()
|
|
9
|
+
const blog = useDocsBlog()
|
|
10
|
+
const site = useGinkoSite()
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const authors = computed(() => blog.authorsOf(props.post))
|
|
13
|
+
const absolute = (path: string) => new URL(path, site.url).href
|
|
14
|
+
|
|
15
|
+
useGinkoSeo(() => {
|
|
16
|
+
const { title, description, date, image } = props.post.data
|
|
17
|
+
const ld = articleLd({
|
|
18
|
+
title, description, lang: language.value, url: absolute(route.path), image: image ? absolute(image.url) : undefined,
|
|
19
|
+
published: date, modified: (props.post.updatedAt ?? date).slice(0, 10),
|
|
20
|
+
})
|
|
21
|
+
return {
|
|
22
|
+
title, description, type: 'article', image: image?.url,
|
|
23
|
+
// articleLd takes one author; a post may have several (and a link), so the list replaces it.
|
|
24
|
+
jsonLd: [authors.value.length ? { ...ld, author: authors.value.map((a) => ({ '@type': 'Person', name: a.data.name, ...(a.data.url ? { url: a.data.url } : {}) })) } : ld],
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div class="docs-blog">
|
|
32
|
+
<div class="docs-blog-panel">
|
|
33
|
+
<article class="docs-post">
|
|
34
|
+
<nav class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
|
|
35
|
+
<ol>
|
|
36
|
+
<li><NuxtLink :to="blog.blogPath.value">{{ t('docs.blog') }}</NuxtLink></li>
|
|
37
|
+
<li><Icon name="lucide:chevron-right" class="docs-icon" /><span aria-current="page">{{ post.data.title }}</span></li>
|
|
38
|
+
</ol>
|
|
39
|
+
</nav>
|
|
40
|
+
<h1>{{ post.data.title }}</h1>
|
|
41
|
+
<p v-if="post.data.description" class="docs-lead">{{ post.data.description }}</p>
|
|
42
|
+
<p class="docs-meta">
|
|
43
|
+
<time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time>
|
|
44
|
+
<template v-if="authors.length">
|
|
45
|
+
· {{ t('docs.by') }}
|
|
46
|
+
<template v-for="(a, i) in authors" :key="a.id">
|
|
47
|
+
<template v-if="i">, </template>
|
|
48
|
+
<a v-if="a.data.url" :href="a.data.url" rel="author">{{ a.data.name }}</a>
|
|
49
|
+
<template v-else>{{ a.data.name }}</template>
|
|
50
|
+
</template>
|
|
51
|
+
</template>
|
|
52
|
+
</p>
|
|
53
|
+
<GinkoImage v-if="post.data.image" :image="post.data.image" class="docs-post-image" eager />
|
|
54
|
+
<GinkoMarkdown :value="post.data.body" :components="markdownComponents" class="docs-prose" />
|
|
55
|
+
</article>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
const { t, locale } = useI18n()
|
|
7
|
+
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
|
|
8
|
+
const input = useTemplateRef<HTMLInputElement>('input')
|
|
9
|
+
const query = ref('')
|
|
10
|
+
const active = ref(0)
|
|
11
|
+
// The engine is imported on first open, so MiniSearch stays out of the page's entry chunk.
|
|
12
|
+
const engine = shallowRef<{ index: SearchIndex; search: typeof import('@lupinum/ginko/search').search }>()
|
|
13
|
+
const mac = ref(true)
|
|
14
|
+
|
|
15
|
+
async function open() {
|
|
16
|
+
dialog.value?.showModal()
|
|
17
|
+
input.value?.select()
|
|
18
|
+
if (engine.value) return
|
|
19
|
+
try {
|
|
20
|
+
const { loadIndex, search } = await import('@lupinum/ginko/search')
|
|
21
|
+
const url: string = `/__ginko/search/${locale.value}.json` // a plain string: the typed route union is too deep to compare
|
|
22
|
+
engine.value = { index: await loadIndex(await $fetch<SearchIndexJson>(url)), search }
|
|
23
|
+
} catch { /* no index: the dialog shows "no results" */ }
|
|
24
|
+
}
|
|
25
|
+
watch(locale, () => { engine.value = undefined })
|
|
26
|
+
|
|
27
|
+
const hits = computed<SearchHit[]>(() => {
|
|
28
|
+
const q = query.value.trim()
|
|
29
|
+
return q && engine.value ? engine.value.search(engine.value.index, q, { limit: 12 }) : []
|
|
30
|
+
})
|
|
31
|
+
// One group per page, in the order of its best hit; the page hit (no heading) leads its group and its sections
|
|
32
|
+
// are indented under it. Pages then sit under their area (Docs, Blog, Changelog), again in the order of the best
|
|
33
|
+
// hit; the area labels show only when hits come from more than one area.
|
|
34
|
+
const AREAS = { docs: 'docs.docs', blog: 'docs.blog', changelog: 'docs.changelog' } as const
|
|
35
|
+
function areaOf(url: string): keyof typeof AREAS | undefined {
|
|
36
|
+
const local = url.startsWith(`/${locale.value}/`) ? url.slice(locale.value.length + 1) : url
|
|
37
|
+
const first = local.split(/[/#?]/)[1] ?? ''
|
|
38
|
+
return Object.hasOwn(AREAS, first) ? (first as keyof typeof AREAS) : undefined
|
|
39
|
+
}
|
|
40
|
+
const groups = computed(() => {
|
|
41
|
+
const byPage = new Map<string, SearchHit[]>()
|
|
42
|
+
for (const hit of hits.value) {
|
|
43
|
+
const page = hit.url.split('#')[0]!
|
|
44
|
+
byPage.set(page, [...(byPage.get(page) ?? []), hit])
|
|
45
|
+
}
|
|
46
|
+
const byArea = new Map<string, { label?: string; rows: { hit: SearchHit; indent: boolean }[] }>()
|
|
47
|
+
for (const list of byPage.values()) {
|
|
48
|
+
const area = areaOf(list[0]!.url)
|
|
49
|
+
const group = byArea.get(area ?? '') ?? { label: area ? t(AREAS[area]) : undefined, rows: [] }
|
|
50
|
+
byArea.set(area ?? '', group)
|
|
51
|
+
const hasPage = list.some((h) => !h.heading)
|
|
52
|
+
group.rows.push(...list.sort((a, b) => Number(!!a.heading) - Number(!!b.heading)).map((hit) => ({ hit, indent: hasPage && !!hit.heading })))
|
|
53
|
+
}
|
|
54
|
+
const list = [...byArea.values()]
|
|
55
|
+
return list.length > 1 ? list : list.map((g) => ({ ...g, label: undefined }))
|
|
56
|
+
})
|
|
57
|
+
const flat = computed(() => groups.value.flatMap((g) => g.rows))
|
|
58
|
+
const indexOf = (hit: SearchHit) => flat.value.findIndex((r) => r.hit.id === hit.id)
|
|
59
|
+
watch(query, () => { active.value = 0 })
|
|
60
|
+
|
|
61
|
+
function move(step: number) {
|
|
62
|
+
if (!flat.value.length) return
|
|
63
|
+
active.value = (active.value + step + flat.value.length) % flat.value.length
|
|
64
|
+
nextTick(() => document.getElementById(`docs-hit-${active.value}`)?.scrollIntoView({ block: 'nearest' }))
|
|
65
|
+
}
|
|
66
|
+
function go() {
|
|
67
|
+
const row = flat.value[active.value]
|
|
68
|
+
if (row) navigateTo(row.hit.url)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const route = useRoute()
|
|
72
|
+
watch(() => route.fullPath, () => dialog.value?.close())
|
|
73
|
+
function onKey(e: KeyboardEvent) {
|
|
74
|
+
const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable
|
|
75
|
+
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
open()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
onMounted(() => {
|
|
81
|
+
mac.value = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
82
|
+
window.addEventListener('keydown', onKey)
|
|
83
|
+
})
|
|
84
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<button type="button" class="docs-search-trigger" :aria-label="t('docs.search')" @click="open">
|
|
89
|
+
<Icon name="lucide:search" class="docs-icon" />
|
|
90
|
+
<span class="docs-search-label">{{ t('docs.searchPlaceholder') }}</span>
|
|
91
|
+
<kbd class="docs-kbd">{{ mac ? '⌘' : 'Ctrl' }} K</kbd>
|
|
92
|
+
</button>
|
|
93
|
+
<Teleport to="body">
|
|
94
|
+
<dialog ref="dialog" class="docs-search-dialog" :aria-label="t('docs.search')" @click.self="dialog?.close()">
|
|
95
|
+
<div class="docs-search-field">
|
|
96
|
+
<Icon name="lucide:search" class="docs-icon" />
|
|
97
|
+
<input
|
|
98
|
+
ref="input" v-model="query" type="search" role="combobox" aria-autocomplete="list" aria-controls="docs-search-list"
|
|
99
|
+
:aria-expanded="flat.length > 0" :aria-activedescendant="flat.length ? `docs-hit-${active}` : undefined"
|
|
100
|
+
:placeholder="t('docs.searchPlaceholder')" :aria-label="t('docs.searchPlaceholder')" autocomplete="off" spellcheck="false"
|
|
101
|
+
@keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="go" @keydown.esc.prevent="dialog?.close()"
|
|
102
|
+
>
|
|
103
|
+
<button type="button" class="docs-kbd" @click="dialog?.close()">Esc</button>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="docs-search-body" tabindex="0" role="region" :aria-label="t('docs.searchResults')">
|
|
106
|
+
<ul v-if="flat.length" id="docs-search-list" class="docs-search-results" role="listbox" :aria-label="t('docs.search')">
|
|
107
|
+
<li v-for="(g, n) in groups" :key="n" role="none">
|
|
108
|
+
<p v-if="g.label" :id="`docs-hit-group-${n}`" class="docs-search-group">{{ g.label }}</p>
|
|
109
|
+
<ul role="group" :aria-labelledby="g.label ? `docs-hit-group-${n}` : undefined">
|
|
110
|
+
<li
|
|
111
|
+
v-for="{ hit, indent } in g.rows" :id="`docs-hit-${indexOf(hit)}`" :key="hit.id" role="option" :aria-selected="indexOf(hit) === active"
|
|
112
|
+
class="docs-search-hit" :class="{ 'is-section': indent }" @mousemove="active = indexOf(hit)" @click="active = indexOf(hit); go()"
|
|
113
|
+
>
|
|
114
|
+
<span class="docs-search-tile"><Icon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
|
|
115
|
+
<span class="docs-search-text">
|
|
116
|
+
<strong>{{ hit.heading ?? hit.title }}</strong>
|
|
117
|
+
<span>{{ indent ? hit.excerpt : hit.heading ? hit.title : hit.excerpt }}</span>
|
|
118
|
+
</span>
|
|
119
|
+
<Icon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
|
|
120
|
+
</li>
|
|
121
|
+
</ul>
|
|
122
|
+
</li>
|
|
123
|
+
</ul>
|
|
124
|
+
<p v-else-if="query.trim()" class="docs-search-empty">{{ t('docs.noResults', { query: query.trim() }) }}</p>
|
|
125
|
+
<p v-else class="docs-search-empty">{{ t('docs.searchHint') }}</p>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="docs-search-foot" aria-hidden="true">
|
|
128
|
+
<span><kbd class="docs-kbd">↑</kbd><kbd class="docs-kbd">↓</kbd> {{ t('docs.searchMove') }}</span>
|
|
129
|
+
<span><kbd class="docs-kbd">↵</kbd> {{ t('docs.searchOpen') }}</span>
|
|
130
|
+
<span><kbd class="docs-kbd">Esc</kbd> {{ t('docs.close') }}</span>
|
|
131
|
+
</div>
|
|
132
|
+
</dialog>
|
|
133
|
+
</Teleport>
|
|
134
|
+
</template>
|