@lupinum/ginko-docs 0.4.0 → 0.6.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 +74 -0
- package/README.md +111 -16
- package/app/app.config.ts +2 -0
- package/app/assets/docs.css +79 -15
- package/app/components/DocsBanner.vue +1 -1
- package/app/components/DocsExample.vue +1 -1
- package/app/components/DocsHeader.vue +7 -6
- package/app/components/DocsIcon.vue +12 -0
- package/app/components/DocsLanding.vue +8 -5
- package/app/components/DocsLanguage.vue +18 -7
- package/app/components/DocsNavSheet.vue +37 -0
- package/app/components/DocsNavTree.vue +2 -2
- package/app/components/DocsPage.vue +16 -12
- package/app/components/DocsPageActions.vue +5 -5
- package/app/components/DocsPageEnd.ts +12 -0
- package/app/components/DocsPost.vue +11 -11
- package/app/components/DocsSearch.vue +7 -5
- package/app/components/DocsShell.vue +19 -3
- package/app/components/DocsSidebar.vue +2 -1
- package/app/components/DocsSidebarEnd.ts +4 -0
- package/app/components/DocsThemeToggle.vue +3 -3
- package/app/components/DocsToc.vue +2 -2
- package/app/composables/useDocs.ts +3 -2
- package/app/composables/useDocsBlog.ts +8 -7
- package/app/composables/useDocsFrame.ts +11 -0
- package/app/composables/useDocsSite.ts +24 -3
- package/app/docs-pages/blog.vue +12 -4
- package/app/docs-pages/changelog.vue +13 -5
- package/app/docs-pages/doc.vue +3 -1
- package/app/error.vue +16 -1
- package/app/layouts/default.vue +4 -0
- package/app/lib/docsPage.ts +40 -0
- package/app/lib/markdownComponents.ts +1 -2
- package/dist/config.d.mts +29 -8
- package/dist/config.d.ts +29 -8
- package/dist/config.mjs +17 -48
- package/dist/icons.d.mts +38 -0
- package/dist/icons.d.ts +38 -0
- package/dist/icons.mjs +175 -0
- package/dist/pages.mjs +56 -24
- package/i18n/de.json +1 -1
- package/i18n/en.json +1 -1
- package/i18n/fr.json +1 -0
- package/nuxt.config.ts +2 -17
- package/package.json +11 -10
- package/server/lib/blogFeed.ts +1 -1
- package/shared/layerRoutes.ts +66 -0
- package/dist/shared/ginko-docs.BwbF7JBR.mjs +0 -4
- package/server/lib/rss.ts +0 -57
|
@@ -13,6 +13,9 @@ const { t } = useI18n()
|
|
|
13
13
|
useGinkoSeo(() => ({ title: data.value.title, description: data.value.description }))
|
|
14
14
|
|
|
15
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)
|
|
16
19
|
const external = (href: string) => /^https?:\/\//.test(href)
|
|
17
20
|
</script>
|
|
18
21
|
|
|
@@ -24,26 +27,26 @@ const external = (href: string) => /^https?:\/\//.test(href)
|
|
|
24
27
|
<p v-if="data.description">{{ data.description }}</p>
|
|
25
28
|
<div v-if="data.links.length" class="docs-hero-links">
|
|
26
29
|
<NuxtLink
|
|
27
|
-
v-for="(l, i) in data.links" :key="l.href" :to="l.href" :target="external(l.href) ? '_blank' : undefined"
|
|
30
|
+
v-for="(l, i) in data.links" :key="l.href" :to="to(l.href)" :target="external(l.href) ? '_blank' : undefined"
|
|
28
31
|
class="docs-button is-large" :class="i === 0 ? 'is-primary' : 'is-soft'"
|
|
29
32
|
>
|
|
30
|
-
{{ l.label }}<
|
|
33
|
+
{{ l.label }}<DocsIcon v-if="i === 0" name="lucide:arrow-right" class="docs-icon" />
|
|
31
34
|
</NuxtLink>
|
|
32
35
|
</div>
|
|
33
36
|
<div v-if="data.install" class="docs-install">
|
|
34
37
|
<code tabindex="0" role="region" :aria-label="t('docs.installCommand')">{{ data.install }}</code>
|
|
35
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 ?? '')">
|
|
36
|
-
<
|
|
39
|
+
<DocsIcon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
37
40
|
</button>
|
|
38
41
|
</div>
|
|
39
42
|
</section>
|
|
40
43
|
<ul v-if="data.features.length" class="docs-features">
|
|
41
44
|
<li v-for="f in data.features" :key="f.title" class="docs-feature">
|
|
42
45
|
<div v-if="f.icon" class="docs-feature-icon">
|
|
43
|
-
<
|
|
46
|
+
<DocsIcon :name="f.icon" class="docs-icon" />
|
|
44
47
|
</div>
|
|
45
48
|
<h2>
|
|
46
|
-
<NuxtLink v-if="f.href" :to="f.href">{{ f.title }}</NuxtLink>
|
|
49
|
+
<NuxtLink v-if="f.href" :to="to(f.href)">{{ f.title }}</NuxtLink>
|
|
47
50
|
<template v-else>{{ f.title }}</template>
|
|
48
51
|
</h2>
|
|
49
52
|
<p>{{ f.description }}</p>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The languages of this page (ginko-i18n alternates, so a doc links to its own translation)
|
|
3
|
-
// header
|
|
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 }>()
|
|
4
6
|
const { t, alternates } = useI18n()
|
|
5
7
|
const { open, toggle } = useDocsPopover()
|
|
6
8
|
const current = computed(() => alternates.value.find((a) => a.current))
|
|
@@ -8,19 +10,28 @@ const current = computed(() => alternates.value.find((a) => a.current))
|
|
|
8
10
|
|
|
9
11
|
<template>
|
|
10
12
|
<template v-if="alternates.length > 1">
|
|
11
|
-
<
|
|
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">
|
|
12
20
|
<button
|
|
13
21
|
type="button" class="docs-icon-button" :aria-expanded="open" aria-controls="docs-lang-menu"
|
|
14
22
|
:aria-label="`${t('docs.language')}: ${current?.name ?? ''}`" @click="toggle"
|
|
15
23
|
>
|
|
16
|
-
<
|
|
24
|
+
<DocsIcon name="lucide:languages" class="docs-icon" />
|
|
17
25
|
<span aria-hidden="true">{{ current?.code.toUpperCase() }}</span>
|
|
18
26
|
</button>
|
|
19
27
|
<ul v-if="open" id="docs-lang-menu" class="docs-popover">
|
|
20
28
|
<li v-for="a in alternates" :key="a.code">
|
|
21
|
-
<NuxtLink :to="a.path" :hreflang="a.language" :
|
|
22
|
-
|
|
23
|
-
|
|
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" />
|
|
24
35
|
</NuxtLink>
|
|
25
36
|
</li>
|
|
26
37
|
</ul>
|
|
@@ -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>
|
|
@@ -19,7 +19,7 @@ const toggle = (node: DocNode) => choice.set(node.path, !isOpen(node))
|
|
|
19
19
|
<li v-for="node in props.nodes" :key="node.path">
|
|
20
20
|
<div class="docs-nav-item">
|
|
21
21
|
<NuxtLink :to="node.path" :aria-current="node.path === route.path ? 'page' : undefined">
|
|
22
|
-
<
|
|
22
|
+
<DocsIcon v-if="node.icon" :name="node.icon" class="docs-icon" />
|
|
23
23
|
<span class="docs-nav-title">{{ node.title }}</span>
|
|
24
24
|
<span v-if="node.badge" class="docs-badge">{{ node.badge }}</span>
|
|
25
25
|
</NuxtLink>
|
|
@@ -27,7 +27,7 @@ const toggle = (node: DocNode) => choice.set(node.path, !isOpen(node))
|
|
|
27
27
|
v-if="node.children.length" type="button" class="docs-nav-toggle"
|
|
28
28
|
:aria-expanded="isOpen(node)" :aria-label="t('docs.toggle', { title: node.title })" @click="toggle(node)"
|
|
29
29
|
>
|
|
30
|
-
<
|
|
30
|
+
<DocsIcon name="lucide:chevron-right" class="docs-icon" />
|
|
31
31
|
</button>
|
|
32
32
|
</div>
|
|
33
33
|
<div v-if="node.children.length" v-show="isOpen(node)" class="docs-nav-children">
|
|
@@ -3,14 +3,15 @@ import { docsEditUrl, docsIssueUrl, docsStudioUrl } from '../lib/docsPage'
|
|
|
3
3
|
import { markdownComponents } from '../lib/markdownComponents'
|
|
4
4
|
// One doc: breadcrumbs, title, lead and page actions, the text with the layer's Markdown components, the edit
|
|
5
5
|
// link and date, previous/next; the table of contents beside it (or in the docs bar, see doc.vue).
|
|
6
|
-
import type
|
|
7
|
-
import { articleLd
|
|
6
|
+
import { breadcrumbJsonLd, type ItemOf } from '@lupinum/ginko'
|
|
7
|
+
import { articleLd } from '@lupinum/ginko/internal'
|
|
8
8
|
import type { docsCollection } from '@lupinum/ginko-docs/config'
|
|
9
9
|
|
|
10
10
|
// `source` (the page's repository path) is in the bundle because this layer asks for it (hook `ginko:files`, nuxt.config.ts).
|
|
11
11
|
const props = defineProps<{ doc: ItemOf<ReturnType<typeof docsCollection>['fields']> & { source?: string } }>()
|
|
12
12
|
const { t, format, language } = useI18n()
|
|
13
13
|
const { crumbs } = useDocs()
|
|
14
|
+
const langOf = useGinkoLang()
|
|
14
15
|
const neighbors = useGinkoNeighbors('docs', () => props.doc.id)
|
|
15
16
|
const site = useGinkoSite()
|
|
16
17
|
// The Studio link needs the platform address and the site name: internal keys of the Ginko module, read by its own layer.
|
|
@@ -24,7 +25,7 @@ useGinkoSeo(() => ({
|
|
|
24
25
|
jsonLd: [
|
|
25
26
|
// articleLd fixes the type to Article; docs are TechArticle.
|
|
26
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' },
|
|
27
|
-
...(crumbs.value.length > 1 ? [
|
|
28
|
+
...(crumbs.value.length > 1 ? [breadcrumbJsonLd(crumbs.value, site.url)] : []),
|
|
28
29
|
],
|
|
29
30
|
}))
|
|
30
31
|
|
|
@@ -36,7 +37,9 @@ const updated = computed(() => props.doc.updatedAt && {
|
|
|
36
37
|
iso: props.doc.updatedAt,
|
|
37
38
|
text: format.date(new Date(props.doc.updatedAt), 'long'),
|
|
38
39
|
})
|
|
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 ?? [])
|
|
40
43
|
const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
|
41
44
|
</script>
|
|
42
45
|
|
|
@@ -46,7 +49,7 @@ const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduc
|
|
|
46
49
|
<nav v-if="crumbs.length > 1" class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
|
|
47
50
|
<ol>
|
|
48
51
|
<li v-for="(c, i) in crumbs" :key="c.path + i">
|
|
49
|
-
<
|
|
52
|
+
<DocsIcon v-if="i" name="lucide:chevron-right" class="docs-icon" />
|
|
50
53
|
<span v-if="i === crumbs.length - 1" aria-current="page">{{ c.title }}</span>
|
|
51
54
|
<NuxtLink v-else :to="c.path">{{ c.title }}</NuxtLink>
|
|
52
55
|
</li>
|
|
@@ -54,30 +57,31 @@ const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduc
|
|
|
54
57
|
</nav>
|
|
55
58
|
<header class="docs-page-head">
|
|
56
59
|
<h1>{{ doc.data.title }}</h1>
|
|
57
|
-
<p v-if="doc.data.description" class="docs-lead">{{ doc.data.description }}</p>
|
|
60
|
+
<p v-if="doc.data.description" class="docs-lead" :lang="langOf(doc, 'description')">{{ doc.data.description }}</p>
|
|
58
61
|
<DocsPageActions :path="route.path" :origin="site.url" />
|
|
59
62
|
</header>
|
|
60
63
|
<GinkoMarkdown :value="doc.data.body" :components="markdownComponents" class="docs-prose" />
|
|
64
|
+
<DocsPageEnd :doc="doc" />
|
|
61
65
|
<footer>
|
|
62
66
|
<div v-if="editUrl || updated" class="docs-page-foot">
|
|
63
|
-
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><
|
|
67
|
+
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><DocsIcon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
|
|
64
68
|
<span v-if="updated">{{ t('docs.updated') }} <time :datetime="updated.iso">{{ updated.text }}</time></span>
|
|
65
69
|
</div>
|
|
66
70
|
<nav v-if="neighbors.prev || neighbors.next" class="docs-pager" :aria-label="t('docs.pager')">
|
|
67
71
|
<NuxtLink v-if="neighbors.prev" :to="neighbors.prev.url" rel="prev">
|
|
68
|
-
<small><
|
|
72
|
+
<small><DocsIcon name="lucide:arrow-left" class="docs-icon" />{{ t('docs.previous') }}</small>{{ neighbors.prev.data.title }}
|
|
69
73
|
</NuxtLink>
|
|
70
74
|
<NuxtLink v-if="neighbors.next" :to="neighbors.next.url" rel="next" class="is-next">
|
|
71
|
-
<small>{{ t('docs.next') }}<
|
|
75
|
+
<small>{{ t('docs.next') }}<DocsIcon name="lucide:arrow-right" class="docs-icon" /></small>{{ neighbors.next.data.title }}
|
|
72
76
|
</NuxtLink>
|
|
73
77
|
</nav>
|
|
74
78
|
</footer>
|
|
75
79
|
</article>
|
|
76
80
|
<DocsToc v-if="toc.length" :links="toc" variant="aside">
|
|
77
81
|
<div class="docs-toc-more">
|
|
78
|
-
<a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><
|
|
79
|
-
<a v-if="issueUrl" :href="issueUrl" target="_blank" rel="noopener"><
|
|
80
|
-
<button type="button" @click="top"><
|
|
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>
|
|
81
85
|
</div>
|
|
82
86
|
</DocsToc>
|
|
83
87
|
</div>
|
|
@@ -22,24 +22,24 @@ const menu = openIn.length > 0
|
|
|
22
22
|
<template>
|
|
23
23
|
<div v-if="cfg.copy !== false || menu" ref="popover" class="docs-actions" role="group" :aria-label="t('docs.pageActions')">
|
|
24
24
|
<button v-if="cfg.copy !== false" type="button" class="docs-button" @click="copy">
|
|
25
|
-
<
|
|
25
|
+
<DocsIcon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
|
|
26
26
|
<span aria-live="polite">{{ state === 'copied' ? t('docs.copied') : state === 'failed' ? t('docs.copyFailed') : t('docs.copyPage') }}</span>
|
|
27
27
|
</button>
|
|
28
28
|
<button type="button" class="docs-button" :aria-expanded="open" aria-controls="docs-page-menu" :aria-label="t('docs.moreActions')" @click="toggle">
|
|
29
|
-
<
|
|
29
|
+
<DocsIcon name="lucide:chevron-down" class="docs-icon" />
|
|
30
30
|
</button>
|
|
31
31
|
<ul v-if="open" id="docs-page-menu" class="docs-popover">
|
|
32
32
|
<li>
|
|
33
33
|
<a :href="mdPath" target="_blank">
|
|
34
|
-
<
|
|
34
|
+
<DocsIcon name="lucide:file-text" class="docs-icon" />
|
|
35
35
|
<span>{{ t('docs.viewMarkdown') }}<small>{{ t('docs.viewMarkdownHint') }}</small></span>
|
|
36
36
|
</a>
|
|
37
37
|
</li>
|
|
38
38
|
<li v-for="id in openIn" :key="id">
|
|
39
39
|
<a :href="links[id]" target="_blank" rel="noopener">
|
|
40
|
-
<
|
|
40
|
+
<DocsIcon :name="assistants[id].icon" class="docs-icon" />
|
|
41
41
|
<span>{{ t(assistants[id].label) }}<small>{{ t('docs.askHint') }}</small></span>
|
|
42
|
-
<
|
|
42
|
+
<DocsIcon name="lucide:arrow-up-right" class="docs-icon" />
|
|
43
43
|
</a>
|
|
44
44
|
</li>
|
|
45
45
|
</ul>
|
|
@@ -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
|
+
})
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { markdownComponents } from '../lib/markdownComponents'
|
|
3
|
-
// One blog post: title, date, authors, text.
|
|
4
|
-
import {
|
|
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
5
|
import type { Post } from '../lib/blogTypes'
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{ post: Post }>()
|
|
8
8
|
const { t, language } = useI18n()
|
|
9
9
|
const blog = useDocsBlog()
|
|
10
|
+
const langOf = useGinkoLang()
|
|
10
11
|
const site = useGinkoSite()
|
|
11
12
|
const route = useRoute()
|
|
12
13
|
const authors = computed(() => blog.authorsOf(props.post))
|
|
13
14
|
const absolute = (path: string) => new URL(path, site.url).href
|
|
14
15
|
|
|
15
16
|
useGinkoSeo(() => {
|
|
16
|
-
const { title, description, date, image } = props.post.data
|
|
17
|
-
const ld = articleLd({
|
|
18
|
-
title, description, lang: language.value, url: absolute(route.path), image: image ? absolute(image.url) : undefined,
|
|
19
|
-
published: date, modified: (props.post.updatedAt ?? date).slice(0, 10),
|
|
20
|
-
})
|
|
17
|
+
const { title, description, date, image, tags } = props.post.data
|
|
21
18
|
return {
|
|
22
19
|
title, description, type: 'article', image: image?.url,
|
|
23
|
-
|
|
24
|
-
|
|
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
25
|
}
|
|
26
26
|
})
|
|
27
27
|
useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
|
|
@@ -34,11 +34,11 @@ useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title:
|
|
|
34
34
|
<nav class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
|
|
35
35
|
<ol>
|
|
36
36
|
<li><NuxtLink :to="blog.blogPath.value">{{ t('docs.blog') }}</NuxtLink></li>
|
|
37
|
-
<li><
|
|
37
|
+
<li><DocsIcon name="lucide:chevron-right" class="docs-icon" /><span aria-current="page">{{ post.data.title }}</span></li>
|
|
38
38
|
</ol>
|
|
39
39
|
</nav>
|
|
40
40
|
<h1>{{ post.data.title }}</h1>
|
|
41
|
-
<p v-if="post.data.description" class="docs-lead">{{ post.data.description }}</p>
|
|
41
|
+
<p v-if="post.data.description" class="docs-lead" :lang="langOf(post, 'description')">{{ post.data.description }}</p>
|
|
42
42
|
<p class="docs-meta">
|
|
43
43
|
<time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time>
|
|
44
44
|
<template v-if="authors.length">
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Hits are grouped by page: the page itself first, then its sections. Arrow keys move, Enter opens.
|
|
4
4
|
import type { SearchHit, SearchIndex, SearchIndexJson } from '@lupinum/ginko/search'
|
|
5
5
|
|
|
6
|
+
// `shortcut` (default true): `/` and Ctrl/⌘ K open this search. A second trigger on the same page sets it to false.
|
|
7
|
+
const { shortcut = true } = defineProps<{ shortcut?: boolean }>()
|
|
6
8
|
const { t, locale } = useI18n()
|
|
7
9
|
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
|
|
8
10
|
const input = useTemplateRef<HTMLInputElement>('input')
|
|
@@ -79,21 +81,21 @@ function onKey(e: KeyboardEvent) {
|
|
|
79
81
|
}
|
|
80
82
|
onMounted(() => {
|
|
81
83
|
mac.value = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
82
|
-
window.addEventListener('keydown', onKey)
|
|
84
|
+
if (shortcut) window.addEventListener('keydown', onKey)
|
|
83
85
|
})
|
|
84
86
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
85
87
|
</script>
|
|
86
88
|
|
|
87
89
|
<template>
|
|
88
90
|
<button type="button" class="docs-search-trigger" :aria-label="t('docs.search')" @click="open">
|
|
89
|
-
<
|
|
91
|
+
<DocsIcon name="lucide:search" class="docs-icon" />
|
|
90
92
|
<span class="docs-search-label">{{ t('docs.searchPlaceholder') }}</span>
|
|
91
93
|
<kbd class="docs-kbd">{{ mac ? '⌘' : 'Ctrl' }} K</kbd>
|
|
92
94
|
</button>
|
|
93
95
|
<Teleport to="body">
|
|
94
96
|
<dialog ref="dialog" class="docs-search-dialog" :aria-label="t('docs.search')" @click.self="dialog?.close()">
|
|
95
97
|
<div class="docs-search-field">
|
|
96
|
-
<
|
|
98
|
+
<DocsIcon name="lucide:search" class="docs-icon" />
|
|
97
99
|
<input
|
|
98
100
|
ref="input" v-model="query" type="search" role="combobox" aria-autocomplete="list" aria-controls="docs-search-list"
|
|
99
101
|
:aria-expanded="flat.length > 0" :aria-activedescendant="flat.length ? `docs-hit-${active}` : undefined"
|
|
@@ -111,12 +113,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
|
111
113
|
v-for="{ hit, indent } in g.rows" :id="`docs-hit-${indexOf(hit)}`" :key="hit.id" role="option" :aria-selected="indexOf(hit) === active"
|
|
112
114
|
class="docs-search-hit" :class="{ 'is-section': indent }" @mousemove="active = indexOf(hit)" @click="active = indexOf(hit); go()"
|
|
113
115
|
>
|
|
114
|
-
<span class="docs-search-tile"><
|
|
116
|
+
<span class="docs-search-tile"><DocsIcon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
|
|
115
117
|
<span class="docs-search-text">
|
|
116
118
|
<strong>{{ hit.heading ?? hit.title }}</strong>
|
|
117
119
|
<span>{{ indent ? hit.excerpt : hit.heading ? hit.title : hit.excerpt }}</span>
|
|
118
120
|
</span>
|
|
119
|
-
<
|
|
121
|
+
<DocsIcon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
|
|
120
122
|
</li>
|
|
121
123
|
</ul>
|
|
122
124
|
</li>
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The docs body: the navigation on the side and the page next to it.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// The docs body: the navigation on the side and the page next to it. Public: a site builds its own pages inside the
|
|
3
|
+
// docs frame with it (slots: default, `bar`). In the layer's own frame the header holds the search and, on phones, the
|
|
4
|
+
// navigation. Inside a site's layout the shell brings both: the search above the navigation, and on phones a bar with
|
|
5
|
+
// a button that opens the navigation as a sheet, the search, and the page's own controls (slot `bar`, the table of
|
|
6
|
+
// contents). `docs.search: false` in app.config leaves the search out (a site with <DocsSearch /> in its own header).
|
|
5
7
|
const { t } = useI18n()
|
|
8
|
+
const standalone = useDocsStandalone()
|
|
9
|
+
const search = !standalone && useAppConfig().docs?.search !== false
|
|
10
|
+
const nav = ref(false)
|
|
6
11
|
</script>
|
|
7
12
|
|
|
8
13
|
<template>
|
|
9
14
|
<div class="docs-shell">
|
|
10
15
|
<nav class="docs-sidebar" :aria-label="t('docs.nav')">
|
|
16
|
+
<div v-if="search" class="docs-sidebar-search">
|
|
17
|
+
<DocsSearch />
|
|
18
|
+
</div>
|
|
11
19
|
<DocsSidebar />
|
|
12
20
|
</nav>
|
|
13
21
|
<div id="docs-content" class="docs-content" tabindex="-1">
|
|
14
22
|
<div class="docs-bar">
|
|
23
|
+
<div v-if="!standalone" class="docs-bar-start">
|
|
24
|
+
<button type="button" class="docs-bar-nav" aria-haspopup="dialog" :aria-expanded="nav" @click="nav = true">
|
|
25
|
+
<DocsIcon name="lucide:panel-left-open" class="docs-icon" />{{ t('docs.contents') }}
|
|
26
|
+
</button>
|
|
27
|
+
<!-- A second trigger for phones; the one above the navigation keeps the keyboard shortcut. -->
|
|
28
|
+
<DocsSearch v-if="search" :shortcut="false" />
|
|
29
|
+
</div>
|
|
15
30
|
<slot name="bar" />
|
|
16
31
|
</div>
|
|
17
32
|
<slot />
|
|
18
33
|
</div>
|
|
34
|
+
<DocsNavSheet v-if="!standalone" v-model:open="nav" />
|
|
19
35
|
</div>
|
|
20
36
|
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
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
|
|
3
|
+
// sections, then the site's DocsSidebarEnd. Used in the sidebar, the phone sheet and the header menu.
|
|
4
4
|
const { t } = useI18n()
|
|
5
5
|
const { tabs, activeTab, sections } = useDocs()
|
|
6
6
|
</script>
|
|
@@ -14,5 +14,6 @@ const { tabs, activeTab, sections } = useDocs()
|
|
|
14
14
|
<h2 v-if="s.title" class="docs-nav-heading">{{ s.title }}</h2>
|
|
15
15
|
<DocsNavTree :nodes="s.nodes" />
|
|
16
16
|
</section>
|
|
17
|
+
<DocsSidebarEnd />
|
|
17
18
|
</div>
|
|
18
19
|
</template>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Override point: below the docs navigation (sidebar, phone sheet and the header menu). Renders nothing here; a site
|
|
2
|
+
// puts its own `components/DocsSidebarEnd.vue` in its app to add links or a contact line. A render function, not an
|
|
3
|
+
// empty <template>: server and browser then agree on the empty placeholder.
|
|
4
|
+
export default defineComponent({ name: 'DocsSidebarEnd', render: () => null })
|
|
@@ -38,8 +38,8 @@ const OPTIONS = ['auto', 'light', 'dark'] as const
|
|
|
38
38
|
<div v-if="props.segmented" class="docs-segmented" role="group" :aria-label="t('docs.theme.label')">
|
|
39
39
|
<button v-for="o in OPTIONS" :key="o" type="button" :aria-pressed="choice === o" @click="choose(o)">{{ t(`docs.theme.${o}`) }}</button>
|
|
40
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
|
-
<
|
|
43
|
-
<
|
|
41
|
+
<button v-else type="button" class="docs-icon-button docs-theme-toggle" :aria-label="t('docs.theme.toggle')" :title="t('docs.theme.toggle')" @click="toggle">
|
|
42
|
+
<DocsIcon name="lucide:moon" class="docs-icon docs-when-light" />
|
|
43
|
+
<DocsIcon name="lucide:sun" class="docs-icon docs-when-dark" />
|
|
44
44
|
</button>
|
|
45
45
|
</template>
|
|
@@ -48,7 +48,7 @@ const title = computed(() => flat.value.find((l) => l.id === current.value)?.tex
|
|
|
48
48
|
<template>
|
|
49
49
|
<nav v-if="props.variant === 'aside'" ref="aside" class="docs-toc" :aria-label="t('docs.onThisPage')">
|
|
50
50
|
<p class="docs-toc-title">
|
|
51
|
-
<
|
|
51
|
+
<DocsIcon name="lucide:text-align-start" class="docs-icon" />{{ t('docs.onThisPage') }}
|
|
52
52
|
</p>
|
|
53
53
|
<ul class="docs-toc-list">
|
|
54
54
|
<li v-for="l in flat" :key="l.id">
|
|
@@ -60,7 +60,7 @@ const title = computed(() => flat.value.find((l) => l.id === current.value)?.tex
|
|
|
60
60
|
<div v-else ref="popover" class="docs-toc-menu">
|
|
61
61
|
<button type="button" :aria-expanded="open" aria-controls="docs-toc-menu" @click="toggle">
|
|
62
62
|
<span>{{ title && flat.length > 1 ? title : t('docs.onThisPage') }}</span>
|
|
63
|
-
<
|
|
63
|
+
<DocsIcon name="lucide:chevron-down" class="docs-icon" />
|
|
64
64
|
</button>
|
|
65
65
|
<nav v-if="open" id="docs-toc-menu" class="docs-popover" :aria-label="t('docs.onThisPage')">
|
|
66
66
|
<ul class="docs-toc-list">
|
|
@@ -46,7 +46,8 @@ export function useDocs() {
|
|
|
46
46
|
const { localePath } = useI18n()
|
|
47
47
|
const { mainLocale } = useGinkoSite()
|
|
48
48
|
const route = (schema.bricks.docs as { route?: Record<string, string> } | undefined)?.route?.[mainLocale]
|
|
49
|
-
const
|
|
49
|
+
const mainBase = route?.replace(/\/\[slug\]$/, '') ?? '/docs'
|
|
50
|
+
const base = computed(() => localePath(mainBase))
|
|
50
51
|
|
|
51
|
-
return { nodes, tabs, activeTab, sections, crumbs, first, base }
|
|
52
|
+
return { nodes, tabs, activeTab, sections, crumbs, first, base, mainBase }
|
|
52
53
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The optional blog and changelog: whether the site has them, where they live in this language, and typed
|
|
2
2
|
// reads. The layer cannot know the site's bricks at compile time, so the reads go through one cast here.
|
|
3
3
|
import schema from '#ginko/schema'
|
|
4
|
-
import { BLOG_PATH, CHANGELOG_PATH } from '../../shared/paths'
|
|
5
4
|
import type { Author, ChangelogEntry, Post } from '../lib/blogTypes'
|
|
6
5
|
|
|
7
6
|
|
|
@@ -10,8 +9,10 @@ const read = <T>(brick: string) => (useGinko as unknown as (b: string) => Comput
|
|
|
10
9
|
|
|
11
10
|
export function useDocsBlog() {
|
|
12
11
|
const { localePath } = useI18n()
|
|
13
|
-
|
|
14
|
-
const
|
|
12
|
+
// The addresses of the bricks' lists in the main language, written by modules/pages.ts.
|
|
13
|
+
const address = useRuntimeConfig().public.ginkoDocs
|
|
14
|
+
const hasPosts = has('posts') && !!address.blog
|
|
15
|
+
const hasChangelog = has('changelog') && !!address.changelog
|
|
15
16
|
|
|
16
17
|
const authors = hasPosts && has('authors') ? read<Author>('authors') : undefined
|
|
17
18
|
const authorsOf = (post: Post) => (post.data.authors ?? []).flatMap((id) => authors?.value.items.find((a) => a.id === id) ?? [])
|
|
@@ -22,11 +23,11 @@ export function useDocsBlog() {
|
|
|
22
23
|
const isDraft = (post: Post) => 'draft' in post.data && post.data.draft === true
|
|
23
24
|
return {
|
|
24
25
|
hasPosts, hasChangelog, authorsOf, isDraft, formatDate,
|
|
25
|
-
blogPath: computed(() => localePath(
|
|
26
|
-
feedPath: computed(() => localePath(`${
|
|
27
|
-
changelogPath: computed(() => localePath(
|
|
26
|
+
blogPath: computed(() => localePath(address.blog)),
|
|
27
|
+
feedPath: computed(() => localePath(`${address.blog}/rss.xml`)),
|
|
28
|
+
changelogPath: computed(() => localePath(address.changelog)),
|
|
28
29
|
posts: () => read<Post>('posts'),
|
|
29
30
|
changelog: () => read<ChangelogEntry>('changelog'),
|
|
30
|
-
post: (slug: string) => (useGinkoEntry as unknown as (b: string, s: string) =>
|
|
31
|
+
post: (slug: string) => (useGinkoEntry as unknown as (b: string, s: string) => Readonly<Ref<Post>>)('posts', slug),
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
|
|
3
|
+
// Whether the docs render in the layer's own frame (layouts/default.vue) or inside a site's layout. Standalone, the
|
|
4
|
+
// header holds the search and, on phones, the navigation; embedded, DocsShell brings both itself.
|
|
5
|
+
const STANDALONE: InjectionKey<boolean> = Symbol('docs-standalone')
|
|
6
|
+
|
|
7
|
+
/** Called by the layer's own layout. */
|
|
8
|
+
export const provideDocsStandalone = () => provide(STANDALONE, true)
|
|
9
|
+
|
|
10
|
+
/** True inside the layer's own frame, false when a site's layout holds the docs. */
|
|
11
|
+
export const useDocsStandalone = () => inject(STANDALONE, false)
|
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
// The site's own
|
|
1
|
+
// The site's own links outside Markdown (app.config nav, footer and banner, the start page's links and cards): their
|
|
2
|
+
// texts and their addresses.
|
|
3
|
+
import schema from '#ginko/schema'
|
|
4
|
+
import { docsMainPath, docsRouteLink } from '../lib/docsPage'
|
|
5
|
+
|
|
2
6
|
export function useDocsSite() {
|
|
3
7
|
const { t, localePath } = useI18n()
|
|
8
|
+
const docs = useGinko('docs')
|
|
9
|
+
const locale = useGinkoLocale()
|
|
10
|
+
const { mainLocale } = useGinkoSite()
|
|
11
|
+
const pages = schema.pages as Record<string, Partial<Record<string, string>> | undefined>
|
|
12
|
+
const docsRoute = (schema.bricks as Record<string, { route?: Partial<Record<string, string>> } | undefined>).docs?.route
|
|
13
|
+
const find = {
|
|
14
|
+
// A page the current language lacks leads to its main-language page.
|
|
15
|
+
doc: (ref: string) => docs.value.items.find((d) => d.id === `docs/${ref}` || d.data.slug === ref)?.url ?? docsMainPath(docsRoute, mainLocale, ref),
|
|
16
|
+
page: (key: string) => pages[key]?.[locale.value] ?? pages[key]?.[mainLocale],
|
|
17
|
+
}
|
|
4
18
|
return {
|
|
5
19
|
/** A message key the site defines (`docs.nav.<id>`, `docs.footer.<id>`, `docs.banner.<id>`); the layer's types do not know them. */
|
|
6
20
|
text: t as (key: string) => string,
|
|
7
|
-
/**
|
|
8
|
-
|
|
21
|
+
/**
|
|
22
|
+
* An address in the reader's language: a route link (`$docs.<page>`, `$page.<key>`) becomes the page's path, an
|
|
23
|
+
* internal path gets the language prefix; an absolute URL, a mail link or an anchor stays as written.
|
|
24
|
+
*/
|
|
25
|
+
href: (raw: string) => {
|
|
26
|
+
// A route link that names nothing never becomes an address: it would be a relative link to a 404.
|
|
27
|
+
if (raw.startsWith('$')) return docsRouteLink(raw, find) ?? localePath('/')
|
|
28
|
+
return raw.startsWith('/') && !raw.startsWith('//') ? localePath(raw) : raw
|
|
29
|
+
},
|
|
9
30
|
}
|
|
10
31
|
}
|
package/app/docs-pages/blog.vue
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// The blog: all posts, newest first (the collection's order).
|
|
3
|
-
const { t } = useI18n()
|
|
3
|
+
const { t, language } = useI18n()
|
|
4
4
|
const blog = useDocsBlog()
|
|
5
|
+
const langOf = useGinkoLang()
|
|
5
6
|
const posts = blog.posts()
|
|
6
|
-
|
|
7
|
+
// A list page: CollectionPage JSON-LD, so search engines read it as the index of its entries.
|
|
8
|
+
const site = useGinkoSite()
|
|
9
|
+
const route = useRoute()
|
|
10
|
+
useGinkoSeo(() => ({
|
|
11
|
+
title: t('docs.blog'),
|
|
12
|
+
description: t('docs.blogLead'),
|
|
13
|
+
jsonLd: [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: t('docs.blog'), url: new URL(route.path, site.url).href, inLanguage: language.value }],
|
|
14
|
+
}))
|
|
7
15
|
useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
|
|
8
16
|
// Nitro's crawler only follows links to pages (no `.xml`), but it also reads this header, so `nuxt generate` writes the feed.
|
|
9
17
|
if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feedPath.value
|
|
@@ -17,7 +25,7 @@ if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feed
|
|
|
17
25
|
<h1>{{ t('docs.blog') }}</h1>
|
|
18
26
|
<p class="docs-lead">{{ t('docs.blogLead') }}</p>
|
|
19
27
|
</div>
|
|
20
|
-
<a :href="blog.feedPath.value" type="application/rss+xml"><
|
|
28
|
+
<a :href="blog.feedPath.value" type="application/rss+xml"><DocsIcon name="lucide:rss" class="docs-icon" />{{ t('docs.rss') }}</a>
|
|
21
29
|
</header>
|
|
22
30
|
<ul class="docs-posts">
|
|
23
31
|
<li v-for="post in posts.items" :key="post.id">
|
|
@@ -35,7 +43,7 @@ if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feed
|
|
|
35
43
|
<NuxtLink :to="post.url">{{ post.data.title }}</NuxtLink>
|
|
36
44
|
<span v-if="blog.isDraft(post)" class="docs-tag docs-tag-draft">{{ t('docs.draft') }}</span>
|
|
37
45
|
</h2>
|
|
38
|
-
<p v-if="post.data.description" class="docs-post-teaser">{{ post.data.description }}</p>
|
|
46
|
+
<p v-if="post.data.description" class="docs-post-teaser" :lang="langOf(post, 'description')">{{ post.data.description }}</p>
|
|
39
47
|
<p v-if="post.data.tags?.length" class="docs-tags">
|
|
40
48
|
<span v-for="tag in post.data.tags" :key="tag.tag" class="docs-tag">{{ tag.tag }}</span>
|
|
41
49
|
</p>
|