@lupinum/ginko-docs 0.4.0-rc.9 → 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,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The docs body: the navigation on the side and the page next to it. On phones the navigation lives in the
|
|
3
|
+
// header's menu (DocsHeader); the bar above the page holds the page's own controls (slot `bar`, the table of
|
|
4
|
+
// contents). It needs no header of its own, so it works in the standalone frame and inside a site's layout alike.
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div class="docs-shell">
|
|
10
|
+
<nav class="docs-sidebar" :aria-label="t('docs.nav')">
|
|
11
|
+
<DocsSidebar />
|
|
12
|
+
</nav>
|
|
13
|
+
<div id="docs-content" class="docs-content" tabindex="-1">
|
|
14
|
+
<div class="docs-bar">
|
|
15
|
+
<slot name="bar" />
|
|
16
|
+
</div>
|
|
17
|
+
<slot />
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The side navigation: the section tabs (when the docs have several), then the pages of the active tab in their
|
|
3
|
+
// sections. Used in the sidebar and in the phone menu.
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const { tabs, activeTab, sections } = useDocs()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div>
|
|
10
|
+
<nav v-if="tabs.length" class="docs-tabs" :aria-label="t('docs.sections')">
|
|
11
|
+
<NuxtLink v-for="tab in tabs" :key="tab.title" :to="tab.path" :aria-current="tab.title === activeTab ? 'true' : undefined">{{ tab.title }}</NuxtLink>
|
|
12
|
+
</nav>
|
|
13
|
+
<section v-for="(s, i) in sections" :key="i" class="docs-nav-section">
|
|
14
|
+
<h2 v-if="s.title" class="docs-nav-heading">{{ s.title }}</h2>
|
|
15
|
+
<DocsNavTree :nodes="s.nodes" />
|
|
16
|
+
</section>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Markdown tables scroll sideways on a phone. The wrapper is the scroll box: keyboard users can focus it and scroll with the arrow keys.
|
|
3
|
+
// role=group, not region: a page can hold several tables, and landmarks with the same name are an error.
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="docs-table" role="group" tabindex="0" :aria-label="t('docs.table')">
|
|
9
|
+
<table><slot /></table>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Auto, light or dark. Auto follows the system and is the default; a choice lives in localStorage and
|
|
3
|
+
// <html data-theme>, and the head script applies it before paint. Both icons are in the HTML and CSS shows the
|
|
4
|
+
// right one, so the server render never guesses the reader's scheme.
|
|
5
|
+
import { COLOR_KEY, colorInitScript } from '../lib/docsInit'
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
useHead({ script: [{ key: 'docs-color-init', innerHTML: colorInitScript(), tagPriority: 'critical' }] })
|
|
9
|
+
|
|
10
|
+
// `segmented` is the phone menu's Auto/Light/Dark switch: the same states and the same storage.
|
|
11
|
+
const props = defineProps<{ segmented?: boolean }>()
|
|
12
|
+
type Choice = 'auto' | 'light' | 'dark'
|
|
13
|
+
const system = (): 'light' | 'dark' => (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
14
|
+
const current = (): Choice => (document.documentElement.getAttribute('data-theme') as Choice | null) ?? 'auto'
|
|
15
|
+
// Only read when the switch is on screen (the phone menu renders on the client), so SSR never guesses.
|
|
16
|
+
const choice = ref<Choice>(props.segmented && import.meta.client ? current() : 'auto')
|
|
17
|
+
|
|
18
|
+
function choose(next: Choice) {
|
|
19
|
+
const html = document.documentElement
|
|
20
|
+
if (next === 'auto') html.removeAttribute('data-theme')
|
|
21
|
+
else html.setAttribute('data-theme', next)
|
|
22
|
+
choice.value = next
|
|
23
|
+
try {
|
|
24
|
+
if (next === 'auto') localStorage.removeItem(COLOR_KEY)
|
|
25
|
+
else localStorage.setItem(COLOR_KEY, next)
|
|
26
|
+
} catch { /* storage blocked: the choice lasts until reload */ }
|
|
27
|
+
}
|
|
28
|
+
// The header button flips what the reader sees; flipping back to the system's scheme returns to Auto.
|
|
29
|
+
function toggle() {
|
|
30
|
+
const shown = current() === 'auto' ? system() : current()
|
|
31
|
+
const next = shown === 'dark' ? 'light' : 'dark'
|
|
32
|
+
choose(next === system() ? 'auto' : next)
|
|
33
|
+
}
|
|
34
|
+
const OPTIONS = ['auto', 'light', 'dark'] as const
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div v-if="props.segmented" class="docs-segmented" role="group" :aria-label="t('docs.theme.label')">
|
|
39
|
+
<button v-for="o in OPTIONS" :key="o" type="button" :aria-pressed="choice === o" @click="choose(o)">{{ t(`docs.theme.${o}`) }}</button>
|
|
40
|
+
</div>
|
|
41
|
+
<button v-else type="button" class="docs-icon-button" :aria-label="t('docs.theme.toggle')" :title="t('docs.theme.toggle')" @click="toggle">
|
|
42
|
+
<Icon name="lucide:moon" class="docs-icon docs-when-light" />
|
|
43
|
+
<Icon name="lucide:sun" class="docs-icon docs-when-dark" />
|
|
44
|
+
</button>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The table of contents of a page: a rail beside the text on wide screens (`aside`), a menu in the docs bar
|
|
3
|
+
// on narrower ones (`menu`). The current section is the last heading that has passed the top of the screen.
|
|
4
|
+
interface TocLink { id: string, text: string, children?: TocLink[] }
|
|
5
|
+
const props = defineProps<{ links: TocLink[], variant: 'aside' | 'menu' }>()
|
|
6
|
+
const { t } = useI18n()
|
|
7
|
+
const { open, toggle } = useDocsPopover()
|
|
8
|
+
|
|
9
|
+
const flat = computed(() => props.links.flatMap((l) => [{ ...l, sub: false }, ...(l.children ?? []).map((c) => ({ ...c, sub: true }))]))
|
|
10
|
+
const current = ref<string>()
|
|
11
|
+
|
|
12
|
+
// A scroll listener instead of an IntersectionObserver: the text may render after this component on client
|
|
13
|
+
// navigation, and looking the headings up on each (throttled) scroll needs no bookkeeping for that.
|
|
14
|
+
let frame = 0
|
|
15
|
+
function spy() {
|
|
16
|
+
cancelAnimationFrame(frame)
|
|
17
|
+
frame = requestAnimationFrame(() => {
|
|
18
|
+
const top = Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 96
|
|
19
|
+
let id: string | undefined
|
|
20
|
+
for (const l of flat.value) {
|
|
21
|
+
const el = document.getElementById(l.id)
|
|
22
|
+
if (el && el.getBoundingClientRect().top <= top + 8) id = l.id
|
|
23
|
+
}
|
|
24
|
+
current.value = id ?? flat.value[0]?.id
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
onMounted(() => {
|
|
28
|
+
spy()
|
|
29
|
+
window.addEventListener('scroll', spy, { passive: true })
|
|
30
|
+
})
|
|
31
|
+
onBeforeUnmount(() => {
|
|
32
|
+
cancelAnimationFrame(frame)
|
|
33
|
+
window.removeEventListener('scroll', spy)
|
|
34
|
+
})
|
|
35
|
+
watch(() => props.links, () => nextTick(spy))
|
|
36
|
+
// A long table of contents scrolls in its own box: keep the current entry in view (the box only, not the page).
|
|
37
|
+
const aside = ref<HTMLElement | null>(null)
|
|
38
|
+
watch(current, (id) => {
|
|
39
|
+
const box = aside.value
|
|
40
|
+
const link = id ? box?.querySelector<HTMLElement>(`a[href="#${CSS.escape(id)}"]`) : undefined
|
|
41
|
+
if (!box || !link || box.scrollHeight <= box.clientHeight) return
|
|
42
|
+
const top = link.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop
|
|
43
|
+
if (top < box.scrollTop + 32 || top > box.scrollTop + box.clientHeight - 48) box.scrollTop = top - box.clientHeight / 3
|
|
44
|
+
})
|
|
45
|
+
const title = computed(() => flat.value.find((l) => l.id === current.value)?.text)
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<nav v-if="props.variant === 'aside'" ref="aside" class="docs-toc" :aria-label="t('docs.onThisPage')">
|
|
50
|
+
<p class="docs-toc-title">
|
|
51
|
+
<Icon name="lucide:text-align-start" class="docs-icon" />{{ t('docs.onThisPage') }}
|
|
52
|
+
</p>
|
|
53
|
+
<ul class="docs-toc-list">
|
|
54
|
+
<li v-for="l in flat" :key="l.id">
|
|
55
|
+
<a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined">{{ l.text }}</a>
|
|
56
|
+
</li>
|
|
57
|
+
</ul>
|
|
58
|
+
<slot />
|
|
59
|
+
</nav>
|
|
60
|
+
<div v-else ref="popover" class="docs-toc-menu">
|
|
61
|
+
<button type="button" :aria-expanded="open" aria-controls="docs-toc-menu" @click="toggle">
|
|
62
|
+
<span>{{ title && flat.length > 1 ? title : t('docs.onThisPage') }}</span>
|
|
63
|
+
<Icon name="lucide:chevron-down" class="docs-icon" />
|
|
64
|
+
</button>
|
|
65
|
+
<nav v-if="open" id="docs-toc-menu" class="docs-popover" :aria-label="t('docs.onThisPage')">
|
|
66
|
+
<ul class="docs-toc-list">
|
|
67
|
+
<li v-for="l in flat" :key="l.id">
|
|
68
|
+
<a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined" @click="toggle">{{ l.text }}</a>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
</nav>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What is docs-specific on top of Ginko's navigation: the tree comes from `useGinkoNav`, the
|
|
2
|
+
// breadcrumb chain from `useGinkoBreadcrumbs`; this adds tabs and sections from the entries' fields.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import { enrichNav, type DocNode, type DocTab } from '../lib/docsNav'
|
|
5
|
+
|
|
6
|
+
export function useDocs() {
|
|
7
|
+
const items = useGinko('docs')
|
|
8
|
+
const tree = useGinkoNav(['docs'])
|
|
9
|
+
const chain = useGinkoBreadcrumbs(['docs'])
|
|
10
|
+
|
|
11
|
+
// Ginko's nodes carry title and path; badge, icon, section and tab live on the entry (rules: enrichNav).
|
|
12
|
+
const nodes = computed<DocNode[]>(() => {
|
|
13
|
+
const byId = new Map(items.value.items.map((d) => [d.id, d.data]))
|
|
14
|
+
return enrichNav(tree.value, (id) => byId.get(id))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const tabs = computed<DocTab[]>(() => {
|
|
18
|
+
const out = new Map<string, DocTab>()
|
|
19
|
+
for (const n of nodes.value) if (n.tab && !out.has(n.tab)) out.set(n.tab, { title: n.tab, path: n.path })
|
|
20
|
+
return out.size > 1 ? [...out.values()] : []
|
|
21
|
+
})
|
|
22
|
+
const activeTab = computed(() => (tabs.value.length ? nodes.value.find((n) => n.path === chain.value[0]?.path)?.tab ?? tabs.value[0]!.title : undefined))
|
|
23
|
+
|
|
24
|
+
// Roots of the active tab, grouped by `section` (a section is a run of neighbours with the same one).
|
|
25
|
+
const sections = computed(() => {
|
|
26
|
+
const out: { title?: string; nodes: DocNode[] }[] = []
|
|
27
|
+
for (const node of nodes.value) {
|
|
28
|
+
if (tabs.value.length && (node.tab ?? tabs.value[0]!.title) !== activeTab.value) continue
|
|
29
|
+
const last = out.at(-1)
|
|
30
|
+
if (last && last.title === node.section) last.nodes.push(node)
|
|
31
|
+
else out.push({ title: node.section, nodes: [node] })
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Tab (when tabs are shown), then the parent chain down to this page.
|
|
37
|
+
const crumbs = computed<{ title: string; path: string }[]>(() => {
|
|
38
|
+
const tab = tabs.value.find((t) => t.title === activeTab.value)
|
|
39
|
+
return [...(tab ? [tab] : []), ...chain.value.map(({ title, path }) => ({ title, path }))]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** The first page of the docs in this language: where `/docs` (and `/` without a start page) lead. */
|
|
43
|
+
const first = computed(() => nodes.value[0]?.path)
|
|
44
|
+
|
|
45
|
+
/** Where the docs live in this language (docsCollection's `base`, `/docs` by default), e.g. `/de/docs`. */
|
|
46
|
+
const { localePath } = useI18n()
|
|
47
|
+
const { mainLocale } = useGinkoSite()
|
|
48
|
+
const route = (schema.bricks.docs as { route?: Record<string, string> } | undefined)?.route?.[mainLocale]
|
|
49
|
+
const base = computed(() => localePath(route?.replace(/\/\[slug\]$/, '') ?? '/docs'))
|
|
50
|
+
|
|
51
|
+
return { nodes, tabs, activeTab, sections, crumbs, first, base }
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The optional blog and changelog: whether the site has them, where they live in this language, and typed
|
|
2
|
+
// reads. The layer cannot know the site's bricks at compile time, so the reads go through one cast here.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import { BLOG_PATH, CHANGELOG_PATH } from '../../shared/paths'
|
|
5
|
+
import type { Author, ChangelogEntry, Post } from '../lib/blogTypes'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const has = (brick: string) => Object.hasOwn(schema.bricks, brick)
|
|
9
|
+
const read = <T>(brick: string) => (useGinko as unknown as (b: string) => ComputedRef<{ items: T[] }>)(brick)
|
|
10
|
+
|
|
11
|
+
export function useDocsBlog() {
|
|
12
|
+
const { localePath } = useI18n()
|
|
13
|
+
const hasPosts = has('posts')
|
|
14
|
+
const hasChangelog = has('changelog')
|
|
15
|
+
|
|
16
|
+
const authors = hasPosts && has('authors') ? read<Author>('authors') : undefined
|
|
17
|
+
const authorsOf = (post: Post) => (post.data.authors ?? []).flatMap((id) => authors?.value.items.find((a) => a.id === id) ?? [])
|
|
18
|
+
const { format } = useI18n()
|
|
19
|
+
const formatDate = (iso: string) => format.date(iso, 'long')
|
|
20
|
+
|
|
21
|
+
// `draft` is a reserved content key, no schema field; it reaches the bundle only in dev, where drafts show.
|
|
22
|
+
const isDraft = (post: Post) => 'draft' in post.data && post.data.draft === true
|
|
23
|
+
return {
|
|
24
|
+
hasPosts, hasChangelog, authorsOf, isDraft, formatDate,
|
|
25
|
+
blogPath: computed(() => localePath(BLOG_PATH)),
|
|
26
|
+
feedPath: computed(() => localePath(`${BLOG_PATH}/rss.xml`)),
|
|
27
|
+
changelogPath: computed(() => localePath(CHANGELOG_PATH)),
|
|
28
|
+
posts: () => read<Post>('posts'),
|
|
29
|
+
changelog: () => read<ChangelogEntry>('changelog'),
|
|
30
|
+
post: (slug: string) => (useGinkoEntry as unknown as (b: string, s: string) => ComputedRef<Post>)('posts', slug),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copies text and shows the result for two seconds: `copied`, or `failed` when the clipboard is blocked.
|
|
2
|
+
export function useDocsCopy() {
|
|
3
|
+
const state = ref<'idle' | 'copied' | 'failed'>('idle')
|
|
4
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
5
|
+
async function copy(text: string | (() => Promise<string>)) {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(typeof text === 'string' ? text : await text())
|
|
8
|
+
state.value = 'copied'
|
|
9
|
+
} catch {
|
|
10
|
+
state.value = 'failed'
|
|
11
|
+
}
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
timer = setTimeout(() => { state.value = 'idle' }, 2000)
|
|
14
|
+
}
|
|
15
|
+
onBeforeUnmount(() => clearTimeout(timer))
|
|
16
|
+
return { state, copy }
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A small disclosure popover (language menu, page actions, phone table of contents): a button toggles a panel;
|
|
2
|
+
// a click outside, Escape or a page change closes it, and Escape returns focus to the button.
|
|
3
|
+
// The component puts `ref="popover"` on the element that holds both the button and the panel, and binds `toggle`
|
|
4
|
+
// to the button's click (Escape returns focus to that button).
|
|
5
|
+
export function useDocsPopover() {
|
|
6
|
+
const open = ref(false)
|
|
7
|
+
const root = useTemplateRef<HTMLElement>('popover')
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
let trigger: HTMLElement | undefined
|
|
10
|
+
|
|
11
|
+
function close(refocus = false) {
|
|
12
|
+
if (!open.value) return
|
|
13
|
+
open.value = false
|
|
14
|
+
if (refocus) (trigger ?? root.value?.querySelector<HTMLElement>('[aria-expanded]'))?.focus()
|
|
15
|
+
}
|
|
16
|
+
const onClick = (e: MouseEvent) => { if (!root.value?.contains(e.target as Node)) close() }
|
|
17
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(true) }
|
|
18
|
+
function listen(on: boolean) {
|
|
19
|
+
if (on) {
|
|
20
|
+
document.addEventListener('click', onClick, true)
|
|
21
|
+
document.addEventListener('keydown', onKey)
|
|
22
|
+
} else {
|
|
23
|
+
document.removeEventListener('click', onClick, true)
|
|
24
|
+
document.removeEventListener('keydown', onKey)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
watch(open, (value) => listen(value))
|
|
29
|
+
watch(() => route.fullPath, () => close())
|
|
30
|
+
onBeforeUnmount(() => listen(false))
|
|
31
|
+
function toggle(event?: Event) {
|
|
32
|
+
if (event?.currentTarget instanceof HTMLElement) trigger = event.currentTarget
|
|
33
|
+
open.value = !open.value
|
|
34
|
+
}
|
|
35
|
+
return { open, toggle, close }
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The site's own entries in app.config (nav, footer and banner links): their texts and their addresses.
|
|
2
|
+
export function useDocsSite() {
|
|
3
|
+
const { t, localePath } = useI18n()
|
|
4
|
+
return {
|
|
5
|
+
/** A message key the site defines (`docs.nav.<id>`, `docs.footer.<id>`, `docs.banner.<id>`); the layer's types do not know them. */
|
|
6
|
+
text: t as (key: string) => string,
|
|
7
|
+
/** An internal path in the reader's language; an absolute URL, a mail link or an anchor stays as written. */
|
|
8
|
+
href: (raw: string) => (raw.startsWith('/') && !raw.startsWith('//') ? localePath(raw) : raw),
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The blog: all posts, newest first (the collection's order).
|
|
3
|
+
const { t } = useI18n()
|
|
4
|
+
const blog = useDocsBlog()
|
|
5
|
+
const posts = blog.posts()
|
|
6
|
+
useGinkoSeo(() => ({ title: t('docs.blog'), description: t('docs.blogLead') }))
|
|
7
|
+
useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
|
|
8
|
+
// Nitro's crawler only follows links to pages (no `.xml`), but it also reads this header, so `nuxt generate` writes the feed.
|
|
9
|
+
if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feedPath.value
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div class="docs-blog">
|
|
14
|
+
<div class="docs-blog-panel">
|
|
15
|
+
<header class="docs-blog-head">
|
|
16
|
+
<div>
|
|
17
|
+
<h1>{{ t('docs.blog') }}</h1>
|
|
18
|
+
<p class="docs-lead">{{ t('docs.blogLead') }}</p>
|
|
19
|
+
</div>
|
|
20
|
+
<a :href="blog.feedPath.value" type="application/rss+xml"><Icon name="lucide:rss" class="docs-icon" />{{ t('docs.rss') }}</a>
|
|
21
|
+
</header>
|
|
22
|
+
<ul class="docs-posts">
|
|
23
|
+
<li v-for="post in posts.items" :key="post.id">
|
|
24
|
+
<div class="docs-post-side">
|
|
25
|
+
<p class="docs-meta"><time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time></p>
|
|
26
|
+
<p v-if="blog.authorsOf(post).length" class="docs-authors">
|
|
27
|
+
<template v-for="a in blog.authorsOf(post)" :key="a.id">
|
|
28
|
+
<GinkoImage v-if="a.data.avatar" :image="a.data.avatar" class="docs-avatar" />
|
|
29
|
+
</template>
|
|
30
|
+
{{ blog.authorsOf(post).map((a) => a.data.name).join(', ') }}
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="docs-post-main">
|
|
34
|
+
<h2>
|
|
35
|
+
<NuxtLink :to="post.url">{{ post.data.title }}</NuxtLink>
|
|
36
|
+
<span v-if="blog.isDraft(post)" class="docs-tag docs-tag-draft">{{ t('docs.draft') }}</span>
|
|
37
|
+
</h2>
|
|
38
|
+
<p v-if="post.data.description" class="docs-post-teaser">{{ post.data.description }}</p>
|
|
39
|
+
<p v-if="post.data.tags?.length" class="docs-tags">
|
|
40
|
+
<span v-for="tag in post.data.tags" :key="tag.tag" class="docs-tag">{{ tag.tag }}</span>
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
</li>
|
|
44
|
+
</ul>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { markdownComponents } from '../lib/markdownComponents'
|
|
3
|
+
// The changelog: one page, one section per release, newest first (the collection's order). Each version has an anchor.
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const blog = useDocsBlog()
|
|
6
|
+
const entries = blog.changelog()
|
|
7
|
+
useGinkoSeo(() => ({ title: t('docs.changelog') }))
|
|
8
|
+
// `1.2.0` -> `v1-2-0`, a valid id and a stable address for the release.
|
|
9
|
+
const anchor = (version: string) => `v${version.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div class="docs-blog">
|
|
14
|
+
<div class="docs-blog-panel">
|
|
15
|
+
<header class="docs-blog-head">
|
|
16
|
+
<h1>{{ t('docs.changelog') }}</h1>
|
|
17
|
+
</header>
|
|
18
|
+
<section v-for="entry in entries.items" :id="anchor(entry.data.version)" :key="entry.id" class="docs-release">
|
|
19
|
+
<div class="docs-release-meta">
|
|
20
|
+
<h2><a :href="`#${anchor(entry.data.version)}`">{{ entry.data.version }}</a></h2>
|
|
21
|
+
<p class="docs-meta"><time :datetime="entry.data.date">{{ blog.formatDate(entry.data.date) }}</time></p>
|
|
22
|
+
</div>
|
|
23
|
+
<GinkoMarkdown :value="entry.data.body" :components="markdownComponents" class="docs-prose" />
|
|
24
|
+
</section>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const doc = useGinkoEntry('docs', () => route.params.slug)
|
|
4
|
+
const toc = computed(() => doc.value.data.body.toc?.links ?? [])
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<DocsShell>
|
|
9
|
+
<template v-if="toc.length" #bar>
|
|
10
|
+
<DocsToc :links="toc" variant="menu" />
|
|
11
|
+
</template>
|
|
12
|
+
<DocsPage :doc="doc" />
|
|
13
|
+
</DocsShell>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { toFirstDoc } from '../lib/docsFirst'
|
|
3
|
+
// The start page: the site's `home` brick (docsHome) when it has one, otherwise the first docs page.
|
|
4
|
+
import schema from '#ginko/schema'
|
|
5
|
+
|
|
6
|
+
const hasHome = Object.hasOwn(schema.bricks, 'home')
|
|
7
|
+
if (!hasHome) await toFirstDoc()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<DocsLanding v-if="hasHome" />
|
|
12
|
+
</template>
|
package/app/error.vue
CHANGED
|
@@ -1,121 +1,29 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
statusMessage?: string;
|
|
14
|
-
statusText?: string;
|
|
15
|
-
url?: string;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const props = defineProps<{
|
|
19
|
-
error: NuxtErrorLike;
|
|
20
|
-
}>();
|
|
21
|
-
|
|
22
|
-
const normalized = computed(() => normalizeAppError(props.error));
|
|
23
|
-
const requestPath = computed(
|
|
24
|
-
() => props.error?.url || (import.meta.client ? window.location.pathname : "/"),
|
|
25
|
-
);
|
|
26
|
-
const locale = computed(() => localeFromPath(requestPath.value));
|
|
27
|
-
const copy = computed(() => {
|
|
28
|
-
const catalog = messages[locale.value].errors;
|
|
29
|
-
return catalog[normalized.value.kind] ?? catalog.unavailable;
|
|
30
|
-
});
|
|
31
|
-
const homePath = computed(() => localizedPath(locale.value, localizedRoutes[locale.value].home));
|
|
32
|
-
const docsPath = computed(() => localizedPath(locale.value, localizedRoutes[locale.value].docs));
|
|
33
|
-
const title = computed(() => `${normalized.value.statusCode} - ${copy.value.title}`);
|
|
34
|
-
// Keep the full site chrome for content-level errors (404 and friends); hard
|
|
35
|
-
// server failures fall back to the bare page so the error screen cannot crash.
|
|
36
|
-
const showChrome = computed(() => normalized.value.kind !== "server");
|
|
37
|
-
|
|
38
|
-
useHead(() => ({
|
|
39
|
-
title: title.value,
|
|
40
|
-
meta: [{ name: "robots", content: "noindex, nofollow" }],
|
|
41
|
-
}));
|
|
42
|
-
|
|
43
|
-
const handleError = () => clearError({ redirect: homePath.value });
|
|
2
|
+
// Not found and other errors, inside the site's frame (the layer's layout, or the site's own). A site with its own
|
|
3
|
+
// error.vue replaces this one.
|
|
4
|
+
import type { NuxtError } from '#app'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ error: NuxtError }>()
|
|
7
|
+
const { t, localePath } = useI18n()
|
|
8
|
+
const docs = useDocs()
|
|
9
|
+
const notFound = computed(() => props.error.statusCode === 404)
|
|
10
|
+
useGinkoSeo(() => ({ title: notFound.value ? t('docs.notFound.title') : t('docs.error.title'), noindex: true }))
|
|
11
|
+
// Links work without JavaScript; with it, clearError leaves the error state and navigates.
|
|
12
|
+
const leave = (to: string) => clearError({ redirect: to })
|
|
44
13
|
</script>
|
|
45
14
|
|
|
46
15
|
<template>
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class="
|
|
56
|
-
aria-hidden="true"
|
|
57
|
-
>
|
|
58
|
-
{{ normalized.statusCode }}
|
|
59
|
-
</p>
|
|
60
|
-
|
|
61
|
-
<h1 class="mb-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
|
62
|
-
{{ copy.title }}
|
|
63
|
-
</h1>
|
|
64
|
-
<p class="mx-auto mb-8 max-w-md leading-7 text-muted-foreground">
|
|
65
|
-
{{ copy.description }}
|
|
66
|
-
</p>
|
|
67
|
-
|
|
68
|
-
<div class="flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
69
|
-
<button class="error-action error-action-primary" type="button" @click="handleError">
|
|
70
|
-
{{ messages[locale].errors.actions.home }}
|
|
71
|
-
</button>
|
|
72
|
-
<a class="error-action error-action-secondary" :href="docsPath">
|
|
73
|
-
{{ messages[locale].errors.actions.docs }}
|
|
74
|
-
</a>
|
|
16
|
+
<NuxtLayout>
|
|
17
|
+
<div class="docs-error">
|
|
18
|
+
<div>
|
|
19
|
+
<p class="docs-error-code">{{ error.statusCode }}</p>
|
|
20
|
+
<h1>{{ notFound ? t('docs.notFound.title') : t('docs.error.title') }}</h1>
|
|
21
|
+
<p>{{ notFound ? t('docs.notFound.text') : t('docs.error.text') }}</p>
|
|
22
|
+
<div class="docs-error-actions">
|
|
23
|
+
<a :href="localePath('/')" class="docs-button is-large is-primary" @click.prevent="leave(localePath('/'))">{{ t('docs.notFound.home') }}</a>
|
|
24
|
+
<a v-if="docs.first.value" :href="docs.first.value" class="docs-button is-large is-soft" @click.prevent="leave(docs.first.value)">{{ t('docs.docs') }}</a>
|
|
75
25
|
</div>
|
|
76
|
-
</
|
|
77
|
-
</
|
|
78
|
-
|
|
79
|
-
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</NuxtLayout>
|
|
80
29
|
</template>
|
|
81
|
-
|
|
82
|
-
<style scoped>
|
|
83
|
-
.error-action {
|
|
84
|
-
align-items: center;
|
|
85
|
-
border-radius: 0.375rem;
|
|
86
|
-
display: inline-flex;
|
|
87
|
-
font-size: 0.875rem;
|
|
88
|
-
font-weight: 500;
|
|
89
|
-
height: 2.5rem;
|
|
90
|
-
justify-content: center;
|
|
91
|
-
padding-inline: 1rem;
|
|
92
|
-
transition:
|
|
93
|
-
background-color 150ms,
|
|
94
|
-
color 150ms,
|
|
95
|
-
border-color 150ms;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
.error-action:focus-visible {
|
|
99
|
-
outline: 2px solid var(--ring);
|
|
100
|
-
outline-offset: 2px;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.error-action-primary {
|
|
104
|
-
background: var(--primary);
|
|
105
|
-
color: var(--primary-foreground);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
.error-action-primary:hover {
|
|
109
|
-
opacity: 0.9;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.error-action-secondary {
|
|
113
|
-
background: var(--background);
|
|
114
|
-
border: 1px solid var(--input);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
.error-action-secondary:hover {
|
|
118
|
-
background: var(--accent);
|
|
119
|
-
color: var(--accent-foreground);
|
|
120
|
-
}
|
|
121
|
-
</style>
|
package/app/layouts/default.vue
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The standalone frame: banner, header, page, footer. A site with its own `layouts/default.vue` replaces it, and
|
|
3
|
+
// the docs pages then sit inside the site's frame (the docs body, DocsShell, works either way).
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const { theme, banner } = useAppConfig().docs ?? {}
|
|
6
|
+
useHead({
|
|
7
|
+
// `docs-standalone` turns on the page background, font and color-scheme (docs.css); embedded, the site owns those.
|
|
8
|
+
htmlAttrs: { class: 'docs-standalone', 'data-docs-theme': theme && theme !== 'default' ? theme : undefined },
|
|
9
|
+
})
|
|
10
|
+
// "Skip to content" goes past the docs navigation to the page itself when the page has one (DocsShell), else to main.
|
|
11
|
+
function skip(event: MouseEvent) {
|
|
12
|
+
const target = document.getElementById('docs-content') ?? document.getElementById('docs-main')
|
|
13
|
+
if (!target) return
|
|
14
|
+
event.preventDefault()
|
|
15
|
+
target.focus()
|
|
16
|
+
target.scrollIntoView()
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
1
20
|
<template>
|
|
2
|
-
<div class="
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
<
|
|
6
|
-
<main id="main
|
|
21
|
+
<div class="docs-frame">
|
|
22
|
+
<a class="docs-skip" href="#docs-main" @click="skip">{{ t('docs.skip') }}</a>
|
|
23
|
+
<DocsBanner v-if="banner" :id="banner.id" :href="banner.href" />
|
|
24
|
+
<DocsHeader />
|
|
25
|
+
<main id="docs-main" class="docs-frame-main" tabindex="-1">
|
|
7
26
|
<slot />
|
|
8
27
|
</main>
|
|
9
|
-
<
|
|
10
|
-
<SiteInteractionLayer />
|
|
28
|
+
<DocsFooter />
|
|
11
29
|
</div>
|
|
12
30
|
</template>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Types of the blog and changelog entries. Kept out of `composables/` so that they are not auto-imported into the host app.
|
|
2
|
+
import type { ItemOf } from '@lupinum/ginko'
|
|
3
|
+
import type { blogCollections, changelogCollection } from '@lupinum/ginko-docs/config'
|
|
4
|
+
|
|
5
|
+
type Blog = ReturnType<typeof blogCollections>
|
|
6
|
+
export type Post = ItemOf<Blog['posts']['fields']> & { url?: string }
|
|
7
|
+
export type Author = ItemOf<Blog['authors']['fields']>
|
|
8
|
+
export type ChangelogEntry = ItemOf<ReturnType<typeof changelogCollection>['fields']>
|