@docsector/docsector-reader 1.7.0 → 2.0.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.
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
2
+ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
3
3
  import { useStore } from 'vuex'
4
4
  import { useRoute, useRouter } from 'vue-router'
5
5
  import { useQuasar } from 'quasar'
@@ -24,6 +24,38 @@ const props = defineProps({
24
24
  })
25
25
 
26
26
  const pageScrollArea = ref(null)
27
+ const pageContainer = ref(null)
28
+ const submenu = ref(null)
29
+ const pageMinHeight = ref('calc(100vh - 86px)')
30
+ const submenuHeight = ref('36px')
31
+ const pageBottomInset = ref('0px')
32
+
33
+ const updatePageMinHeight = () => {
34
+ const pageContainerEl = pageContainer.value?.$el || pageContainer.value
35
+ const submenuEl = submenu.value?.$el || submenu.value
36
+
37
+ if (!pageContainerEl || !submenuEl) {
38
+ return
39
+ }
40
+
41
+ const pageContainerStyles = window.getComputedStyle(pageContainerEl)
42
+ const headerHeight = Number.parseFloat(pageContainerStyles.paddingTop) || 0
43
+ const measuredSubmenuHeight = submenuEl.offsetHeight || 0
44
+ const isMobile = $q.screen.lt.md
45
+ const totalOffset = Math.max(0, Math.round(headerHeight + (isMobile ? 0 : measuredSubmenuHeight)))
46
+
47
+ pageMinHeight.value = `calc(100vh - ${totalOffset}px)`
48
+ submenuHeight.value = `${Math.max(36, Math.round(measuredSubmenuHeight))}px`
49
+ pageBottomInset.value = isMobile ? submenuHeight.value : '0px'
50
+ }
51
+
52
+ const schedulePageMinHeightUpdate = () => {
53
+ window.requestAnimationFrame(() => {
54
+ window.requestAnimationFrame(() => {
55
+ updatePageMinHeight()
56
+ })
57
+ })
58
+ }
27
59
 
28
60
  const overview = computed(() => route.matched[0].path)
