@lupinum/ginko-docs 0.4.0 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +111 -16
  3. package/app/app.config.ts +2 -0
  4. package/app/assets/docs.css +79 -15
  5. package/app/components/DocsBanner.vue +1 -1
  6. package/app/components/DocsExample.vue +1 -1
  7. package/app/components/DocsHeader.vue +7 -6
  8. package/app/components/DocsIcon.vue +12 -0
  9. package/app/components/DocsLanding.vue +8 -5
  10. package/app/components/DocsLanguage.vue +18 -7
  11. package/app/components/DocsNavSheet.vue +37 -0
  12. package/app/components/DocsNavTree.vue +2 -2
  13. package/app/components/DocsPage.vue +16 -12
  14. package/app/components/DocsPageActions.vue +5 -5
  15. package/app/components/DocsPageEnd.ts +12 -0
  16. package/app/components/DocsPost.vue +11 -11
  17. package/app/components/DocsSearch.vue +7 -5
  18. package/app/components/DocsShell.vue +19 -3
  19. package/app/components/DocsSidebar.vue +2 -1
  20. package/app/components/DocsSidebarEnd.ts +4 -0
  21. package/app/components/DocsThemeToggle.vue +3 -3
  22. package/app/components/DocsToc.vue +2 -2
  23. package/app/composables/useDocs.ts +3 -2
  24. package/app/composables/useDocsBlog.ts +8 -7
  25. package/app/composables/useDocsFrame.ts +11 -0
  26. package/app/composables/useDocsSite.ts +24 -3
  27. package/app/docs-pages/blog.vue +12 -4
  28. package/app/docs-pages/changelog.vue +13 -5
  29. package/app/docs-pages/doc.vue +3 -1
  30. package/app/error.vue +16 -1
  31. package/app/layouts/default.vue +4 -0
  32. package/app/lib/docsPage.ts +40 -0
  33. package/app/lib/markdownComponents.ts +1 -2
  34. package/dist/config.d.mts +28 -8
  35. package/dist/config.d.ts +28 -8
  36. package/dist/config.mjs +16 -47
  37. package/dist/icons.d.mts +38 -0
  38. package/dist/icons.d.ts +38 -0
  39. package/dist/icons.mjs +175 -0
  40. package/dist/pages.mjs +56 -24
  41. package/i18n/de.json +1 -1
  42. package/i18n/en.json +1 -1
  43. package/i18n/fr.json +1 -0
  44. package/nuxt.config.ts +2 -17
  45. package/package.json +11 -10
  46. package/server/lib/blogFeed.ts +1 -1
  47. package/shared/layerRoutes.ts +66 -0
  48. package/dist/shared/ginko-docs.BwbF7JBR.mjs +0 -4
  49. 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 }}<Icon v-if="i === 0" name="lucide:arrow-right" class="docs-icon" />
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
- <Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
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
- <Icon :name="f.icon" class="docs-icon" />
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). A menu in the
3
- // header and in the phone menu. Each language is named in itself ("Deutsch").
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
- <div ref="popover" class="docs-lang">
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
- <Icon name="lucide:languages" class="docs-icon" />
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" :lang="a.language" :aria-current="a.current ? 'page' : undefined">
22
- {{ a.name }}
23
- <Icon v-if="a.current" name="lucide:check" class="docs-icon" />
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
- <Icon v-if="node.icon" :name="node.icon" class="docs-icon" />
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
- <Icon name="lucide:chevron-right" class="docs-icon" />
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 { ItemOf } from '@lupinum/ginko'
7
- import { articleLd, breadcrumbLd } from '@lupinum/ginko/internal'
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 ? [breadcrumbLd(crumbs.value.map((c) => ({ ...c, children: [] })), new URL(site.url).origin)] : []),
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
- const toc = computed(() => props.doc.data.body.toc?.links ?? [])
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
- <Icon v-if="i" name="lucide:chevron-right" class="docs-icon" />
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"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
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><Icon name="lucide:arrow-left" class="docs-icon" />{{ t('docs.previous') }}</small>{{ neighbors.prev.data.title }}
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') }}<Icon name="lucide:arrow-right" class="docs-icon" /></small>{{ neighbors.next.data.title }}
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"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
79
- <a v-if="issueUrl" :href="issueUrl" target="_blank" rel="noopener"><Icon name="lucide:circle-alert" class="docs-icon" />{{ t('docs.issue') }}</a>
80
- <button type="button" @click="top"><Icon name="lucide:arrow-up" class="docs-icon" />{{ t('docs.top') }}</button>
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
- <Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
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
- <Icon name="lucide:chevron-down" class="docs-icon" />
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
- <Icon name="lucide:file-text" class="docs-icon" />
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
- <Icon :name="assistants[id].icon" class="docs-icon" />
40
+ <DocsIcon :name="assistants[id].icon" class="docs-icon" />
41
41
  <span>{{ t(assistants[id].label) }}<small>{{ t('docs.askHint') }}</small></span>
42
- <Icon name="lucide:arrow-up-right" class="docs-icon" />
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. Article JSON-LD names the authors and carries both dates.
4
- import { articleLd } from '@lupinum/ginko/internal'
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
- // articleLd takes one author; a post may have several (and a link), so the list replaces it.
24
- jsonLd: [authors.value.length ? { ...ld, author: authors.value.map((a) => ({ '@type': 'Person', name: a.data.name, ...(a.data.url ? { url: a.data.url } : {}) })) } : ld],
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><Icon name="lucide:chevron-right" class="docs-icon" /><span aria-current="page">{{ post.data.title }}</span></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
- <Icon name="lucide:search" class="docs-icon" />
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
- <Icon name="lucide:search" class="docs-icon" />
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"><Icon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
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
- <Icon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
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. 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.
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 in the phone menu.
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
- <Icon name="lucide:moon" class="docs-icon docs-when-light" />
43
- <Icon name="lucide:sun" class="docs-icon docs-when-dark" />
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
- <Icon name="lucide:text-align-start" class="docs-icon" />{{ t('docs.onThisPage') }}
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
- <Icon name="lucide:chevron-down" class="docs-icon" />
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 base = computed(() => localePath(route?.replace(/\/\[slug\]$/, '') ?? '/docs'))
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
- const hasPosts = has('posts')
14
- const hasChangelog = has('changelog')
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(BLOG_PATH)),
26
- feedPath: computed(() => localePath(`${BLOG_PATH}/rss.xml`)),
27
- changelogPath: computed(() => localePath(CHANGELOG_PATH)),
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) => ComputedRef<Post>)('posts', slug),
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 entries in app.config (nav, footer and banner links): their texts and their addresses.
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
- /** 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),
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
  }
@@ -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
- useGinkoSeo(() => ({ title: t('docs.blog'), description: t('docs.blogLead') }))
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"><Icon name="lucide:rss" class="docs-icon" />{{ t('docs.rss') }}</a>
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>