@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,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Announcement above the header. The server always renders it; a dismissed one is hidden before first
|
|
3
|
+
// paint by the head script (html[data-docs-banner-off], docs.css) and removed here after hydration.
|
|
4
|
+
import { bannerInitScript, bannerKey } from '../lib/docsInit'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ id: string, href?: string }>()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const { text, href: siteHref } = useDocsSite()
|
|
9
|
+
useHead({ script: [{ key: 'docs-banner-init', innerHTML: bannerInitScript(props.id), tagPriority: 'critical' }] })
|
|
10
|
+
const dismissed = ref(false)
|
|
11
|
+
onMounted(() => {
|
|
12
|
+
try { dismissed.value = localStorage.getItem(bannerKey(props.id)) === '1' } catch { /* shown */ }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function dismiss() {
|
|
16
|
+
dismissed.value = true
|
|
17
|
+
try { localStorage.setItem(bannerKey(props.id), '1') } catch { /* gone until reload */ }
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div v-if="!dismissed" class="docs-banner" role="region" :aria-label="text(`docs.banner.${id}`)">
|
|
23
|
+
<p>
|
|
24
|
+
<NuxtLink v-if="href" :to="siteHref(href)">{{ text(`docs.banner.${id}`) }}</NuxtLink>
|
|
25
|
+
<template v-else>{{ text(`docs.banner.${id}`) }}</template>
|
|
26
|
+
</p>
|
|
27
|
+
<button type="button" class="docs-icon-button" :aria-label="t('docs.bannerClose')" @click="dismiss"><DocsIcon name="lucide:x" class="docs-icon" /></button>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -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
|
+
<DocsIcon :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,113 @@
|
|
|
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
|
|
4
|
+
// languages as links at its top, the docs navigation (on docs pages), the site links and social links, and the
|
|
5
|
+
// Auto/Light/Dark switch. The light/dark button stays in the header on phones too.
|
|
6
|
+
const { t, localePath } = useI18n()
|
|
7
|
+
const cfg = useAppConfig().docs ?? {}
|
|
8
|
+
const title = cfg.title ?? useGinkoSite().name
|
|
9
|
+
const { text, href } = useDocsSite()
|
|
10
|
+
const docs = useDocs()
|
|
11
|
+
const blog = useDocsBlog()
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
|
|
14
|
+
const within = (base: string | undefined) => !!base && (route.path === base || route.path.startsWith(`${base}/`))
|
|
15
|
+
const links = computed(() => [
|
|
16
|
+
...(docs.first.value ? [{ key: 'docs', label: t('docs.docs'), to: docs.first.value, active: within(docs.base.value) }] : []),
|
|
17
|
+
...(blog.hasPosts ? [{ key: 'blog', label: t('docs.blog'), to: blog.blogPath.value, active: within(blog.blogPath.value) }] : []),
|
|
18
|
+
...(blog.hasChangelog ? [{ key: 'changelog', label: t('docs.changelog'), to: blog.changelogPath.value, active: within(blog.changelogPath.value) }] : []),
|
|
19
|
+
...(cfg.nav ?? []).map((l) => ({ key: l.id, label: text(`docs.nav.${l.id}`), to: href(l.href), active: within(href(l.href)) })),
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
// The phone menu. Where the docs navigation shows: on the docs pages only (not the blog or the start page).
|
|
23
|
+
const menu = ref(false)
|
|
24
|
+
const inDocs = computed(() => within(docs.base.value))
|
|
25
|
+
const button = useTemplateRef<HTMLButtonElement>('button')
|
|
26
|
+
const panel = useTemplateRef<HTMLElement>('panel')
|
|
27
|
+
|
|
28
|
+
function close(refocus = false) {
|
|
29
|
+
if (!menu.value) return
|
|
30
|
+
menu.value = false
|
|
31
|
+
if (refocus) button.value?.focus()
|
|
32
|
+
}
|
|
33
|
+
function onKey(e: KeyboardEvent) {
|
|
34
|
+
// An open language popover takes the first Escape.
|
|
35
|
+
if (e.key === 'Escape' && !panel.value?.querySelector('.docs-popover')) close(true)
|
|
36
|
+
}
|
|
37
|
+
const wide = import.meta.client ? matchMedia('(min-width: 64rem)') : undefined
|
|
38
|
+
const onWide = () => { if (wide?.matches) close() }
|
|
39
|
+
|
|
40
|
+
// While open: the page behind does not scroll, Escape closes, and focus moves in (and back to the button on close).
|
|
41
|
+
watch(menu, async (open) => {
|
|
42
|
+
document.documentElement.style.overflow = open ? 'hidden' : ''
|
|
43
|
+
if (open) {
|
|
44
|
+
document.addEventListener('keydown', onKey)
|
|
45
|
+
wide?.addEventListener('change', onWide)
|
|
46
|
+
await nextTick()
|
|
47
|
+
panel.value?.focus()
|
|
48
|
+
} else {
|
|
49
|
+
document.removeEventListener('keydown', onKey)
|
|
50
|
+
wide?.removeEventListener('change', onWide)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
watch(() => route.fullPath, () => close())
|
|
54
|
+
onBeforeUnmount(() => {
|
|
55
|
+
document.documentElement.style.overflow = ''
|
|
56
|
+
document.removeEventListener('keydown', onKey)
|
|
57
|
+
wide?.removeEventListener('change', onWide)
|
|
58
|
+
})
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<header class="docs-header">
|
|
63
|
+
<div class="docs-header-inner">
|
|
64
|
+
<NuxtLink :to="localePath('/')" class="docs-brand">
|
|
65
|
+
<template v-if="cfg.logo">
|
|
66
|
+
<img :class="{ 'docs-when-light': cfg.logo.dark }" :src="cfg.logo.light" :alt="cfg.logo.wordmark ? title : ''">
|
|
67
|
+
<img v-if="cfg.logo.dark" class="docs-when-dark" :src="cfg.logo.dark" alt="">
|
|
68
|
+
</template>
|
|
69
|
+
<span v-if="!cfg.logo?.wordmark">{{ title }}</span>
|
|
70
|
+
</NuxtLink>
|
|
71
|
+
<nav v-if="links.length > 1 || cfg.nav?.length" class="docs-nav-links" :aria-label="t('docs.siteLinks')">
|
|
72
|
+
<NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
|
|
73
|
+
</nav>
|
|
74
|
+
<div class="docs-header-end">
|
|
75
|
+
<DocsSearch v-if="cfg.search !== false" />
|
|
76
|
+
<DocsLanguage />
|
|
77
|
+
<DocsThemeToggle />
|
|
78
|
+
<template v-if="cfg.social?.length">
|
|
79
|
+
<span class="docs-header-sep" aria-hidden="true" />
|
|
80
|
+
<div class="docs-social">
|
|
81
|
+
<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">
|
|
82
|
+
<DocsIcon :name="s.icon" class="docs-icon" />
|
|
83
|
+
</a>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
<button
|
|
87
|
+
ref="button" type="button" class="docs-icon-button docs-header-menu" :aria-expanded="menu" aria-controls="docs-menu"
|
|
88
|
+
:aria-label="menu ? t('docs.close') : t('docs.siteMenu')" @click="menu = !menu"
|
|
89
|
+
>
|
|
90
|
+
<DocsIcon :name="menu ? 'lucide:x' : 'lucide:menu'" class="docs-icon" />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<!-- Teleported: the header's backdrop blur would otherwise make it the containing block of the fixed menu. -->
|
|
95
|
+
<Teleport to="body">
|
|
96
|
+
<div v-if="menu" id="docs-menu" ref="panel" class="docs-menu" role="dialog" tabindex="-1" :aria-label="t('docs.siteMenu')">
|
|
97
|
+
<DocsLanguage list />
|
|
98
|
+
<div v-if="inDocs" class="docs-menu-scroll">
|
|
99
|
+
<DocsSidebar />
|
|
100
|
+
</div>
|
|
101
|
+
<div class="docs-menu-more">
|
|
102
|
+
<nav class="docs-menu-links" :aria-label="t('docs.siteLinks')">
|
|
103
|
+
<NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
|
|
104
|
+
<a v-for="s in cfg.social ?? []" :key="s.href" :href="s.href" target="_blank" rel="noopener">{{ s.label }}</a>
|
|
105
|
+
</nav>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="docs-menu-foot">
|
|
108
|
+
<DocsThemeToggle segmented />
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</Teleport>
|
|
112
|
+
</header>
|
|
113
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One icon from the build-time map (modules/icons.ts). A bare name is a Lucide icon; an unknown name renders nothing.
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
import icons from '#docs-icons'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ name: string }>()
|
|
7
|
+
const icon = computed(() => icons[props.name.includes(':') ? props.name : `lucide:${props.name}`])
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<svg v-if="icon" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" :viewBox="icon[1]" aria-hidden="true" v-html="icon[0]" />
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
// A route link (`$docs.<page>`, `$page.<key>`) follows the reader's language; a path or URL stays as written.
|
|
17
|
+
const { href } = useDocsSite()
|
|
18
|
+
const to = (raw: string) => (raw.startsWith('$') ? href(raw) : raw)
|
|
19
|
+
const external = (href: string) => /^https?:\/\//.test(href)
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="docs-landing">
|
|
24
|
+
<div class="docs-landing-inner">
|
|
25
|
+
<section class="docs-hero">
|
|
26
|
+
<h1>{{ data.title }}</h1>
|
|
27
|
+
<p v-if="data.description">{{ data.description }}</p>
|
|
28
|
+
<div v-if="data.links.length" class="docs-hero-links">
|
|
29
|
+
<NuxtLink
|
|
30
|
+
v-for="(l, i) in data.links" :key="l.href" :to="to(l.href)" :target="external(l.href) ? '_blank' : undefined"
|
|
31
|
+
class="docs-button is-large" :class="i === 0 ? 'is-primary' : 'is-soft'"
|
|
32
|
+
>
|
|
33
|
+
{{ l.label }}<DocsIcon v-if="i === 0" name="lucide:arrow-right" class="docs-icon" />
|
|
34
|
+
</NuxtLink>
|
|
35
|
+
</div>
|
|
36
|
+
<div v-if="data.install" class="docs-install">
|
|
37
|
+
<code tabindex="0" role="region" :aria-label="t('docs.installCommand')">{{ data.install }}</code>
|
|
38
|
+
<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 ?? '')">
|
|
39
|
+
<DocsIcon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
<ul v-if="data.features.length" class="docs-features">
|
|
44
|
+
<li v-for="f in data.features" :key="f.title" class="docs-feature">
|
|
45
|
+
<div v-if="f.icon" class="docs-feature-icon">
|
|
46
|
+
<DocsIcon :name="f.icon" class="docs-icon" />
|
|
47
|
+
</div>
|
|
48
|
+
<h2>
|
|
49
|
+
<NuxtLink v-if="f.href" :to="to(f.href)">{{ f.title }}</NuxtLink>
|
|
50
|
+
<template v-else>{{ f.title }}</template>
|
|
51
|
+
</h2>
|
|
52
|
+
<p>{{ f.description }}</p>
|
|
53
|
+
</li>
|
|
54
|
+
</ul>
|
|
55
|
+
<GinkoMarkdown v-if="data.body" :value="data.body" :components="markdownComponents" class="docs-prose docs-landing-body" />
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The languages of this page (ginko-i18n alternates, so a doc links to its own translation), each named in itself
|
|
3
|
+
// ("Deutsch"). In the header a menu; with `list` (the phone menu) a row of links, one tap each. A language without
|
|
4
|
+
// this page says so, in the page's language (`lang` sits on the name only), because its link leads to that language's start page.
|
|
5
|
+
const props = defineProps<{ list?: boolean }>()
|
|
6
|
+
const { t, alternates } = useI18n()
|
|
7
|
+
const { open, toggle } = useDocsPopover()
|
|
8
|
+
const current = computed(() => alternates.value.find((a) => a.current))
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<template v-if="alternates.length > 1">
|
|
13
|
+
<nav v-if="props.list" class="docs-lang-list" :aria-label="t('docs.language')">
|
|
14
|
+
<NuxtLink v-for="a in alternates" :key="a.code" :to="a.path" :hreflang="a.language" :aria-current="a.current ? 'page' : undefined">
|
|
15
|
+
<span :lang="a.language">{{ a.name }}</span>
|
|
16
|
+
<small v-if="!a.translated">{{ t('docs.untranslated') }}</small>
|
|
17
|
+
</NuxtLink>
|
|
18
|
+
</nav>
|
|
19
|
+
<div v-else ref="popover" class="docs-lang">
|
|
20
|
+
<button
|
|
21
|
+
type="button" class="docs-icon-button" :aria-expanded="open" aria-controls="docs-lang-menu"
|
|
22
|
+
:aria-label="`${t('docs.language')}: ${current?.name ?? ''}`" @click="toggle"
|
|
23
|
+
>
|
|
24
|
+
<DocsIcon name="lucide:languages" class="docs-icon" />
|
|
25
|
+
<span aria-hidden="true">{{ current?.code.toUpperCase() }}</span>
|
|
26
|
+
</button>
|
|
27
|
+
<ul v-if="open" id="docs-lang-menu" class="docs-popover">
|
|
28
|
+
<li v-for="a in alternates" :key="a.code">
|
|
29
|
+
<NuxtLink :to="a.path" :hreflang="a.language" :aria-current="a.current ? 'page' : undefined">
|
|
30
|
+
<span>
|
|
31
|
+
<span :lang="a.language">{{ a.name }}</span>
|
|
32
|
+
<small v-if="!a.translated">{{ t('docs.untranslated') }}</small>
|
|
33
|
+
</span>
|
|
34
|
+
<DocsIcon v-if="a.current" name="lucide:check" class="docs-icon" />
|
|
35
|
+
</NuxtLink>
|
|
36
|
+
</li>
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The docs navigation on phones when a site's layout holds the docs (in the layer's own frame the header menu has it):
|
|
3
|
+
// a sheet from the start edge. A native modal dialog: focus stays inside, Escape and a tap beside it close it, and the
|
|
4
|
+
// page behind cannot be reached. It closes on navigation and when the window grows to the sidebar width.
|
|
5
|
+
const open = defineModel<boolean>('open', { required: true })
|
|
6
|
+
const { t } = useI18n()
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
|
|
9
|
+
const wide = import.meta.client ? matchMedia('(min-width: 64rem)') : undefined
|
|
10
|
+
const onWide = () => { if (wide?.matches) open.value = false }
|
|
11
|
+
|
|
12
|
+
watch(open, (value) => {
|
|
13
|
+
if (value) {
|
|
14
|
+
dialog.value?.showModal()
|
|
15
|
+
wide?.addEventListener('change', onWide)
|
|
16
|
+
} else {
|
|
17
|
+
dialog.value?.close()
|
|
18
|
+
wide?.removeEventListener('change', onWide)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
watch(() => route.fullPath, () => { open.value = false })
|
|
22
|
+
onBeforeUnmount(() => wide?.removeEventListener('change', onWide))
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<dialog ref="dialog" class="docs-nav-sheet" :aria-label="t('docs.nav')" @close="open = false" @click.self="open = false">
|
|
27
|
+
<div class="docs-nav-sheet-head">
|
|
28
|
+
<p>{{ t('docs.contents') }}</p>
|
|
29
|
+
<button type="button" class="docs-icon-button" :aria-label="t('docs.close')" @click="open = false">
|
|
30
|
+
<DocsIcon name="lucide:x" class="docs-icon" />
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="docs-nav-sheet-body">
|
|
34
|
+
<DocsSidebar />
|
|
35
|
+
</div>
|
|
36
|
+
</dialog>
|
|
37
|
+
</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
|
+
<DocsIcon 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
|
+
<DocsIcon 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,88 @@
|
|
|
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 { breadcrumbJsonLd, type ItemOf } from '@lupinum/ginko'
|
|
7
|
+
import { articleLd } 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 langOf = useGinkoLang()
|
|
15
|
+
const neighbors = useGinkoNeighbors('docs', () => props.doc.id)
|
|
16
|
+
const site = useGinkoSite()
|
|
17
|
+
// The Studio link needs the platform address and the site name: internal keys of the Ginko module, read by its own layer.
|
|
18
|
+
const { platform, site: siteName } = useRuntimeConfig().public.ginko as { site: string, platform: string }
|
|
19
|
+
const route = useRoute()
|
|
20
|
+
useGinkoSeo(() => ({
|
|
21
|
+
title: props.doc.data.title,
|
|
22
|
+
description: props.doc.data.description,
|
|
23
|
+
section: props.doc.data.section,
|
|
24
|
+
type: 'article',
|
|
25
|
+
jsonLd: [
|
|
26
|
+
// articleLd fixes the type to Article; docs are TechArticle.
|
|
27
|
+
{ ...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' },
|
|
28
|
+
...(crumbs.value.length > 1 ? [breadcrumbJsonLd(crumbs.value, site.url)] : []),
|
|
29
|
+
],
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const repository = useAppConfig().docs?.repository
|
|
33
|
+
// A page from a file is edited in the repository; one stored on the platform in the Studio.
|
|
34
|
+
const editUrl = computed(() => (props.doc.source ? docsEditUrl(repository, props.doc.source) : docsStudioUrl(platform, siteName, 'docs', props.doc.id)))
|
|
35
|
+
const issueUrl = computed(() => docsIssueUrl(repository, props.doc.data.title, new URL(route.path, site.url).href))
|
|
36
|
+
const updated = computed(() => props.doc.updatedAt && {
|
|
37
|
+
iso: props.doc.updatedAt,
|
|
38
|
+
text: format.date(new Date(props.doc.updatedAt), 'long'),
|
|
39
|
+
})
|
|
40
|
+
// The toc comes with the text (page state holds only its ref); the same request as <GinkoMarkdown> below.
|
|
41
|
+
const tree = await useGinkoMarkdown(() => props.doc.data.body)
|
|
42
|
+
const toc = computed(() => tree.value?.toc?.links ?? [])
|
|
43
|
+
const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div class="docs-page">
|
|
48
|
+
<article class="docs-article">
|
|
49
|
+
<nav v-if="crumbs.length > 1" class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
|
|
50
|
+
<ol>
|
|
51
|
+
<li v-for="(c, i) in crumbs" :key="c.path + i">
|
|
52
|
+
<DocsIcon v-if="i" name="lucide:chevron-right" class="docs-icon" />
|
|
53
|
+
<span v-if="i === crumbs.length - 1" aria-current="page">{{ c.title }}</span>
|
|
54
|
+
<NuxtLink v-else :to="c.path">{{ c.title }}</NuxtLink>
|
|
55
|
+
</li>
|
|
56
|
+
</ol>
|
|
57
|
+
</nav>
|
|
58
|
+
<header class="docs-page-head">
|
|
59
|
+
<h1>{{ doc.data.title }}</h1>
|
|
60
|
+
<p v-if="doc.data.description" class="docs-lead" :lang="langOf(doc, 'description')">{{ doc.data.description }}</p>
|
|
61
|
+
<DocsPageActions :path="route.path" :origin="site.url" />
|
|
62
|
+
</header>
|
|
63
|
+
<GinkoMarkdown :value="doc.data.body" :components="markdownComponents" class="docs-prose" />
|
|
64
|
+
<DocsPageEnd :doc="doc" />
|
|
65
|
+
<footer>
|
|
66
|
+
<div v-if="editUrl || updated" class="docs-page-foot">
|
|
67
|
+
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><DocsIcon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
|
|
68
|
+
<span v-if="updated">{{ t('docs.updated') }} <time :datetime="updated.iso">{{ updated.text }}</time></span>
|
|
69
|
+
</div>
|
|
70
|
+
<nav v-if="neighbors.prev || neighbors.next" class="docs-pager" :aria-label="t('docs.pager')">
|
|
71
|
+
<NuxtLink v-if="neighbors.prev" :to="neighbors.prev.url" rel="prev">
|
|
72
|
+
<small><DocsIcon name="lucide:arrow-left" class="docs-icon" />{{ t('docs.previous') }}</small>{{ neighbors.prev.data.title }}
|
|
73
|
+
</NuxtLink>
|
|
74
|
+
<NuxtLink v-if="neighbors.next" :to="neighbors.next.url" rel="next" class="is-next">
|
|
75
|
+
<small>{{ t('docs.next') }}<DocsIcon name="lucide:arrow-right" class="docs-icon" /></small>{{ neighbors.next.data.title }}
|
|
76
|
+
</NuxtLink>
|
|
77
|
+
</nav>
|
|
78
|
+
</footer>
|
|
79
|
+
</article>
|
|
80
|
+
<DocsToc v-if="toc.length" :links="toc" variant="aside">
|
|
81
|
+
<div class="docs-toc-more">
|
|
82
|
+
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><DocsIcon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
|
|
83
|
+
<a v-if="issueUrl" :href="issueUrl" target="_blank" rel="noopener"><DocsIcon name="lucide:circle-alert" class="docs-icon" />{{ t('docs.issue') }}</a>
|
|
84
|
+
<button type="button" @click="top"><DocsIcon name="lucide:arrow-up" class="docs-icon" />{{ t('docs.top') }}</button>
|
|
85
|
+
</div>
|
|
86
|
+
</DocsToc>
|
|
87
|
+
</div>
|
|
88
|
+
</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
|
+
<DocsIcon :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
|
+
<DocsIcon 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
|
+
<DocsIcon 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
|
+
<DocsIcon :name="assistants[id].icon" class="docs-icon" />
|
|
41
|
+
<span>{{ t(assistants[id].label) }}<small>{{ t('docs.askHint') }}</small></span>
|
|
42
|
+
<DocsIcon name="lucide:arrow-up-right" class="docs-icon" />
|
|
43
|
+
</a>
|
|
44
|
+
</li>
|
|
45
|
+
</ul>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PropType } from 'vue'
|
|
2
|
+
import type { ItemOf } from '@lupinum/ginko'
|
|
3
|
+
import type { docsCollection } from '@lupinum/ginko-docs/config'
|
|
4
|
+
|
|
5
|
+
// Override point: after the text of every docs page, before "Edit this page" and the pager. Renders nothing here; a
|
|
6
|
+
// site puts its own `components/DocsPageEnd.vue` (same name, prop `doc`) in its app to show a support box or a call
|
|
7
|
+
// to action. A render function, not an empty <template>: server and browser then agree on the empty placeholder.
|
|
8
|
+
export default defineComponent({
|
|
9
|
+
name: 'DocsPageEnd',
|
|
10
|
+
props: { doc: { type: Object as PropType<ItemOf<ReturnType<typeof docsCollection>['fields']>>, required: true } },
|
|
11
|
+
render: () => null,
|
|
12
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
3
|
+
// One blog post: title, date, authors, text. BlogPosting JSON-LD names the authors and carries both dates.
|
|
4
|
+
import { postJsonLd } from '@lupinum/ginko/basics'
|
|
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 langOf = useGinkoLang()
|
|
11
|
+
const site = useGinkoSite()
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const authors = computed(() => blog.authorsOf(props.post))
|
|
14
|
+
const absolute = (path: string) => new URL(path, site.url).href
|
|
15
|
+
|
|
16
|
+
useGinkoSeo(() => {
|
|
17
|
+
const { title, description, date, image, tags } = props.post.data
|
|
18
|
+
return {
|
|
19
|
+
title, description, type: 'article', image: image?.url,
|
|
20
|
+
jsonLd: [postJsonLd({
|
|
21
|
+
title, description, lang: language.value, url: absolute(route.path), image: image ? absolute(image.url) : undefined,
|
|
22
|
+
date, modified: (props.post.updatedAt ?? date).slice(0, 10),
|
|
23
|
+
authors: authors.value.map((a) => a.data), tags: tags?.map((item) => item.tag),
|
|
24
|
+
})],
|
|
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><DocsIcon 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" :lang="langOf(post, 'description')">{{ 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>
|