29
61
  const showcase = computed(() => {
@@ -164,6 +196,10 @@ const handleMainScrollKeys = (event) => {
164
196
 
165
197
  onMounted(() => {
166
198
  window.addEventListener('keydown', handleMainScrollKeys)
199
+ window.addEventListener('resize', schedulePageMinHeightUpdate)
200
+ nextTick(() => {
201
+ schedulePageMinHeightUpdate()
202
+ })
167
203
 
168
204
  router.beforeEach((to, from, next) => {
169
205
  resetPageScroll()
@@ -180,38 +216,60 @@ onMounted(() => {
180
216
 
181
217
  onBeforeUnmount(() => {
182
218
  window.removeEventListener('keydown', handleMainScrollKeys)
219
+ window.removeEventListener('resize', schedulePageMinHeightUpdate)
220
+ })
221
+
222
+ watch(() => route.fullPath, () => {
223
+ nextTick(() => {
224
+ schedulePageMinHeightUpdate()
225
+ })
183
226
  })
184
227
  </script>
185
228
 
186
229
  <template>
187
- <q-page-container id="page-container">
188
- <q-toolbar id="submenu" class="bg-grey-8 text-white">
189
- <q-toolbar-title class="toolbar-container">
190
- <q-btn-group :class="$q.screen.lt.md ? 'mobile' : null">
191
- <q-btn
192
- v-if="overview && (showcase || vs)"
193
- no-caps flat
194
- :class="pActive('/overview')"
195
- :label="$t('submenu.overview')" icon="pageview"
196
- @click="subroute('/overview')"
197
- />
198
- <q-btn
199
- v-if="showcase"
200
- no-caps flat
201
- :class="pActive('/showcase')"
202
- :label="$t('submenu.showcase')" icon="play_circle_filled"
203
- @click="subroute('/showcase')"
204
- />
205
- <q-btn
206
- v-if="vs"
207
- no-caps flat
208
- :class="pActive('/vs')"
209
- :label="$t('submenu.versus')" icon="compare"
210
- @click="subroute('/vs')"
211
- />
212
- </q-btn-group>
213
- </q-toolbar-title>
214
- <q-btn @click="toggleSectionsTree" icon="account_tree" />
230
+ <q-page-container
231
+ id="page-container"
232
+ ref="pageContainer"
233
+ :style="{
234
+ '--d-page-min-height': pageMinHeight,
235
+ '--d-submenu-height': submenuHeight,
236
+ '--d-page-bottom-inset': pageBottomInset
237
+ }"
238
+ >
239
+ <q-toolbar
240
+ id="submenu"
241
+ ref="submenu"
242
+ class="bg-grey-8 text-white"
243
+ :class="$q.screen.lt.md ? 'd-submenu--mobile' : 'd-submenu--desktop'"
244
+ >
245
+ <div class="d-submenu__content">
246
+ <q-toolbar-title class="toolbar-container">
247
+ <q-btn-group :class="$q.screen.lt.md ? 'mobile' : null">
248
+ <q-btn
249
+ v-if="overview && (showcase || vs)"
250
+ no-caps flat
251
+ :class="pActive('/overview')"
252
+ :label="$t('submenu.overview')" icon="pageview"
253
+ @click="subroute('/overview')"
254
+ />
255
+ <q-btn
256
+ v-if="showcase"
257
+ no-caps flat
258
+ :class="pActive('/showcase')"
259
+ :label="$t('submenu.showcase')" icon="play_circle_filled"
260
+ @click="subroute('/showcase')"
261
+ />
262
+ <q-btn
263
+ v-if="vs"
264
+ no-caps flat
265
+ :class="pActive('/vs')"
266
+ :label="$t('submenu.versus')" icon="compare"
267
+ @click="subroute('/vs')"
268
+ />
269
+ </q-btn-group>
270
+ </q-toolbar-title>
271
+ <q-btn class="d-submenu__toggle" @click="toggleSectionsTree" icon="account_tree" />
272
+ </div>
215
273
  </q-toolbar>
216
274
 
217
275
  <q-page id="page">
@@ -236,7 +294,7 @@ onBeforeUnmount(() => {
236
294
 
237
295
  .content,
238
296
  .content > div.scroll
239
- min-height: calc(100vh - 86px)
297
+ min-height: var(--d-page-min-height, calc(100vh - 86px))
240
298
 
241
299
  .content > div.scroll > div.q-scrollarea__content
242
300
  max-width: 100%
@@ -244,9 +302,10 @@ onBeforeUnmount(() => {
244
302
 
245
303
  .content:not(.no-padding) > div.scroll > div.q-scrollarea__content
246
304
  padding: 15px
305
+ padding-bottom: calc(15px + var(--d-page-bottom-inset, 0px) + env(safe-area-inset-bottom, 0px))
247
306
 
248
307
  #page
249
- min-height: calc(100vh - 86px) !important
308
+ min-height: var(--d-page-min-height, calc(100vh - 86px)) !important
250
309
 
251
310
  #scroll-container
252
311
  width: 100%
@@ -259,10 +318,23 @@ onBeforeUnmount(() => {
259
318
  box-shadow: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px rgba(0,0,0,0.14), 0 1px 6px rgba(0,0,0,0.12)
260
319
  overflow: visible
261
320
 
321
+ .d-submenu__content
322
+ width: calc(100% - 30px)
323
+ max-width: 1200px
324
+ min-height: inherit
325
+ margin: 0 auto
326
+ display: flex
327
+ align-items: center
328
+ align-self: stretch
329
+
330
+ .d-submenu__toggle
331
+ flex: 0 0 auto
332
+
262
333
  .on-left
263
334
  margin-right: 5px
264
335
  .toolbar-container
265
336
  overflow: visible
337
+ padding: 0
266
338
  .q-btn-group
267
339
  box-shadow: none
268
340
  &.mobile
@@ -276,6 +348,19 @@ onBeforeUnmount(() => {
276
348
  &:not(.focus-helper)
277
349
  margin-left: 6px
278
350
 
351
+ #submenu.d-submenu--mobile
352
+ position: fixed
353
+ left: 0
354
+ right: 0
355
+ bottom: 0
356
+ z-index: 1500
357
+ min-height: 40px
358
+ padding-bottom: env(safe-area-inset-bottom, 0px)
359
+ box-shadow: 0 -1px 2px rgba(0,0,0,0.12), 0 -2px 6px rgba(0,0,0,0.08)
360
+
361
+ .d-submenu__content
362
+ align-items: flex-end
363
+
279
364
  #submenu a,
280
365
  #submenu button
281
366
  border-radius: 0
@@ -289,6 +374,10 @@ body.body--light
289
374
  background-color: #fff !important
290
375
  color: #000
291
376
  box-shadow: 0 10px 0 0 #fff
377
+
378
+ #submenu.d-submenu--mobile a.active,
379
+ #submenu.d-submenu--mobile button.active
380
+ box-shadow: 0 -10px 0 0 #fff
292
381
  // Dark
293
382
  body.body--dark
294
383
  #submenu a.active,
@@ -297,6 +386,10 @@ body.body--dark
297
386
  color: #fff
298
387
  box-shadow: 0 10px 0 0 var(--q-dark-page)
299
388
 
389
+ #submenu.d-submenu--mobile a.active,
390
+ #submenu.d-submenu--mobile button.active
391
+ box-shadow: 0 -10px 0 0 var(--q-dark-page)
392
+
300
393
  body.mobile.body--dark
301
394
  .q-drawer--right
302
395
  background: rgba(18, 0, 0, 0.7)
@@ -2,13 +2,16 @@
2
2
  import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
3
3
  import { useStore } from 'vuex'
4
4
  import { useQuasar } from 'quasar'
5
+ import { useI18n } from 'vue-i18n'
5
6
  import { useRoute } from "vue-router";
6
7
 
7
8
  import useNavigator from '../composables/useNavigator'
9
+ import { pageTitleI18nPath } from '../i18n/path'
8
10
 
9
11
  const store = useStore()
10
12
  const $q = useQuasar()
11
13
  const route = useRoute()
14
+ const { t } = useI18n()
12
15
  const { navigate, anchor, selected: navigatorSelected } = useNavigator()
13
16
 
14
17
  const scrolling = ref(null)
@@ -47,6 +50,15 @@ const stylize = computed(() => {
47
50
  }
48
51
  })
49
52
 
53
+ const fallbackNodeLabel = computed(() => {
54
+ const base = store.state.i18n.base
55
+ if (!base) {
56
+ return ''
57
+ }
58
+
59
+ return t(pageTitleI18nPath(base))
60
+ })
61
+
50
62
  const scrollToActiveAnchor = () => {
51
63
  if (scrolling.value) {
52
64
  clearTimeout(scrolling.value)
@@ -109,7 +121,7 @@ onBeforeUnmount(() => {
109
121
  >
110
122
  <template v-slot:default-header="props">
111
123
  <b v-if="props.node.label" v-html="props.node.label"></b>
112
- <b v-else>{{ $t(`_.${$store.state.i18n.base}._`) }}</b>
124
+ <b v-else>{{ fallbackNodeLabel }}</b>
113
125
  </template>
114
126
  </q-tree>
115
127
  </template>
@@ -7,6 +7,7 @@ import { copyToClipboard, useQuasar } from 'quasar'
7
7
 
8
8
  import docsectorConfig from 'docsector.config.js'
9
9
  import gitDates from 'virtual:docsector-git-dates'
10
+ import { pageValueI18nPath } from '../i18n/path'
10
11
 
11
12
  const $q = useQuasar()
12
13
  const store = useStore()
@@ -59,7 +60,7 @@ const rawMarkdown = computed(() => {
59
60
  const absolute = store.state.i18n.absolute
60
61
  if (!absolute) return ''
61
62
 
62
- const source = t(`_.${absolute}.source`)
63
+ const source = t(pageValueI18nPath(absolute, 'source'))
63
64
  if (!source) return ''
64
65
 
65
66
  return String(source)
@@ -6,6 +6,7 @@ import { openURL } from 'quasar'
6
6
  import { useI18n } from 'vue-i18n'
7
7
 
8
8
  import docsectorConfig from 'docsector.config.js'
9
+ import { pageValueI18nPath, routeTitleI18nPath } from '../i18n/path'
9
10
 
10
11
  const store = useStore()
11
12
  const route = useRoute()
@@ -72,7 +73,7 @@ const progress = computed(() => {
72
73
  const currentLang = locale.value
73
74
 
74
75
  // Count headers (## and ###) in the default language source
75
- const defaultSourcePath = `_.${i18nPathAbsolute}.source`
76
+ const defaultSourcePath = pageValueI18nPath(i18nPathAbsolute, 'source')
76
77
  const defaultSource = te(defaultSourcePath, defaultLang) ? tm(defaultSourcePath, defaultLang) : ''
77
78
 
78
79
  if (!defaultSource || typeof defaultSource !== 'string') {
@@ -106,7 +107,7 @@ const progress = computed(() => {
106
107
 
107
108
  const languages = computed(() => {
108
109
  const i18nPathAbsolute = store.state.i18n.absolute
109
- const translations = `_.${i18nPathAbsolute}._translations`
110
+ const translations = pageValueI18nPath(i18nPathAbsolute, '_translations')
110
111
  const i18nLocales = availableLocales
111
112
  let fallbackLastUpdated = null
112
113
 
@@ -165,6 +166,10 @@ const hideRemoteHomeFooterMeta = computed(() => {
165
166
 
166
167
  return isRemoteHome && isHomePage
167
168
  })
169
+
170
+ const getRouteTitle = (path) => {
171
+ return t(routeTitleI18nPath(path))
172
+ }
168
173
  </script>
169
174
 
170
175
  <template>
@@ -197,11 +202,11 @@ const hideRemoteHomeFooterMeta = computed(() => {
197
202
  <router-link class="link col" v-if="prev" :to="`${prev}/overview/`">
198
203
  <div class="text-caption">{{ $t('page.nav.prev') }}</div>
199
204
  <q-icon name="navigate_before" />
200
- <span>{{ $t(`_${prev.replace(/_$/, '').replace(/\//g, '.')}._`) }}</span>
205
+ <span>{{ getRouteTitle(prev) }}</span>
201
206
  </router-link>
202
207
  <router-link class="link col" v-if="next" :to="`${next}/overview/`">
203
208
  <div class="text-caption">{{ $t('page.nav.next') }}</div>
204
- <span>{{ $t(`_${next.replace(/_$/, '').replace(/\//g, '.')}._`) }}</span>
209
+ <span>{{ getRouteTitle(next) }}</span>
205
210
  <q-icon name="navigate_next" />
206
211
  </router-link>
207
212
  </nav>
@@ -14,6 +14,7 @@ import DPageSourceCode from './DPageSourceCode.vue'
14
14
  import DMermaidDiagram from './DMermaidDiagram.vue'
15
15
  import DPageBlockquote from './DPageBlockquote.vue'
16
16
  import DQuickLinks from './DQuickLinks.vue'
17
+ import { pageValueI18nPath } from '../i18n/path'
17
18
 
18
19
  const props = defineProps({
19
20
  id: {
@@ -122,7 +123,7 @@ const tokenized = computed(() => {
122
123
  return []
123
124
  }
124
125
 
125
- const source = t(`_.${absolute}.source`)
126
+ const source = t(pageValueI18nPath(absolute, 'source'))
126
127
  const normalizedSource = String(source)
127
128
  .replace(/&#123;/g, '{')
128
129
  .replace(/&#125;/g, '}')
@@ -140,9 +141,11 @@ const tokenized = computed(() => {
140
141
  allowedAttributes: ['filename']
141
142
  })
142
143
 
143
- // Use a plain inline renderer to avoid markdown-it-attrs edge cases
144
- // when rendering isolated inline fragments.
145
- const MarkdownInline = new MarkdownIt()
144
+ // Keep inline rendering aligned with block parsing so raw HTML inline
145
+ // fragments (e.g. <b>, <a>) are rendered instead of escaped.
146
+ const MarkdownInline = new MarkdownIt({
147
+ html: true
148
+ })
146
149
 
147
150
  const markdownEnv = {}
148
151
 
@@ -326,6 +329,12 @@ const tokenized = computed(() => {
326
329
  })
327
330
  break
328
331
  }
332
+ case 'html_block':
333
+ tokens.push({
334
+ tag: 'html',
335
+ content: element.content
336
+ })
337
+ break
329
338
  }
330
339
  } else if (level === 1) {
331
340
  const parent = tokens[tokens.length - 1]
@@ -373,6 +382,10 @@ const tokenized = computed(() => {
373
382
  case 'inline':
374
383
  parent.content += element.content
375
384
  break
385
+ case 'html_inline':
386
+ case 'html_block':
387
+ parent.content += element.content
388
+ break
376
389
 
377
390
  case 'list_item_close':
378
391
  parent.content += '</li>'
@@ -453,6 +466,11 @@ const tokenized = computed(() => {
453
466
  <table v-html="token.content"></table>
454
467
  </div>
455
468
 
469
+ <div
470
+ v-else-if="token.tag === 'html'"
471
+ v-html="token.content"
472
+ ></div>
473
+
456
474
  <p
457
475
  v-else-if="token.tag === 'p'"
458
476
  v-html="token.content"
@@ -1,4 +1,5 @@
1
1
  import docsectorConfig from 'docsector.config.js'
2
+ import { pageValueI18nPath } from '../i18n/path'
2
3
 
3
4
  let activeCleanup = null
4
5
 
@@ -253,7 +254,7 @@ function createToolDefinitions ({
253
254
 
254
255
  let content = ''
255
256
  if (includeContent && absolute) {
256
- const source = translate(`_.${absolute}.source`)
257
+ const source = translate(pageValueI18nPath(absolute, 'source'))
257
258
  if (source) {
258
259
  content = decodeMarkdownSource(source)
259
260
  }
@@ -8,10 +8,10 @@
8
8
  *
9
9
  * import { buildMessages } from '@docsector/docsector-reader/i18n'
10
10
  * import boot from 'pages/boot'
11
- * import pages from 'pages'
11
+ * import { allPages as pages } from 'virtual:docsector-books'
12
12
  *
13
13
  * const langModules = import.meta.glob('./languages/*.hjson', { eager: true })
14
- * const mdModules = import.meta.glob('../pages/⁣**​/⁣*.md', { eager: true, query: '?raw', import: 'default' })
14
+ * const mdModules = import.meta.glob('all markdown files under ../pages recursively', { eager: true, query: '?raw', import: 'default' })
15
15
  *
16
16
  * export default buildMessages({ langModules, mdModules, pages, boot })
17
17
  */
@@ -113,14 +113,15 @@ export function filter (source) {
113
113
  *
114
114
  * @param {Object} options
115
115
  * @param {Object} options.langModules - Result of import.meta.glob('./languages/*.hjson', { eager: true })
116
- * @param {Object} options.mdModules - Result of import.meta.glob('../pages/**​/*.md', { eager: true, query: '?raw', import: 'default' })
117
- * @param {Object} options.pages - Page registry from pages/index.js
116
+ * @param {Object} options.mdModules - Result of recursively globbing markdown files under ../pages with eager raw imports
117
+ * @param {Object} [options.pages] - Legacy merged page registry from virtual:docsector-books (allPages)
118
+ * @param {Object} [options.books] - Book registry from virtual:docsector-books (preferred, avoids path collisions)
118
119
  * @param {Object} options.boot - Boot meta from pages/boot.js
119
120
  * @param {string[]} [options.langs] - Language codes to process (auto-detected from langModules if omitted)
120
121
  * @param {Object<string,string>} [options.homePageOverride] - Optional per-language Home markdown override
121
122
  * @returns {Object} Complete i18n messages object keyed by locale
122
123
  */
123
- export function buildMessages ({ langModules, mdModules, pages, boot, langs, homePageOverride = {} }) {
124
+ export function buildMessages ({ langModules, mdModules, pages, books, boot, langs, homePageOverride = {} }) {
124
125
  // Auto-detect languages from HJSON files if not provided
125
126
  if (!langs) {
126
127
  langs = Object.keys(langModules).map(key => {
@@ -199,6 +200,23 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs, hom
199
200
  return match[1].trim()
200
201
  }
201
202
 
203
+ const pageEntries = []
204
+
205
+ if (books && typeof books === 'object' && Object.keys(books).length > 0) {
206
+ for (const [bookId, book] of Object.entries(books)) {
207
+ const routes = book?.routes || {}
208
+ const fallbackBook = book?.config?.id || bookId || 'manual'
209
+
210
+ for (const [key, page] of Object.entries(routes)) {
211
+ pageEntries.push({ key, page, fallbackBook })
212
+ }
213
+ }
214
+ } else {
215
+ for (const [key, page] of Object.entries(pages || {})) {
216
+ pageEntries.push({ key, page, fallbackBook: null })
217
+ }
218
+ }
219
+
202
220
  // @ Iterate langs
203
221
  for (const lang of langs) {
204
222
  // Load HJSON language file
@@ -231,14 +249,18 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs, hom
231
249
  i18n[lang]._.home.overview.source = loadHomepage(lang)
232
250
 
233
251
  // @ Iterate pages
234
- for (const [key, page] of Object.entries(pages)) {
235
- const path = key.slice(1)
252
+ for (const entry of pageEntries) {
253
+ const { key, page, fallbackBook } = entry
254
+ const path = key.startsWith('/') ? key.slice(1) : key
236
255
 
237
256
  const config = page.config
238
257
  const data = page.data
239
258
  const meta = page.meta || boot.meta
240
259
 
241
- const topPage = config?.type ?? 'manual'
260
+ const topPage = config?.book ?? config?.type ?? fallbackBook ?? 'manual'
261
+ if (i18n[lang]._[topPage] === undefined) {
262
+ i18n[lang]._[topPage] = {}
263
+ }
242
264
 
243
265
  // ---
244
266
 
@@ -254,7 +276,7 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs, hom
254
276
  // @ Set metadata
255
277
  // title
256
278
  if (node._ === undefined) {
257
- node._ = data[lang]?.title || data['*']?.title
279
+ node._ = data?.[lang]?.title || data?.['*']?.title || data?.['en-US']?.title || ''
258
280
  }
259
281
 
260
282
  if (config === null) {
@@ -293,6 +315,11 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs, hom
293
315
  continue
294
316
  }
295
317
 
318
+ const hasInternalLink = typeof config?.link?.to === 'string' && config.link.to.trim().length > 0
319
+ if (hasInternalLink) {
320
+ continue
321
+ }
322
+
296
323
  // @ Subpages
297
324
  // Overview
298
325
  _.overview.source = load(topPage, path, 'overview', lang)
package/src/i18n/index.js CHANGED
@@ -9,6 +9,6 @@ const mdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?r
9
9
 
10
10
  // @ Import pages
11
11
  import boot from 'pages/boot'
12
- import pages from 'pages'
12
+ import { books } from 'virtual:docsector-books'
13
13
 
14
- export default buildMessages({ langModules, mdModules, pages, boot, homePageOverride })
14
+ export default buildMessages({ langModules, mdModules, books, boot, homePageOverride })
@@ -0,0 +1,101 @@
1
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
2
+
3
+ function escapeSegment (segment) {
4
+ return String(segment)
5
+ .replace(/\\/g, '\\\\')
6
+ .replace(/'/g, "\\'")
7
+ }
8
+
9
+ function normalizeSegments (segmentsInput, accumulator = []) {
10
+ for (const segment of segmentsInput) {
11
+ if (Array.isArray(segment)) {
12
+ normalizeSegments(segment, accumulator)
13
+ continue
14
+ }
15
+
16
+ if (segment === undefined || segment === null) {
17
+ continue
18
+ }
19
+
20
+ const normalized = String(segment).trim()
21
+ if (normalized.length === 0) {
22
+ continue
23
+ }
24
+
25
+ accumulator.push(normalized)
26
+ }
27
+
28
+ return accumulator
29
+ }
30
+
31
+ export function buildI18nPath (...segmentsInput) {
32
+ const segments = normalizeSegments(segmentsInput)
33
+ if (segments.length === 0) {
34
+ return ''
35
+ }
36
+
37
+ let path = ''
38
+
39
+ for (const [index, segment] of segments.entries()) {
40
+ if (index === 0) {
41
+ path += IDENTIFIER_PATTERN.test(segment)
42
+ ? segment
43
+ : `['${escapeSegment(segment)}']`
44
+ continue
45
+ }
46
+
47
+ path += IDENTIFIER_PATTERN.test(segment)
48
+ ? `.${segment}`
49
+ : `['${escapeSegment(segment)}']`
50
+ }
51
+
52
+ return path
53
+ }
54
+
55
+ export function splitRoutePathSegments (routePath) {
56
+ const normalized = String(routePath || '')
57
+ .replace(/\/+$/, '')
58
+ .replace(/^\//, '')
59
+
60
+ if (normalized.length === 0) {
61
+ return []
62
+ }
63
+
64
+ return normalized.split('/').filter(Boolean)
65
+ }
66
+
67
+ export function splitDotPathSegments (dotPath) {
68
+ const normalized = String(dotPath || '').trim()
69
+ if (normalized.length === 0) {
70
+ return []
71
+ }
72
+
73
+ return normalized.split('.').filter(Boolean)
74
+ }
75
+
76
+ export function routeTitleI18nPath (routePath) {
77
+ return buildI18nPath('_', ...splitRoutePathSegments(routePath), '_')
78
+ }
79
+
80
+ export function routeSubpageSourceI18nPath (routePath, subpage = 'overview') {
81
+ return buildI18nPath('_', ...splitRoutePathSegments(routePath), subpage, 'source')
82
+ }
83
+
84
+ export function pageTitleI18nPath (basePath) {
85
+ return buildI18nPath('_', ...splitDotPathSegments(basePath), '_')
86
+ }
87
+
88
+ export function pageValueI18nPath (absolutePath, key = 'source') {
89
+ return buildI18nPath('_', ...splitDotPathSegments(absolutePath), key)
90
+ }
91
+
92
+ export function namespacedLabelI18nPath (book, nodePath) {
93
+ const normalizedNodePath = String(nodePath || '').replace(/^\./, '')
94
+
95
+ return buildI18nPath(
96
+ '_',
97
+ String(book || 'manual'),
98
+ ...splitDotPathSegments(normalizedNodePath),
99
+ '_'
100
+ )
101
+ }
package/src/index.js CHANGED
@@ -234,11 +234,34 @@ export function createDocsector (config = {}) {
234
234
  }
235
235
  }
236
236
 
237
+ /**
238
+ * Define a Docsector book entry for the pages registry.
239
+ *
240
+ * @param {Object} config - Book configuration (id, label, icon, order, color)
241
+ * @param {Object|string} [config.color] - Tab text color settings
242
+ * @param {string} [config.color.active] - Active tab text color token (Quasar color key, CSS var, or CSS color)
243
+ * @param {string} [config.color.inactive] - Inactive tab text color token (Quasar color key, CSS var, or CSS color)
244
+ * @returns {Object} Normalized book definition
245
+ */
246
+ export function defineBook (config = {}) {
247
+ const resolvedId = typeof config.id === 'string' ? config.id : ''
248
+ const resolvedLabel = config.label
249
+ || (resolvedId ? `${resolvedId.charAt(0).toUpperCase()}${resolvedId.slice(1)}` : '')
250
+
251
+ return {
252
+ ...config,
253
+ ...(resolvedId ? { id: resolvedId } : {}),
254
+ ...(resolvedLabel ? { label: resolvedLabel } : {})
255
+ }
256
+ }
257
+
237
258
  /**
238
259
  * Define a Docsector page entry for the pages registry.
239
260
  *
240
261
  * @param {Object} options - Page options
241
- * @param {Object} options.config - Page configuration (icon, status, type, menu, subpages)
262
+ * @param {Object} options.config - Page configuration (icon, status, book, menu, subpages, link)
263
+ * @param {Object} [options.config.link] - Optional internal navigation link (menu shortcut)
264
+ * @param {string} options.config.link.to - Internal destination path (e.g. '/guide/getting-started/overview/')
242
265
  * @param {Object} options.data - Per-language titles { 'en-US': { title: '...' } }
243
266
  * @returns {Object} Page definition
244
267
  */
@@ -249,4 +272,4 @@ export function definePage (options) {
249
272
  }
250
273
  }
251
274
 
252
- export default { createDocsector, definePage }
275
+ export default { createDocsector, defineBook, definePage }