@docsector/docsector-reader 4.4.5 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,10 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
59
59
  - 🔎 **Search** — Menu search across all documentation content and tags
60
60
  - 💬 **Assistant Chat UX Enhancements** — Long conversations keep focus on recent messages, load earlier history progressively, deduplicate repeated sources, preserve the assistant panel open state across reloads, include per-message copy actions, and show a floating quick return to the bottom
61
61
  - 📱 **Responsive** — Mobile-friendly with collapsible sidebar and drawers
62
+ - 🏷️ **Clickable Header Branding** — The configured `branding.logo` and `branding.name` render as a home link in the global header, aligned left on desktop with a compact mobile treatment
62
63
  - 📚 **Book Tabs with Per-State Colors** — Define `*.book.js` tabs with icons, order, and `color.active` / `color.inactive`
64
+ - 📐 **Book Layout Presets** — Configure books with the default documentation chrome or a `fullwidth` layout that keeps the header and book tabs while removing the sidebar, subpage toolbar, and Table of Contents
65
+ - 🦶 **Global Branding Footer** — Built-in `Powered by Docsector` footer renders across documentation and system pages, while respecting each page's own scroll container for full-width layout integration without double scrollbars
63
66
  - 🔀 **Internal Shortcut Pages** — Route entries can redirect with `config.link.to`, keeping localized titles while inheriting icon/status from the destination page
64
67
  - 📐 **Responsive Subpage Toolbar** — Subpage actions align with the content column on desktop and dock to the bottom on mobile
65
68
  - ⬆️ **Reading Progress Back to Top** — Documentation subpages can show a floating back-to-top control with circular reading progress that stays above the mobile subpage toolbar
@@ -70,6 +73,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
70
73
  - 📊 **Translation Progress** — Automatic translation percentage based on header coverage
71
74
  - 🌐 **Accurate Available Translations** — Locale availability counter now uses actual localized page source presence, avoiding false negatives when metadata is equal
72
75
  - 🏠 **Markdown Home at Root** — Homepage is rendered from `src/pages/Homepage.{lang}.md` directly at `/`
76
+ - 🧱 **Configurable Homepage Layout** — Set `homePage.layout` to `default` or `fullwidth`; fullwidth keeps the header and book tabs while removing the sidebar, subpage toolbar, Table of Contents, and homepage footer
73
77
  - 🌍 **Remote README as Home** — Optional build-time remote README source for homepage with automatic local fallback and automatic primary-title handoff when the remote README already provides the project heading
74
78
  - 🔗 **GitHub-Compatible Heading Anchors** — Markdown headings use GitHub-style slugs so standard README Table of Contents links work inside Docsector
75
79
  - 🧬 **Scaffolded Homepage Override Wiring** — New consumer projects automatically wire `virtual:docsector-homepage-override` into i18n message building
@@ -476,6 +480,9 @@ You can configure Docsector Reader to use a remote README as homepage content.
476
480
 
477
481
  - Fetch happens at build-time.
478
482
  - The same README content is used for all configured languages.
483
+ - The homepage uses the `default` layout unless `homePage.layout` is set to `fullwidth`.
484
+ - `homePage.layout` accepts only `default` and `fullwidth`.
485
+ - The `fullwidth` homepage layout keeps the global header and book tabs, but removes the sidebar, subpage toolbar, Table of Contents, and homepage footer.
479
486
  - When the remote README resolves successfully, Docsector hides the autogenerated homepage title and uses the README's own primary heading in the rendered content.
480
487
  - If fetch fails, it falls back to local `src/pages/Homepage.{lang}.md` by default and keeps the usual autogenerated homepage title.
481
488
  - Standard GitHub-style heading links and README Table of Contents fragments keep working in the rendered homepage.
@@ -488,6 +495,7 @@ export default {
488
495
 
489
496
  homePage: {
490
497
  source: 'remote-readme',
498
+ layout: 'fullwidth',
491
499
  remoteReadmeUrl: 'https://raw.githubusercontent.com/your-org/your-repo/main/README.md',
492
500
  timeoutMs: 8000,
493
501
  fallbackToLocal: true
@@ -1053,6 +1061,7 @@ export default defineBook({
1053
1061
  label: 'Guide',
1054
1062
  icon: 'school',
1055
1063
  order: 2,
1064
+ layout: 'default',
1056
1065
  color: {
1057
1066
  active: 'white',
1058
1067
  inactive: 'secondary'
@@ -1090,6 +1099,8 @@ Notes:
1090
1099
  - `color.active` and `color.inactive` control the tab text color for each state.
1091
1100
  - Color values accept Quasar tokens (`secondary`, `red-6`), CSS variables (`--brand-color` or `var(--brand-color)`), and plain CSS colors (`white`, `#fff`, `rgb(...)`).
1092
1101
  - Legacy `color: 'secondary'` still works, but the object form is the recommended API.
1102
+ - `layout: 'default'` keeps the standard documentation chrome with sidebar, subpage toolbar, and Table of Contents.
1103
+ - `layout: 'fullwidth'` keeps the global header and book tabs, but removes the book sidebar, subpage toolbar, and Table of Contents for pages in that book.
1093
1104
  - Tabs are ordered by `order`.
1094
1105
 
1095
1106
  ---
package/bin/docsector.js CHANGED
@@ -24,7 +24,7 @@ const packageRoot = resolve(__dirname, '..')
24
24
  const args = process.argv.slice(2)
25
25
  const command = args[0]
26
26
 
27
- const VERSION = '4.4.5'
27
+ const VERSION = '4.5.0'
28
28
  const AUTHORING_SKILL_NAME = 'docsector-documentation-authoring'
29
29
  const AUTHORING_SKILL_DESCRIPTION = 'Author Docsector documentation with Markdown, custom blocks, MCP, and WebMCP.'
30
30
  const AUTHORING_SKILL_PUBLIC_PATH = `/.well-known/agent-skills/${AUTHORING_SKILL_NAME}/SKILL.md`
@@ -117,6 +117,7 @@ export default {
117
117
  // @ Home page source
118
118
  homePage: {
119
119
  source: 'remote-readme',
120
+ layout: 'default',
120
121
  remoteReadmeUrl: 'https://raw.githubusercontent.com/docsector/docsector-reader/main/README.md',
121
122
  timeoutMs: 8000,
122
123
  fallbackToLocal: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "4.4.5",
3
+ "version": "4.5.0",
4
4
  "description": "A documentation rendering engine built with Vue 3, Quasar v2 and Vite. Transform Markdown into beautiful, navigable documentation sites.",
5
5
  "productName": "Docsector Reader",
6
6
  "author": "Rodrigo de Araujo Vieira",
@@ -1,10 +1,16 @@
1
1
  <script setup>
2
- import { ref, computed, watch } from 'vue'
2
+ import { ref, computed, watch, nextTick } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
3
4
  import { useQuasar } from 'quasar'
4
- import { useStore } from 'vuex'
5
5
  import Prism from './code-block-highlighting'
6
+ import {
7
+ buildSourceCodeLineAnchorId,
8
+ resolveSourceCodeLineHref,
9
+ shouldHandleSourceCodeLineActivation
10
+ } from './source-code-anchor'
6
11
  import { countRenderedCodeLines } from './source-code-lines'
7
12
  import { looksLikeFileName, resolveFileIconUrl } from '../composables/useFileIcon'
13
+ import useNavigator from '../composables/useNavigator'
8
14
 
9
15
  defineOptions({
10
16
  name: 'DBlockSourceCode'
@@ -38,15 +44,18 @@ const props = defineProps({
38
44
  })
39
45
 
40
46
  const $q = useQuasar()
41
- const store = useStore()
47
+ const route = useRoute()
48
+ const router = useRouter()
49
+ const { anchor: scrollToAnchor } = useNavigator()
42
50
 
43
51
  const copyBtnDisabled = ref(false)
44
52
  const copyBtnColor = ref(null)
45
53
  const copyBtnIcon = ref('content_copy')
46
54
  const codeRef = ref(null)
47
55
  const activeTab = ref(0)
56
+ const lineAnchorTopOffset = 34
57
+ const lineAnchorScrollRetryDelay = 500
48
58
 
49
- const href = computed(() => `${store.state.page.absolute}#${anchor.value}`)
50
59
  const coloring = computed(() => $q.dark.isActive ? 'dark' : 'white')
51
60
  const anchor = computed(() => printToLetter(props.index + 1))
52
61
 
@@ -155,6 +164,49 @@ function copyCode() {
155
164
  }
156
165
  }
157
166
 
167
+ function buildLineAnchorId(line) {
168
+ return buildSourceCodeLineAnchorId(anchor.value, line)
169
+ }
170
+
171
+ function buildLineHref(line) {
172
+ return resolveSourceCodeLineHref(router, route.path, route.query, buildLineAnchorId(line))
173
+ }
174
+
175
+ function scrollToLineAnchor(hash) {
176
+ scrollToAnchor(hash, false)
177
+ window.setTimeout(() => {
178
+ scrollToAnchor(hash, false)
179
+ }, lineAnchorScrollRetryDelay)
180
+ }
181
+
182
+ async function navigateToLineAnchor(event, line) {
183
+ if (!shouldHandleSourceCodeLineActivation(event)) {
184
+ return
185
+ }
186
+
187
+ const hash = `#${buildLineAnchorId(line)}`
188
+
189
+ event.preventDefault()
190
+
191
+ if (route.hash === hash) {
192
+ scrollToLineAnchor(hash)
193
+ return
194
+ }
195
+
196
+ window.setTimeout(() => {
197
+ scrollToAnchor(hash, false)
198
+ }, lineAnchorScrollRetryDelay)
199
+
200
+ await router.push({
201
+ path: route.path,
202
+ query: route.query,
203
+ hash
204
+ })
205
+
206
+ await nextTick()
207
+ scrollToLineAnchor(hash)
208
+ }
209
+
158
210
  function printToLetter(number) {
159
211
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
160
212
  let result = ''
@@ -245,9 +297,9 @@ function printToLetter(number) {
245
297
  <div class="code" :class="coloring">
246
298
  <div class="lines" v-if="lines && lines > 1">
247
299
  <template v-for="(line, index) in lines" :key="index">
248
- <a class="line" :href="href+line">
300
+ <a class="line" :href="buildLineHref(line)" @click="navigateToLineAnchor($event, line)">
249
301
  <i class="fa fa-link" aria-hidden="true" data-hidden="true"></i>
250
- <span :id="`${anchor}${line}`">{{ line }}</span>
302
+ <span :id="buildLineAnchorId(line)" :data-anchor-offset-top="lineAnchorTopOffset">{{ line }}</span>
251
303
  </a>
252
304
  </template>
253
305
  </div>
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <footer class="d-footer" role="contentinfo">
3
+ <div class="d-footer__content">
4
+ <span class="d-footer__label">Powered by</span>
5
+ <span class="d-footer__brand">Docsector</span>
6
+ </div>
7
+ </footer>
8
+ </template>
9
+
10
+ <script setup>
11
+ defineOptions({ name: 'DFooter' })
12
+ </script>
13
+
14
+ <style lang="sass">
15
+ .d-footer
16
+ width: 100%
17
+ background: var(--q-primary, #655529)
18
+ color: #ffffff
19
+
20
+ .d-footer__content
21
+ max-width: 1200px
22
+ margin: 0 auto
23
+ padding: 14px 24px
24
+ display: flex
25
+ align-items: center
26
+ justify-content: center
27
+ gap: 6px
28
+ text-align: center
29
+ font-size: 0.95rem
30
+ line-height: 1.4
31
+
32
+ .d-footer__label
33
+ opacity: 0.84
34
+
35
+ .d-footer__brand
36
+ font-weight: 700
37
+
38
+ @media (max-width: 599px)
39
+ .d-footer
40
+ .d-footer__content
41
+ padding: 12px 16px
42
+ font-size: 0.9rem
43
+ </style>
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <teleport v-if="ready && siteFooterOutletElement" :to="siteFooterOutletElement">
3
+ <d-footer />
4
+ </teleport>
5
+
6
+ <d-footer v-else-if="ready" />
7
+ </template>
8
+
9
+ <script setup>
10
+ import { onMounted, ref } from 'vue'
11
+
12
+ import DFooter from './DFooter.vue'
13
+ import { siteFooterOutletElement } from '../composables/site-footer-outlet'
14
+
15
+ defineOptions({ name: 'DFooterHost' })
16
+
17
+ const ready = ref(false)
18
+
19
+ onMounted(() => {
20
+ ready.value = true
21
+ })
22
+ </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <div ref="outlet" class="d-footer-outlet"></div>
3
+ </template>
4
+
5
+ <script setup>
6
+ import { onMounted, onBeforeUnmount, ref } from 'vue'
7
+
8
+ import { registerSiteFooterOutlet, unregisterSiteFooterOutlet } from '../composables/site-footer-outlet'
9
+
10
+ defineOptions({ name: 'DFooterOutlet' })
11
+
12
+ const outlet = ref(null)
13
+
14
+ onMounted(() => {
15
+ registerSiteFooterOutlet(outlet.value)
16
+ })
17
+
18
+ onBeforeUnmount(() => {
19
+ unregisterSiteFooterOutlet(outlet.value)
20
+ })
21
+ </script>
22
+
23
+ <style lang="sass">
24
+ .d-footer-outlet
25
+ width: calc(100% + (2 * var(--d-footer-outlet-padding-x, 0px)))
26
+ margin-left: calc(-1 * var(--d-footer-outlet-padding-x, 0px))
27
+ margin-right: calc(-1 * var(--d-footer-outlet-padding-x, 0px))
28
+ margin-bottom: calc(-1 * var(--d-footer-outlet-padding-bottom, 0px))
29
+
30
+ > .d-footer
31
+ box-sizing: border-box
32
+ padding-bottom: var(--d-footer-outlet-padding-bottom, 0px)
33
+ </style>
@@ -8,11 +8,14 @@ import docsectorConfig from 'docsector.config.js'
8
8
 
9
9
  import useNavigator from '../composables/useNavigator'
10
10
  import { getReadingProgressState } from '../composables/useReadingProgress'
11
+ import { ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY } from '../composables/anchor-scroll-state'
11
12
  import { normalizeAiAssistantConfig } from '../ai-assistant/config'
12
13
  import { getAssistantRightRailState } from '../ai-assistant/layout'
14
+ import { resolveRoutePageLayout } from '../page-layout'
13
15
 
14
16
  import DPageAnchor from './DPageAnchor.vue'
15
17
  import DAssistantPanel from './DAssistantPanel.vue'
18
+ import DFooterOutlet from './DFooterOutlet.vue'
16
19
  import DPageMeta from './DPageMeta.vue'
17
20
 
18
21
  const store = useStore()
@@ -21,7 +24,7 @@ const route = useRoute()
21
24
  const $q = useQuasar()
22
25
  const { locale } = useI18n()
23
26
 
24
- const { scrolling, navigate } = useNavigator()
27
+ const { scrolling, navigate, anchor: scrollToAnchor } = useNavigator()
25
28
 
26
29
  const props = defineProps({
27
30
  disableNav: {
@@ -41,8 +44,14 @@ const pageMinHeight = ref('calc(100vh - 86px)')
41
44
  const submenuHeight = ref('36px')
42
45
  const pageBottomInset = ref('0px')
43
46
  const readingProgress = ref(getReadingProgressState())
47
+ const routeHashScrollTimeout = ref(null)
44
48
  const assistantConfig = normalizeAiAssistantConfig(docsectorConfig)
45
49
  const assistantEnabled = assistantConfig.enabled === true
50
+ const pageLayout = computed(() => resolveRoutePageLayout(route))
51
+ const showSubmenu = computed(() => pageLayout.value.submenu)
52
+ const showToc = computed(() => pageLayout.value.toc)
53
+ const showPageMeta = computed(() => !props.disableNav && pageLayout.value.footer)
54
+ const isFullwidthContent = computed(() => pageLayout.value.contentWidth === 'fullwidth')
46
55
 
47
56
  const getPageScrollContainer = () => {
48
57
  return pageScrollArea.value?.$el?.querySelector('.q-scrollarea__container') || null
@@ -67,19 +76,19 @@ const updatePageMinHeight = () => {
67
76
  const pageContainerEl = pageContainer.value?.$el || pageContainer.value
68
77
  const submenuEl = submenu.value?.$el || submenu.value
69
78
 
70
- if (!pageContainerEl || !submenuEl) {
79
+ if (!pageContainerEl) {
71
80
  return
72
81
  }
73
82
 
74
83
  const pageContainerStyles = window.getComputedStyle(pageContainerEl)
75
84
  const headerHeight = Number.parseFloat(pageContainerStyles.paddingTop) || 0
76
- const measuredSubmenuHeight = submenuEl.offsetHeight || 0
85
+ const measuredSubmenuHeight = showSubmenu.value ? (submenuEl?.offsetHeight || 0) : 0
77
86
  const isMobile = $q.screen.lt.md
78
- const totalOffset = Math.max(0, Math.round(headerHeight + (isMobile ? 0 : measuredSubmenuHeight)))
87
+ const totalOffset = Math.max(0, Math.round(headerHeight + (isMobile || !showSubmenu.value ? 0 : measuredSubmenuHeight)))
79
88
 
80
89
  pageMinHeight.value = `calc(100vh - ${totalOffset}px)`
81
- submenuHeight.value = `${Math.max(36, Math.round(measuredSubmenuHeight))}px`
82
- pageBottomInset.value = isMobile ? submenuHeight.value : '0px'
90
+ submenuHeight.value = `${showSubmenu.value ? Math.max(36, Math.round(measuredSubmenuHeight)) : 0}px`
91
+ pageBottomInset.value = isMobile && showSubmenu.value ? submenuHeight.value : '0px'
83
92
  syncReadingProgress()
84
93
  }
85
94
 
@@ -91,6 +100,22 @@ const schedulePageMinHeightUpdate = () => {
91
100
  })
92
101
  }
93
102
 
103
+ const scheduleRouteHashScroll = () => {
104
+ if (routeHashScrollTimeout.value) {
105
+ clearTimeout(routeHashScrollTimeout.value)
106
+ routeHashScrollTimeout.value = null
107
+ }
108
+
109
+ if (showToc.value || !route.hash) {
110
+ return
111
+ }
112
+
113
+ routeHashScrollTimeout.value = setTimeout(() => {
114
+ scrollToAnchor(route.hash, false)
115
+ routeHashScrollTimeout.value = null
116
+ }, 500)
117
+ }
118
+
94
119
  const overview = computed(() => route.matched[0].path)
95
120
  const showcase = computed(() => {
96
121
  const showcase = route.matched[0].meta.subpages.showcase
@@ -123,7 +148,7 @@ const shouldShowBackToTopControl = computed(() => {
123
148
  return props.showBackToTopControl && readingProgress.value.hasOverflow && readingProgress.value.isVisible
124
149
  })
125
150
  const rightRailState = computed(() => getAssistantRightRailState({
126
- tocOpen: layoutMeta.value,
151
+ tocOpen: showToc.value && layoutMeta.value,
127
152
  assistantOpen: assistantEnabled && layoutAssistant.value,
128
153
  screenWidth: $q.screen.width,
129
154
  assistantWidth: assistantWidth.value,
@@ -137,8 +162,8 @@ const mobileAssistantOpen = computed({
137
162
  set: (value) => { layoutAssistant.value = value }
138
163
  })
139
164
  const mobileTocOpen = computed({
140
- get: () => rightRailState.value.isMobile && layoutMeta.value,
141
- set: (value) => { layoutMeta.value = value }
165
+ get: () => showToc.value && rightRailState.value.isMobile && layoutMeta.value,
166
+ set: (value) => { layoutMeta.value = showToc.value && value }
142
167
  })
143
168
  const backToTopRightOffset = computed(() => {
144
169
  return rightRailState.value.backToTopRightOffset
@@ -157,6 +182,7 @@ const currentPageTitle = computed(() => {
157
182
  })
158
183
 
159
184
  const toggleSectionsTree = () => {
185
+ if (!showToc.value) return
160
186
  layoutMeta.value = !layoutMeta.value
161
187
  }
162
188
 
@@ -218,6 +244,8 @@ const subroute = (to) => {
218
244
  }
219
245
 
220
246
  const resetPageScroll = () => {
247
+ getPageScrollContainer()?.style.removeProperty(ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY)
248
+
221
249
  if (pageScrollArea.value !== null) {
222
250
  pageScrollArea.value.setScrollPosition('vertical', 0, 0)
223
251
  }
@@ -331,12 +359,18 @@ onMounted(() => {
331
359
  window.addEventListener('resize', schedulePageMinHeightUpdate)
332
360
  nextTick(() => {
333
361
  schedulePageMinHeightUpdate()
362
+ scheduleRouteHashScroll()
334
363
  })
335
364
 
336
365
  router.beforeEach((to, from, next) => {
337
- resetPageScroll()
366
+ const sameEffectivePage = isSameEffectivePage(from, to)
367
+ const hashOnlyNavigation = sameEffectivePage && to.hash !== ''
338
368
 
339
- if (to.hash === '' && !isSameEffectivePage(from, to)) {
369
+ if (!hashOnlyNavigation) {
370
+ resetPageScroll()
371
+ }
372
+
373
+ if (to.hash === '' && !sameEffectivePage) {
340
374
  store.commit('page/resetAnchor')
341
375
  store.commit('page/resetAnchors')
342
376
  store.commit('page/resetNodes')
@@ -349,12 +383,17 @@ onMounted(() => {
349
383
  onBeforeUnmount(() => {
350
384
  window.removeEventListener('keydown', handleMainScrollKeys)
351
385
  window.removeEventListener('resize', schedulePageMinHeightUpdate)
386
+
387
+ if (routeHashScrollTimeout.value) {
388
+ clearTimeout(routeHashScrollTimeout.value)
389
+ }
352
390
  })
353
391
 
354
392
  watch(() => route.fullPath, () => {
355
393
  nextTick(() => {
356
394
  schedulePageMinHeightUpdate()
357
395
  syncReadingProgress(0)
396
+ scheduleRouteHashScroll()
358
397
  })
359
398
  })
360
399
  </script>
@@ -368,8 +407,13 @@ watch(() => route.fullPath, () => {
368
407
  '--d-submenu-height': submenuHeight,
369
408
  '--d-page-bottom-inset': pageBottomInset
370
409
  }"
410
+ :class="[
411
+ `d-page-layout--${pageLayout.mode}`,
412
+ isFullwidthContent ? 'd-page-layout--fullwidth-content' : 'd-page-layout--contained-content'
413
+ ]"
371
414
  >
372
415
  <q-toolbar
416
+ v-if="showSubmenu"
373
417
  id="submenu"
374
418
  ref="submenu"
375
419
  class="bg-grey-8 text-white"
@@ -401,7 +445,7 @@ watch(() => route.fullPath, () => {
401
445
  />
402
446
  </q-btn-group>
403
447
  </q-toolbar-title>
404
- <q-btn class="d-submenu__toggle" :class="layoutMeta ? 'active' : null" @click="toggleSectionsTree" icon="account_tree">
448
+ <q-btn v-if="showToc" class="d-submenu__toggle" :class="layoutMeta ? 'active' : null" @click="toggleSectionsTree" icon="account_tree">
405
449
  <q-tooltip>{{ $t('page.edit.anchor') }}</q-tooltip>
406
450
  </q-btn>
407
451
  </div>
@@ -409,10 +453,11 @@ watch(() => route.fullPath, () => {
409
453
 
410
454
  <q-page id="page">
411
455
  <q-scroll-area class="content" :class="main" ref="pageScrollArea">
412
- <div id="scroll-container" @click="handleContentAnchorClick">
456
+ <div id="scroll-container" :class="isFullwidthContent ? 'd-scroll-container--fullwidth' : null" @click="handleContentAnchorClick">
413
457
  <slot />
414
458
  </div>
415
- <d-page-meta v-if="!disableNav" />
459
+ <d-page-meta v-if="showPageMeta" />
460
+ <d-footer-outlet />
416
461
  <q-scroll-observer @scroll="handlePageScroll" :debounce="300" />
417
462
  </q-scroll-area>
418
463
  </q-page>
@@ -456,10 +501,10 @@ watch(() => route.fullPath, () => {
456
501
  @update:model-value="setRightRailOpen"
457
502
  >
458
503
  <div class="d-right-rail">
459
- <div v-if="rightRailState.showToc" class="d-right-rail__toc" :style="{ width: `${rightRailState.tocWidth}px` }">
504
+ <div v-if="showToc && rightRailState.showToc" class="d-right-rail__toc" :style="{ width: `${rightRailState.tocWidth}px` }">
460
505
  <d-page-anchor id="anchor" />
461
506
  </div>
462
- <q-separator v-if="rightRailState.showToc && rightRailState.showAssistant" vertical />
507
+ <q-separator v-if="showToc && rightRailState.showToc && rightRailState.showAssistant" vertical />
463
508
  <d-assistant-panel
464
509
  v-if="rightRailState.showAssistant"
465
510
  class="d-right-rail__assistant"
@@ -475,6 +520,7 @@ watch(() => route.fullPath, () => {
475
520
  </q-drawer>
476
521
 
477
522
  <q-dialog
523
+ v-if="showToc"
478
524
  v-model="mobileTocOpen"
479
525
  position="right"
480
526
  square
@@ -519,12 +565,16 @@ watch(() => route.fullPath, () => {
519
565
  min-height: var(--d-page-min-height, calc(100vh - 86px))
520
566
 
521
567
  .content > div.scroll > div.q-scrollarea__content
568
+ --d-page-content-padding-x: 15px
569
+ --d-page-content-padding-bottom: calc(15px + var(--d-page-bottom-inset, 0px) + var(--d-anchor-scroll-extra-bottom, 0px) + env(safe-area-inset-bottom, 0px))
570
+ --d-footer-outlet-padding-x: var(--d-page-content-padding-x)
571
+ --d-footer-outlet-padding-bottom: var(--d-page-content-padding-bottom)
522
572
  max-width: 100%
523
573
  box-sizing: border-box
524
574
 
525
575
  .content:not(.no-padding) > div.scroll > div.q-scrollarea__content
526
- padding: 15px
527
- padding-bottom: calc(15px + var(--d-page-bottom-inset, 0px) + env(safe-area-inset-bottom, 0px))
576
+ padding: var(--d-page-content-padding-x)
577
+ padding-bottom: var(--d-page-content-padding-bottom)
528
578
 
529
579
  #page
530
580
  min-height: var(--d-page-min-height, calc(100vh - 86px)) !important
@@ -598,6 +648,17 @@ body.body--dark
598
648
  max-width: 1200px
599
649
  margin: auto
600
650
 
651
+ #page-container.d-page-layout--fullwidth-content
652
+ #scroll-container,
653
+ #d-page-meta
654
+ max-width: none
655
+
656
+ #d-page-meta > .row,
657
+ #d-page-meta > #d-page-nav
658
+ padding-left: 15px
659
+ padding-right: 15px
660
+ box-sizing: border-box
661
+
601
662
  #submenu
602
663
  min-height: 36px
603
664
  padding: 0
@@ -0,0 +1,15 @@
1
+ export function buildSourceCodeLineAnchorId(anchorPrefix, line) {
2
+ return `${anchorPrefix}${line}`
3
+ }
4
+
5
+ export function resolveSourceCodeLineHref(router, routePath, routeQuery, anchorId) {
6
+ return router.resolve({
7
+ path: routePath,
8
+ query: routeQuery,
9
+ hash: `#${anchorId}`
10
+ }).href
11
+ }
12
+
13
+ export function shouldHandleSourceCodeLineActivation(event) {
14
+ return !(event.defaultPrevented || event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
15
+ }
@@ -0,0 +1,35 @@
1
+ export const ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY = '--d-anchor-scroll-extra-bottom'
2
+
3
+ const toFiniteNumber = (value) => {
4
+ const normalized = Number(value)
5
+ return Number.isFinite(normalized) ? normalized : 0
6
+ }
7
+
8
+ export function getAnchorScrollMaxTop ({ scrollHeight = 0, clientHeight = 0 } = {}) {
9
+ return Math.max(0, toFiniteNumber(scrollHeight) - toFiniteNumber(clientHeight))
10
+ }
11
+
12
+ export function getAnchorScrollExtraBottom ({ offsetTop = 0, scrollHeight = 0, clientHeight = 0, currentExtraBottom = 0 } = {}) {
13
+ const baseScrollHeight = Math.max(0, toFiniteNumber(scrollHeight) - Math.max(0, toFiniteNumber(currentExtraBottom)))
14
+ const maxScrollTop = getAnchorScrollMaxTop({ scrollHeight: baseScrollHeight, clientHeight })
15
+ return Math.ceil(Math.max(0, toFiniteNumber(offsetTop) - maxScrollTop))
16
+ }
17
+
18
+ export function setAnchorScrollExtraBottom (target, value = 0) {
19
+ if (typeof HTMLElement === 'undefined') {
20
+ return
21
+ }
22
+
23
+ if (!(target instanceof HTMLElement)) {
24
+ return
25
+ }
26
+
27
+ const extraBottom = Math.max(0, Math.ceil(toFiniteNumber(value)))
28
+
29
+ if (extraBottom > 0) {
30
+ target.style.setProperty(ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY, `${extraBottom}px`)
31
+ return
32
+ }
33
+
34
+ target.style.removeProperty(ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY)
35
+ }
@@ -0,0 +1,15 @@
1
+ import { shallowRef } from 'vue'
2
+
3
+ export const siteFooterOutletElement = shallowRef(null)
4
+
5
+ export function registerSiteFooterOutlet (element) {
6
+ if (element instanceof HTMLElement) {
7
+ siteFooterOutletElement.value = element
8
+ }
9
+ }
10
+
11
+ export function unregisterSiteFooterOutlet (element) {
12
+ if (siteFooterOutletElement.value === element) {
13
+ siteFooterOutletElement.value = null
14
+ }
15
+ }
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
4
4
  import { ref } from 'vue'
5
5
 
6
6
  import { DEFAULT_ACTIVE_ANCHOR_OFFSET, getActiveAnchorId } from './useActiveAnchor'
7
+ import { ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY, getAnchorScrollExtraBottom, setAnchorScrollExtraBottom } from './anchor-scroll-state'
7
8
 
8
9
  export default function useNavigator() {
9
10
  const store = useStore()
@@ -11,6 +12,87 @@ export default function useNavigator() {
11
12
  const route = useRoute()
12
13
  const selected = ref(null)
13
14
 
15
+ const normalizeScrollTarget = (target) => {
16
+ if (target === document.body || target === document.documentElement) {
17
+ return window
18
+ }
19
+
20
+ return target
21
+ }
22
+
23
+ const getScrollTargetTop = (target) => {
24
+ if (target === window) {
25
+ return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
26
+ }
27
+
28
+ return target?.scrollTop || 0
29
+ }
30
+
31
+ const getScrollTargetRectTop = (target) => {
32
+ if (target === window) {
33
+ return 0
34
+ }
35
+
36
+ return target?.getBoundingClientRect?.().top || 0
37
+ }
38
+
39
+ const getAnchorTopOffset = (anchorEl) => {
40
+ if (!(anchorEl instanceof HTMLElement)) {
41
+ return 0
42
+ }
43
+
44
+ const explicitOffset = Number.parseFloat(anchorEl.dataset.anchorOffsetTop || '')
45
+
46
+ if (Number.isFinite(explicitOffset)) {
47
+ return Math.max(0, explicitOffset)
48
+ }
49
+
50
+ return 0
51
+ }
52
+
53
+ const syncAnchorScrollExtraBottom = (scrollTarget, offsetTop) => {
54
+ if (!(scrollTarget instanceof HTMLElement)) {
55
+ return
56
+ }
57
+
58
+ const extraBottom = getAnchorScrollExtraBottom({
59
+ offsetTop,
60
+ scrollHeight: scrollTarget.scrollHeight,
61
+ clientHeight: scrollTarget.clientHeight,
62
+ currentExtraBottom: Number.parseFloat(scrollTarget.style.getPropertyValue(ANCHOR_SCROLL_EXTRA_BOTTOM_PROPERTY) || '')
63
+ })
64
+
65
+ setAnchorScrollExtraBottom(scrollTarget, extraBottom)
66
+
67
+ if (extraBottom > 0) {
68
+ void scrollTarget.scrollHeight
69
+ }
70
+ }
71
+
72
+ const resolveAnchorScrollState = (anchorEl, { syncExtraBottom = true } = {}) => {
73
+ if (!(anchorEl instanceof HTMLElement)) {
74
+ return null
75
+ }
76
+
77
+ const pageScrollContainer = anchorEl.closest('.q-scrollarea__container')
78
+ const scrollTarget = normalizeScrollTarget(pageScrollContainer || scroll.getScrollTarget(anchorEl))
79
+ const anchorRect = anchorEl.getBoundingClientRect()
80
+ const scrollTop = getScrollTargetTop(scrollTarget)
81
+ const targetRectTop = getScrollTargetRectTop(scrollTarget)
82
+ const anchorTopOffset = getAnchorTopOffset(anchorEl)
83
+
84
+ const offsetTop = Math.max(0, ((anchorRect.top - targetRectTop) + scrollTop) - anchorTopOffset)
85
+
86
+ if (syncExtraBottom) {
87
+ syncAnchorScrollExtraBottom(scrollTarget, offsetTop)
88
+ }
89
+
90
+ return {
91
+ scrollTarget,
92
+ offsetTop
93
+ }
94
+ }
95
+
14
96
  const normalizeDomAnchorId = (id) => {
15
97
  if (id === null || id === undefined || id === false) {
16
98
  return ''
@@ -62,10 +144,11 @@ export default function useNavigator() {
62
144
  const Anchor = document.getElementById(anchorId)
63
145
 
64
146
  if (Anchor !== null && typeof Anchor === 'object') {
65
- const ScrollTarget = scroll.getScrollTarget(Anchor)
66
- const AnchorOffsetTop = Anchor.offsetTop
147
+ const scrollState = resolveAnchorScrollState(Anchor)
67
148
 
68
- scroll.setVerticalScrollPosition(ScrollTarget, AnchorOffsetTop, 300)
149
+ if (scrollState !== null) {
150
+ scroll.setVerticalScrollPosition(scrollState.scrollTarget, scrollState.offsetTop, 300)
151
+ }
69
152
 
70
153
  setTimeout(() => {
71
154
  store.commit('page/setScrolling', true)
@@ -98,7 +181,7 @@ export default function useNavigator() {
98
181
  const Anchor = document.getElementById(domAnchorId)
99
182
 
100
183
  if (Anchor !== null && typeof Anchor === 'object') {
101
- return Anchor.offsetTop
184
+ return resolveAnchorScrollState(Anchor, { syncExtraBottom: false })?.offsetTop
102
185
  }
103
186
 
104
187
  return undefined
package/src/index.js CHANGED
@@ -226,6 +226,7 @@ export function createDocsector (config = {}) {
226
226
 
227
227
  homePage: {
228
228
  source: 'local',
229
+ layout: 'default',
229
230
  remoteReadmeUrl: null,
230
231
  timeoutMs: 8000,
231
232
  fallbackToLocal: true,
@@ -237,10 +238,12 @@ export function createDocsector (config = {}) {
237
238
  /**
238
239
  * Define a Docsector book entry for the pages registry.
239
240
  *
240
- * @param {Object} config - Book configuration (id, label, icon, order, color)
241
+ * @param {Object} config - Book configuration (id, label, icon, order, color, layout)
241
242
  * @param {Object|string} [config.color] - Tab text color settings
242
243
  * @param {string} [config.color.active] - Active tab text color token (Quasar color key, CSS var, or CSS color)
243
244
  * @param {string} [config.color.inactive] - Inactive tab text color token (Quasar color key, CSS var, or CSS color)
245
+ * @param {string|Object} [config.layout] - Page layout preset for pages in the book (`default` or `fullwidth`)
246
+ * @param {string|Object} [config.layouts] - Alias for `layout`, useful when passing explicit layout flags
244
247
  * @returns {Object} Normalized book definition
245
248
  */
246
249
  export function defineBook (config = {}) {
@@ -251,7 +254,8 @@ export function defineBook (config = {}) {
251
254
  return {
252
255
  ...config,
253
256
  ...(resolvedId ? { id: resolvedId } : {}),
254
- ...(resolvedLabel ? { label: resolvedLabel } : {})
257
+ ...(resolvedLabel ? { label: resolvedLabel } : {}),
258
+ ...(config.layouts === undefined && config.layout !== undefined ? { layouts: config.layout } : {})
255
259
  }
256
260
  }
257
261
 
@@ -1,19 +1,36 @@
1
1
  <template>
2
2
  <q-layout view="lHh LpR lFf">
3
- <q-header class="d-header left-btn" elevated>
3
+ <q-header class="d-header" :class="showSidebar ? 'left-btn' : 'd-header--no-sidebar'" elevated>
4
4
  <q-toolbar color="primary">
5
- <q-btn class="filled" square icon="menu" aria-label="Toggle Menu" @click="toogleMenu" />
6
- <q-toolbar-title class="text-center">
7
- <img
8
- v-if="branding.logo"
9
- :src="branding.logo"
10
- :alt="branding.name"
11
- height="26"
12
- style="vertical-align: middle;"
13
- class="q-mr-sm"
14
- />
15
- <q-icon class="q-mb-xs q-mr-sm" :name="headerTitleIcon" />
16
- {{ headerTitleText }}
5
+ <q-btn v-if="showSidebar" class="filled" square icon="menu" aria-label="Toggle Menu" @click="toogleMenu" />
6
+ <q-toolbar-title
7
+ class="d-header__brand-slot row no-wrap items-stretch self-stretch q-pa-none"
8
+ :class="$q.screen.lt.sm ? 'justify-start' : 'justify-center'"
9
+ >
10
+ <q-btn
11
+ class="filled d-header__brand"
12
+ :class="$q.screen.lt.sm ? 'q-px-sm' : 'q-px-md'"
13
+ align="left"
14
+ no-caps
15
+ stretch
16
+ to="/"
17
+ :aria-label="brandAriaLabel"
18
+ >
19
+ <img
20
+ v-if="branding.logo"
21
+ :src="branding.logo"
22
+ :alt="brandName"
23
+ height="26"
24
+ class="d-header__brand-logo q-mr-sm"
25
+ />
26
+ <span class="d-header__brand-text col column justify-center no-wrap">
27
+ <span class="d-header__brand-name ellipsis text-left">{{ brandName }}</span>
28
+ <span
29
+ v-if="brandVersion"
30
+ class="d-header__brand-version text-caption ellipsis text-left"
31
+ >{{ brandVersion }}</span>
32
+ </span>
33
+ </q-btn>
17
34
  </q-toolbar-title>
18
35
  <q-btn
19
36
  v-if="assistantEnabled"
@@ -53,55 +70,58 @@
53
70
  </q-tabs>
54
71
  </q-header>
55
72
 
56
- <q-drawer elevated show-if-above side="left" v-model="layout.menu">
73
+ <q-drawer v-if="showSidebar" elevated show-if-above side="left" v-model="layout.menu">
57
74
  <d-menu />
58
75
  </q-drawer>
59
76
 
60
77
  <router-view />
78
+
79
+ <d-footer-host />
61
80
  </q-layout>
62
81
  </template>
63
82
 
64
83
  <script setup>
65
- import { ref, computed } from 'vue'
84
+ import { ref, computed, watch } from 'vue'
66
85
  import { useRoute, useRouter } from 'vue-router'
67
86
  import { useStore } from 'vuex'
68
87
  import { useI18n } from 'vue-i18n'
69
- import { useMeta, colors } from 'quasar'
88
+ import { useMeta, colors, useQuasar } from 'quasar'
70
89
 
71
90
  import DMenu from '../components/DMenu.vue'
91
+ import DFooterHost from '../components/DFooterHost.vue'
72
92
  import docsectorConfig from 'docsector.config.js'
73
93
  import { normalizeAiAssistantConfig } from '../ai-assistant/config'
74
94
  import { allBooks, booksByVersion } from 'virtual:docsector-books'
75
- import { pageTitleI18nPath } from '../i18n/path'
95
+ import { resolveRoutePageLayout } from '../page-layout'
76
96
 
77
97
  defineOptions({ name: 'LayoutDefault' })
78
98
 
79
99
  const branding = docsectorConfig.branding || {}
100
+ const brandName = branding.name || 'Docsector'
101
+ const brandVersion = typeof branding.version === 'string' ? branding.version.trim() : ''
102
+ const brandAriaLabel = `Open ${brandName} home`
80
103
  const assistantConfig = normalizeAiAssistantConfig(docsectorConfig)
81
104
  const assistantEnabled = assistantConfig.enabled === true
82
105
 
83
106
  const route = useRoute()
84
107
  const router = useRouter()
85
108
  const store = useStore()
109
+ const $q = useQuasar()
86
110
  const { t, locale } = useI18n()
87
111
 
88
112
  const layout = ref({
89
113
  menu: false
90
114
  })
91
115
 
116
+ const pageLayout = computed(() => resolveRoutePageLayout(route))
117
+ const showSidebar = computed(() => pageLayout.value.sidebar)
92
118
  const assistantOpen = computed(() => store.state.layout.assistant)
93
119
 
94
- const headerTitleIcon = computed(() => {
95
- return route.matched[0].meta.icon ?? route.meta.icon
96
- })
97
-
98
- const headerTitleText = computed(() => {
99
- if (store.state.i18n.base) {
100
- return t(pageTitleI18nPath(store.state.i18n.base))
101
- } else {
102
- return t(`menu.${route.matched[1].meta.menu}`)
120
+ watch(showSidebar, (value) => {
121
+ if (!value) {
122
+ layout.value.menu = false
103
123
  }
104
- })
124
+ }, { immediate: true })
105
125
 
106
126
  const defaultBookTabColors = Object.freeze({
107
127
  active: 'white',
@@ -362,6 +382,24 @@ store.commit('page/resetAnchors')
362
382
  &.left-btn
363
383
  .q-toolbar
364
384
  padding: 0
385
+ .d-header__brand
386
+ min-width: 0
387
+ max-width: 100%
388
+ .d-header__brand-logo
389
+ display: block
390
+ flex-shrink: 0
391
+ .d-header__brand-text
392
+ min-width: 0
393
+ line-height: 1
394
+ .d-header__brand-name
395
+ display: block
396
+ line-height: 0.95rem
397
+ .d-header__brand-version
398
+ display: block
399
+ margin-top: -2px
400
+ font-size: 10px
401
+ line-height: 0.75rem
402
+ opacity: 0.8
365
403
  .q-tabs
366
404
  margin-top: 2px
367
405
  .d-book-tabs-separator
@@ -1,5 +1,12 @@
1
1
  <template>
2
2
  <q-layout view="lHh lpR lFr">
3
3
  <router-view />
4
+ <d-footer-host />
4
5
  </q-layout>
5
6
  </template>
7
+
8
+ <script setup>
9
+ import DFooterHost from '../components/DFooterHost.vue'
10
+
11
+ defineOptions({ name: 'LayoutSystem' })
12
+ </script>
@@ -0,0 +1,156 @@
1
+ export const PAGE_LAYOUT_DEFAULT = 'default'
2
+ export const PAGE_LAYOUT_FULLWIDTH = 'fullwidth'
3
+
4
+ const PAGE_LAYOUT_PRESETS = Object.freeze({
5
+ [PAGE_LAYOUT_DEFAULT]: Object.freeze({
6
+ mode: PAGE_LAYOUT_DEFAULT,
7
+ sidebar: true,
8
+ submenu: true,
9
+ toc: true,
10
+ footer: true,
11
+ contentWidth: 'contained'
12
+ }),
13
+ [PAGE_LAYOUT_FULLWIDTH]: Object.freeze({
14
+ mode: PAGE_LAYOUT_FULLWIDTH,
15
+ sidebar: false,
16
+ submenu: false,
17
+ toc: false,
18
+ footer: true,
19
+ contentWidth: 'fullwidth'
20
+ })
21
+ })
22
+
23
+ export const HOME_PAGE_FULLWIDTH_LAYOUT = Object.freeze({
24
+ ...PAGE_LAYOUT_PRESETS[PAGE_LAYOUT_FULLWIDTH],
25
+ footer: false
26
+ })
27
+
28
+ const normalizeLayoutMode = (value) => {
29
+ const normalized = String(value || '')
30
+ .trim()
31
+ .toLowerCase()
32
+ .replace(/[\s_-]+/g, '-')
33
+
34
+ if (normalized === PAGE_LAYOUT_FULLWIDTH) {
35
+ return PAGE_LAYOUT_FULLWIDTH
36
+ }
37
+
38
+ return PAGE_LAYOUT_DEFAULT
39
+ }
40
+
41
+ const normalizeBoolean = (value, fallback) => {
42
+ if (typeof value === 'boolean') return value
43
+ if (value === undefined || value === null) return fallback
44
+
45
+ const normalized = String(value).trim().toLowerCase()
46
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true
47
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false
48
+
49
+ return fallback
50
+ }
51
+
52
+ const firstDefined = (source, keys) => {
53
+ for (const key of keys) {
54
+ if (source?.[key] !== undefined) {
55
+ return source[key]
56
+ }
57
+ }
58
+
59
+ return undefined
60
+ }
61
+
62
+ const normalizeContentWidth = (value, fallback) => {
63
+ if (value === undefined || value === null) return fallback
64
+
65
+ const normalized = String(value).trim().toLowerCase().replace(/[\s_-]+/g, '-')
66
+ if (['full', 'fullwidth', 'full-width', 'wide', 'fluid'].includes(normalized)) {
67
+ return 'fullwidth'
68
+ }
69
+
70
+ return 'contained'
71
+ }
72
+
73
+ const toLayoutPatch = (source) => {
74
+ if (typeof source === 'string') {
75
+ return { mode: normalizeLayoutMode(source) }
76
+ }
77
+
78
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
79
+ return null
80
+ }
81
+
82
+ const patch = {}
83
+ const mode = firstDefined(source, ['mode', 'layout', 'preset', 'name', 'type'])
84
+ if (mode !== undefined) {
85
+ patch.mode = normalizeLayoutMode(mode)
86
+ }
87
+
88
+ const sidebar = firstDefined(source, ['sidebar', 'menu', 'left'])
89
+ const submenu = firstDefined(source, ['submenu', 'subpage', 'subpageBar'])
90
+ const toc = firstDefined(source, ['toc', 'tableOfContents', 'anchors', 'meta'])
91
+ const footer = firstDefined(source, ['footer', 'nav', 'navigationFooter'])
92
+ const contentWidth = firstDefined(source, ['contentWidth', 'width'])
93
+
94
+ if (sidebar !== undefined) patch.sidebar = normalizeBoolean(sidebar, true)
95
+ if (submenu !== undefined) patch.submenu = normalizeBoolean(submenu, true)
96
+ if (toc !== undefined) patch.toc = normalizeBoolean(toc, true)
97
+ if (footer !== undefined) patch.footer = normalizeBoolean(footer, true)
98
+ if (contentWidth !== undefined) patch.contentWidth = normalizeContentWidth(contentWidth, 'contained')
99
+
100
+ return patch
101
+ }
102
+
103
+ export function resolvePageLayout (...sources) {
104
+ let layout = { ...PAGE_LAYOUT_PRESETS[PAGE_LAYOUT_DEFAULT] }
105
+
106
+ for (const source of sources) {
107
+ const patch = toLayoutPatch(source)
108
+ if (!patch) continue
109
+
110
+ if (patch.mode) {
111
+ layout = { ...PAGE_LAYOUT_PRESETS[patch.mode] }
112
+ }
113
+
114
+ layout = {
115
+ ...layout,
116
+ ...patch,
117
+ mode: patch.mode || layout.mode,
118
+ contentWidth: normalizeContentWidth(patch.contentWidth, layout.contentWidth)
119
+ }
120
+ }
121
+
122
+ return layout
123
+ }
124
+
125
+ export function isHomeRoute (route = {}) {
126
+ const meta = route?.meta || route?.matched?.[0]?.meta || {}
127
+ return meta.book === 'home' || meta.type === 'home'
128
+ }
129
+
130
+ export function resolveHomePageLayout (homePage = {}) {
131
+ const mode = normalizeLayoutMode(homePage?.layout)
132
+
133
+ if (mode === PAGE_LAYOUT_FULLWIDTH) {
134
+ return { ...HOME_PAGE_FULLWIDTH_LAYOUT }
135
+ }
136
+
137
+ return resolvePageLayout(PAGE_LAYOUT_DEFAULT)
138
+ }
139
+
140
+ export function resolveRoutePageLayout (route = {}) {
141
+ const sources = []
142
+
143
+ if (Array.isArray(route?.matched)) {
144
+ for (const record of route.matched) {
145
+ const meta = record?.meta || {}
146
+ if (meta.layout !== undefined) sources.push(meta.layout)
147
+ if (meta.layouts !== undefined) sources.push(meta.layouts)
148
+ }
149
+ }
150
+
151
+ const meta = route?.meta || {}
152
+ if (meta.layout !== undefined) sources.push(meta.layout)
153
+ if (meta.layouts !== undefined) sources.push(meta.layouts)
154
+
155
+ return resolvePageLayout(...sources)
156
+ }
@@ -9,7 +9,12 @@
9
9
  <strong>(404)</strong>
10
10
  </p>
11
11
  <q-btn color="secondary" style="width:200px;" @click="$router.push('/')">Go home</q-btn>
12
+ <d-footer-outlet />
12
13
  </q-scroll-area>
13
14
  </q-page>
14
15
  </q-page-container>
15
16
  </template>
17
+
18
+ <script setup>
19
+ import DFooterOutlet from 'components/DFooterOutlet.vue'
20
+ </script>
@@ -0,0 +1,49 @@
1
+ ## Overview
2
+
3
+ The Full book is a working example of a book-level layout opt-in. It uses `layout: 'fullwidth'` in `src/pages/full.book.js`, so every page registered in `src/pages/full.index.js` inherits the fullwidth page chrome.
4
+
5
+ This page should render with the global header and book tabs, but without the left sidebar, subpage toolbar, Table of Contents toggle, or Table of Contents rail.
6
+
7
+ ## Book Configuration
8
+
9
+ ```javascript
10
+ import { defineBook } from '../index.js'
11
+
12
+ export default defineBook({
13
+ id: 'full',
14
+ label: 'Full',
15
+ icon: 'fullscreen',
16
+ order: 3,
17
+ layout: 'fullwidth'
18
+ })
19
+ ```
20
+
21
+ The `layout` field accepts two values:
22
+
23
+ - `default` keeps the standard documentation chrome.
24
+ - `fullwidth` keeps the global header and book tabs, then removes the book sidebar, subpage toolbar, and Table of Contents for pages in that book.
25
+
26
+ ## When to Use It
27
+
28
+ Use a fullwidth book when a section needs more freedom than a reference page, such as:
29
+
30
+ - Product overview pages
31
+ - Landing-style documentation sections
32
+ - Rich Vue-driven pages
33
+ - Visual guides with wide screenshots or diagrams
34
+ - Documentation experiences that should not be constrained by a sidebar and Table of Contents
35
+
36
+ ## Homepage Comparison
37
+
38
+ Homepage fullwidth is configured separately in `docsector.config.js`:
39
+
40
+ ```javascript
41
+ export default {
42
+ homePage: {
43
+ source: 'local',
44
+ layout: 'fullwidth'
45
+ }
46
+ }
47
+ ```
48
+
49
+ That setting is opt-in for the homepage only. A book-level `layout: 'fullwidth'` is opt-in for every page in that book.
@@ -0,0 +1,49 @@
1
+ ## Visão Geral
2
+
3
+ O book Full é um exemplo funcional de opt-in de layout no nível do book. Ele usa `layout: 'fullwidth'` em `src/pages/full.book.js`, então toda página registrada em `src/pages/full.index.js` herda o chrome de página fullwidth.
4
+
5
+ Esta página deve renderizar com o header global e as abas de books, mas sem a sidebar esquerda, sem a toolbar de subpáginas, sem o botão de Table of Contents e sem a área lateral do Table of Contents.
6
+
7
+ ## Configuração do Book
8
+
9
+ ```javascript
10
+ import { defineBook } from '../index.js'
11
+
12
+ export default defineBook({
13
+ id: 'full',
14
+ label: 'Full',
15
+ icon: 'fullscreen',
16
+ order: 3,
17
+ layout: 'fullwidth'
18
+ })
19
+ ```
20
+
21
+ O campo `layout` aceita dois valores:
22
+
23
+ - `default` mantém o chrome padrão de documentação.
24
+ - `fullwidth` mantém o header global e as abas de books, depois remove a sidebar do book, a toolbar de subpáginas e o Table of Contents das páginas desse book.
25
+
26
+ ## Quando Usar
27
+
28
+ Use um book fullwidth quando uma seção precisa de mais liberdade do que uma página de referência, como:
29
+
30
+ - Páginas de visão geral de produto
31
+ - Seções de documentação com estilo de landing page
32
+ - Páginas ricas movidas por Vue
33
+ - Guias visuais com screenshots ou diagramas largos
34
+ - Experiências de documentação que não devem ficar limitadas por uma sidebar e um Table of Contents
35
+
36
+ ## Comparação com a Homepage
37
+
38
+ O fullwidth da homepage é configurado separadamente em `docsector.config.js`:
39
+
40
+ ```javascript
41
+ export default {
42
+ homePage: {
43
+ source: 'local',
44
+ layout: 'fullwidth'
45
+ }
46
+ }
47
+ ```
48
+
49
+ Essa configuração é opt-in apenas para a homepage. Um `layout: 'fullwidth'` no nível do book é opt-in para todas as páginas desse book.
@@ -0,0 +1,13 @@
1
+ import { defineBook } from '../index.js'
2
+
3
+ export default defineBook({
4
+ id: 'full',
5
+ label: 'Full',
6
+ icon: 'fullscreen',
7
+ order: 3,
8
+ layout: 'fullwidth',
9
+ color: {
10
+ active: 'white',
11
+ inactive: 'white'
12
+ }
13
+ })
@@ -0,0 +1,36 @@
1
+ export default {
2
+ '/layout': {
3
+ config: {
4
+ icon: 'fullscreen',
5
+ status: 'new',
6
+ version: 'v4.5.0',
7
+ meta: {
8
+ description: {
9
+ 'en-US': 'Fullwidth Layout — Documentation of Docsector Reader',
10
+ 'pt-BR': 'Fullwidth Layout — Documentation of Docsector Reader'
11
+ }
12
+ },
13
+ book: 'full',
14
+ menu: {
15
+ header: {
16
+ icon: 'fullscreen',
17
+ label: 'Fullwidth'
18
+ }
19
+ },
20
+ subpages: {
21
+ showcase: false,
22
+ vs: false
23
+ }
24
+ },
25
+ data: {
26
+ 'en-US': { title: 'Fullwidth Layout' },
27
+ 'pt-BR': { title: 'Fullwidth Layout' }
28
+ },
29
+ metadata: {
30
+ tags: {
31
+ 'en-US': 'fullwidth layout book sidebar table of contents home page landing page custom docs',
32
+ 'pt-BR': 'fullwidth layout book sidebar table of contents home page landing page custom docs'
33
+ }
34
+ }
35
+ }
36
+ }
@@ -144,6 +144,7 @@ function getPagesRegistryFilesForRoot (root) {
144
144
  function normalizeBookConfig (rawConfig = {}, fallbackId = 'manual', index = 0) {
145
145
  const resolvedId = rawConfig.id || fallbackId || `book-${index + 1}`
146
146
  const label = rawConfig.label || (resolvedId.charAt(0).toUpperCase() + resolvedId.slice(1))
147
+ const layouts = rawConfig.layouts ?? rawConfig.layout
147
148
 
148
149
  return {
149
150
  ...rawConfig,
@@ -151,7 +152,8 @@ function normalizeBookConfig (rawConfig = {}, fallbackId = 'manual', index = 0)
151
152
  label,
152
153
  icon: rawConfig.icon || 'menu_book',
153
154
  order: rawConfig.order ?? (index + 1),
154
- color: normalizeBookColorConfig(rawConfig.color)
155
+ color: normalizeBookColorConfig(rawConfig.color),
156
+ ...(layouts !== undefined ? { layouts } : {})
155
157
  }
156
158
  }
157
159
 
@@ -704,6 +706,7 @@ export const booksByVersion = entries.reduce((accumulator, entry, index) => {
704
706
  icon: config.icon || 'menu_book',
705
707
  order: config.order ?? (index + 1),
706
708
  color: normalizeBookColor(config.color),
709
+ ...(config.layouts !== undefined || config.layout !== undefined ? { layouts: config.layouts ?? config.layout } : {}),
707
710
  version: version.id,
708
711
  versionPrefix: version.routePrefix
709
712
  }
@@ -1,5 +1,7 @@
1
1
  import { pageEntries, versions } from 'virtual:docsector-books'
2
2
  import boot from 'pages/boot'
3
+ import docsectorConfig from 'docsector.config.js'
4
+ import { resolveHomePageLayout, resolvePageLayout } from '../page-layout'
3
5
 
4
6
  const normalizeInternalLink = (linkTo) => {
5
7
  const normalized = String(linkTo || '').trim()
@@ -89,6 +91,7 @@ for (const entry of pageEntries || []) {
89
91
 
90
92
  const topPage = config.book ?? config.type ?? entry?.book ?? 'manual'
91
93
  const routePath = normalizeRoutePath(`${entry?.versionPrefix || ''}/${topPage}${path}`)
94
+ const layouts = resolvePageLayout(entry?.bookConfig?.layouts ?? entry?.bookConfig?.layout, config.layouts ?? config.layout)
92
95
  const hasInternalLink = typeof config?.link?.to === 'string' && config.link.to.trim().length > 0
93
96
  const internalLinkTo = hasInternalLink ? normalizeVersionedInternalLink(config.link.to, entry?.versionPrefix || '') : null
94
97
  const linkedConfig = hasInternalLink
@@ -167,6 +170,7 @@ for (const entry of pageEntries || []) {
167
170
  sourceRoot: entry.sourceRoot || '',
168
171
  sourcePathBase: buildSourcePathBase(entry, topPage, path),
169
172
  pagePath: path,
173
+ layouts,
170
174
  i18nSegments: entry.i18nSegments || [topPage, ...String(path).replace(/^\//, '').split('/').filter(Boolean)],
171
175
  menuGroupPath: String(path).replace(/^\//, '').split('/').filter(Boolean)[0] || '',
172
176
  unversionedPath: entry.unversionedPath || `/${topPage}${path}`
@@ -201,10 +205,7 @@ const routes = [
201
205
  }
202
206
  },
203
207
  meta: boot.meta,
204
- layouts: {
205
- footer: false,
206
- submenu: false
207
- },
208
+ layouts: resolveHomePageLayout(docsectorConfig.homePage),
208
209
  pages: {}
209
210
  },
210
211
  children: [