@docsector/docsector-reader 2.0.7 → 2.1.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
@@ -68,6 +68,8 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
68
68
  - 🧬 **Scaffolded Homepage Override Wiring** — New consumer projects automatically wire `virtual:docsector-homepage-override` into i18n message building
69
69
  - 🧭 **Quick Links Custom Element** — Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
70
70
  - 🗂️ **API Catalog Well-Known** — Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
71
+ - 🗃️ **Multi-Version History** — Archive older major versions under `src/pages/.old/<version>/` and expose them at prefixed routes (e.g. `/v0.x/guide/...`) while keeping the current docs at unprefixed routes
72
+ - 🏷️ **Version Selector Badges** — Every version in the sidebar selector displays a color-coded badge: green for released, orange for draft, red for deprecated; fully customizable via `badge: { label, color, textColor }`
71
73
  - ⚙️ **Single Config File** — Customize branding, links, and languages via `docsector.config.js`
72
74
 
73
75
  ---
@@ -675,7 +677,10 @@ export default {
675
677
  logo: '/images/logo/my-logo.png',
676
678
  name: 'My Project',
677
679
  version: 'v1.0.0',
678
- versions: ['v1.0.0', 'v0.9.0']
680
+ versions: [
681
+ { id: 'v1.0.0', current: true, released: false },
682
+ { id: 'v0.9.0', released: true, status: 'deprecated' }
683
+ ]
679
684
  },
680
685
 
681
686
  links: {
@@ -754,6 +759,10 @@ export default {
754
759
  }
755
760
  ```
756
761
 
762
+ The current version keeps the normal unprefixed routes such as `/guide/getting-started/overview/`. Archived major versions can be placed under `src/pages/.old/<version>/` with the same book/index/Markdown layout, and are exposed with a URL prefix such as `/v0.x/guide/getting-started/overview/`.
763
+
764
+ Every version shows a release badge in the selector. Released versions default to `released`; versions with `released: false` or `status: 'draft'` default to `draft`; versions with `status: 'deprecated'` or `deprecated: true` default to `deprecated` in red. Use `badge: { label, color, textColor }` when you need custom badge copy or colors.
765
+
757
766
  ### MCP (optional)
758
767
 
759
768
  ```javascript
@@ -777,15 +786,17 @@ import { buildMessages } from '@docsector/docsector-reader/i18n'
777
786
  import homePageOverride from 'virtual:docsector-homepage-override'
778
787
 
779
788
  const langModules = import.meta.glob('./languages/*.hjson', { eager: true })
780
- const mdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?raw', import: 'default' })
789
+ const currentMdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?raw', import: 'default' })
790
+ const oldMdModules = import.meta.glob('../pages/.old/**/*.md', { eager: true, query: '?raw', import: 'default' })
791
+ const mdModules = { ...currentMdModules, ...oldMdModules }
781
792
 
782
793
  import boot from 'pages/boot'
783
- import { books } from 'virtual:docsector-books'
794
+ import { books, pageEntries } from 'virtual:docsector-books'
784
795
 
785
- export default buildMessages({ langModules, mdModules, books, boot, homePageOverride })
796
+ export default buildMessages({ langModules, mdModules, books, pageEntries, boot, homePageOverride })
786
797
  ```
787
798
 
788
- > `books` is the preferred source because it preserves per-book registries and avoids path collisions when two books reuse the same route key.
799
+ > `pageEntries` is the preferred source because it preserves per-book and per-version registries and avoids path collisions when books or archived versions reuse the same route key.
789
800
 
790
801
  ### Language files
791
802
 
package/bin/docsector.js CHANGED
@@ -23,7 +23,7 @@ const packageRoot = resolve(__dirname, '..')
23
23
  const args = process.argv.slice(2)
24
24
  const command = args[0]
25
25
 
26
- const VERSION = '2.0.7'
26
+ const VERSION = '2.1.0'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
@@ -105,7 +105,10 @@ export default {
105
105
  branding: {
106
106
  logo: '/images/logo.png',
107
107
  name: 'My Documentation',
108
- version: 'v0.1.0'
108
+ version: 'v0.1.0',
109
+ versions: [
110
+ { id: 'v0.1.0', current: true, released: false }
111
+ ]
109
112
  },
110
113
 
111
114
  // @ Links
@@ -269,13 +272,15 @@ import homePageOverride from 'virtual:docsector-homepage-override'
269
272
  // @ Import language HJSON files (Vite-compatible eager import)
270
273
  const langModules = import.meta.glob('./languages/*.hjson', { eager: true })
271
274
  // @ Import markdown files (Vite-compatible eager import as raw strings)
272
- const mdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?raw', import: 'default' })
275
+ const currentMdModules = import.meta.glob('../pages/**/*.md', { eager: true, query: '?raw', import: 'default' })
276
+ const oldMdModules = import.meta.glob('../pages/.old/**/*.md', { eager: true, query: '?raw', import: 'default' })
277
+ const mdModules = { ...currentMdModules, ...oldMdModules }
273
278
 
274
279
  // @ Import pages
275
280
  import boot from 'pages/boot'
276
- import { allPages as pages } from 'virtual:docsector-books'
281
+ import { books, pageEntries } from 'virtual:docsector-books'
277
282
 
278
- export default buildMessages({ langModules, mdModules, pages, boot, homePageOverride })
283
+ export default buildMessages({ langModules, mdModules, books, pageEntries, boot, homePageOverride })
279
284
  `
280
285
 
281
286
  const TEMPLATE_I18N_HJSON = `\
@@ -15,7 +15,19 @@ export default {
15
15
  // Project name displayed in the sidebar
16
16
  name: 'Docsector Reader',
17
17
  // Version label displayed next to the name
18
- version: 'v' + pkg.version
18
+ version: 'v' + pkg.version,
19
+ versions: [
20
+ {
21
+ id: 'v' + pkg.version,
22
+ current: true,
23
+ released: false
24
+ },
25
+ {
26
+ id: 'v0.x',
27
+ released: true,
28
+ status: 'deprecated'
29
+ }
30
+ ]
19
31
  },
20
32
 
21
33
  // @ Links
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "2.0.7",
3
+ "version": "2.1.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",
@@ -7,7 +7,7 @@ import { useI18n } from 'vue-i18n'
7
7
  import tags from '@docsector/tags'
8
8
  import DMenuItem from './DMenuItem.vue'
9
9
  import docsectorConfig from 'docsector.config.js'
10
- import { allBooks } from 'virtual:docsector-books'
10
+ import { allBooks, booksByVersion, versions } from 'virtual:docsector-books'
11
11
  import { namespacedLabelI18nPath, routeSubpageSourceI18nPath } from '../i18n/path'
12
12
 
13
13
  const $q = useQuasar()
@@ -20,19 +20,93 @@ const links = docsectorConfig.links || {}
20
20
 
21
21
  const term = ref(null)
22
22
  const founds = ref(false)
23
- const version = ref(branding.version || 'v0.x')
24
- const versions = ref(branding.versions || ['v0.x'])
25
23
  const items = ref([])
26
24
  const scrolling = ref(null)
27
25
 
28
26
  const subpage = computed(() => {
29
27
  const parent = $route.matched[0]?.path
30
28
  const child = $route.matched[1]?.path
29
+ if (!parent || !child) return '/overview'
31
30
  return child.substring(parent.length)
32
31
  })
33
32
 
33
+ const activeVersionId = computed(() => {
34
+ return $route.matched?.[0]?.meta?.version ?? versions?.[0]?.id ?? ''
35
+ })
36
+
37
+ const activeBooks = computed(() => {
38
+ if (activeVersionId.value && booksByVersion?.[activeVersionId.value]?.allBooks) {
39
+ return booksByVersion[activeVersionId.value].allBooks
40
+ }
41
+
42
+ return allBooks || []
43
+ })
44
+
45
+ const draftReleaseStatuses = new Set(['draft', 'unreleased', 'preview', 'next'])
46
+
47
+ const normalizeVersionBadge = (item) => {
48
+ const configuredStatus = item.deprecated === true
49
+ ? 'deprecated'
50
+ : (item.releaseStatus || item.status)
51
+ const explicitlyReleased = item.released !== undefined ? item.released !== false : null
52
+ const released = configuredStatus === 'deprecated'
53
+ ? true
54
+ : (explicitlyReleased ?? !draftReleaseStatuses.has(String(configuredStatus || '').toLowerCase()))
55
+ const releaseStatus = configuredStatus || (released ? 'released' : 'draft')
56
+ const rawBadge = item.badge ?? item.releaseBadge
57
+ const deprecated = releaseStatus === 'deprecated'
58
+ const defaultColor = deprecated ? 'negative' : (released ? 'positive' : 'warning')
59
+ const defaultTextColor = (deprecated || released) ? 'white' : 'dark'
60
+
61
+ if (rawBadge === false || rawBadge === null) {
62
+ return { label: releaseStatus, color: defaultColor, textColor: defaultTextColor }
63
+ }
64
+
65
+ if (typeof rawBadge === 'string') {
66
+ return { label: rawBadge, color: defaultColor, textColor: defaultTextColor }
67
+ }
68
+
69
+ if (typeof rawBadge === 'object' && rawBadge !== null) {
70
+ const label = rawBadge.label || rawBadge.text || releaseStatus
71
+ if (!label) {
72
+ return null
73
+ }
74
+
75
+ return {
76
+ ...rawBadge,
77
+ label,
78
+ color: rawBadge.color || defaultColor,
79
+ textColor: rawBadge.textColor || defaultTextColor
80
+ }
81
+ }
82
+
83
+ return { label: releaseStatus, color: defaultColor, textColor: defaultTextColor }
84
+ }
85
+
86
+ const versionOptions = computed(() => {
87
+ return (versions || [])
88
+ .filter(item => item && item.id)
89
+ .map(item => ({
90
+ label: item.label || item.id,
91
+ value: item.id,
92
+ badge: normalizeVersionBadge(item),
93
+ released: item.released !== false,
94
+ deprecated: item.deprecated === true || item.releaseStatus === 'deprecated' || item.status === 'deprecated',
95
+ releaseStatus: item.releaseStatus || item.status || (item.released === false ? 'draft' : 'released')
96
+ }))
97
+ })
98
+
99
+ const activeVersionOption = computed(() => {
100
+ return versionOptions.value.find(item => item.value === activeVersionId.value) || null
101
+ })
102
+
103
+ const version = computed({
104
+ get: () => activeVersionId.value,
105
+ set: (versionId) => onVersionChange(versionId)
106
+ })
107
+
34
108
  const defaultBookId = computed(() => {
35
- const sortedBooks = [...(allBooks || [])]
109
+ const sortedBooks = [...activeBooks.value]
36
110
  .filter(book => book && typeof book.id === 'string' && book.id.length > 0)
37
111
  .sort((a, b) => {
38
112
  const orderA = Number.isFinite(a.order) ? a.order : Number.MAX_SAFE_INTEGER
@@ -52,13 +126,97 @@ const currentBookId = computed(() => {
52
126
  return defaultBookId.value
53
127
  })
54
128
 
129
+ const normalizeRoutePath = (path) => {
130
+ const normalized = String(path || '').trim()
131
+ if (normalized === '' || normalized === '/') {
132
+ return '/'
133
+ }
134
+
135
+ const sanitized = normalized.replace(/\/+$/, '')
136
+ return sanitized === '' ? '/' : sanitized
137
+ }
138
+
139
+ const getTopRoutes = () => {
140
+ return ($router.options.routes || []).slice(0, -2)
141
+ }
142
+
143
+ const routeHasSubpage = (route, subpageName) => {
144
+ return (route.children || []).some(child => child.path === subpageName)
145
+ }
146
+
147
+ const routeToSubpagePath = (route, subpageName) => {
148
+ return `${route.path.replace(/\/$/, '')}/${subpageName}/`
149
+ }
150
+
151
+ const getCurrentSubpageName = () => {
152
+ return String(subpage.value || '/overview').replace(/^\/+|\/+$/g, '') || 'overview'
153
+ }
154
+
155
+ const getFirstRoutePathByVersion = (versionId, preferredBook = null) => {
156
+ const routes = getTopRoutes()
157
+
158
+ for (const preferBook of [preferredBook, null]) {
159
+ for (const route of routes) {
160
+ if (route?.meta?.version !== versionId) continue
161
+ if (preferBook && (route.meta?.book ?? route.meta?.type) !== preferBook) continue
162
+ if (!routeHasSubpage(route, 'overview')) continue
163
+
164
+ const hasInternalLink = typeof route.meta?.link?.to === 'string' && route.meta.link.to.trim().length > 0
165
+ if (hasInternalLink) continue
166
+
167
+ return routeToSubpagePath(route, 'overview')
168
+ }
169
+ }
170
+
171
+ return '/'
172
+ }
173
+
174
+ const getEquivalentRoutePath = (versionId) => {
175
+ const routeMeta = $route.matched?.[0]?.meta || {}
176
+ const book = routeMeta.book ?? routeMeta.type ?? currentBookId.value
177
+ const pagePath = routeMeta.pagePath
178
+ const subpageName = getCurrentSubpageName()
179
+
180
+ if (book && typeof pagePath === 'string') {
181
+ const equivalentRoute = getTopRoutes().find(route => {
182
+ return route?.meta?.version === versionId &&
183
+ (route.meta?.book ?? route.meta?.type) === book &&
184
+ route.meta?.pagePath === pagePath &&
185
+ routeHasSubpage(route, subpageName)
186
+ })
187
+
188
+ if (equivalentRoute) {
189
+ return routeToSubpagePath(equivalentRoute, subpageName)
190
+ }
191
+ }
192
+
193
+ return getFirstRoutePathByVersion(versionId, book)
194
+ }
195
+
196
+ function onVersionChange (versionId) {
197
+ if (!versionId || versionId === activeVersionId.value) return
198
+
199
+ const targetVersion = (versions || []).find(item => item.id === versionId)
200
+ if (!targetVersion) return
201
+
202
+ if (typeof targetVersion.url === 'string' && targetVersion.url.trim().length > 0) {
203
+ openURL(targetVersion.url)
204
+ return
205
+ }
206
+
207
+ const targetPath = getEquivalentRoutePath(versionId)
208
+ if (normalizeRoutePath($route.path) !== normalizeRoutePath(targetPath)) {
209
+ $router.push(targetPath)
210
+ }
211
+ }
212
+
55
213
  const searchTerm = (term) => {
56
214
  if (term.length > 1) {
57
215
  term = term.toLowerCase()
58
216
  const locale = $q.localStorage.getItem('setting.language')
59
217
  founds.value = []
60
218
 
61
- for (const [index, group] of items.value.entries()) {
219
+ for (const group of items.value) {
62
220
  searchTermIterate(group, term, locale)
63
221
  }
64
222
  } else {
@@ -74,13 +232,14 @@ const searchTermIterate = (items, term, locale) => {
74
232
  } else if (typeof items === 'object') {
75
233
  const item = items
76
234
  const path = item.path
235
+ const tagPath = item.meta?.unversionedPath || path
77
236
  founds.value[path] = false
78
237
 
79
238
  // @ search in i18n/tags.hjson
80
239
  if (tags[locale] && Object.keys(tags[locale]).length > 0) {
81
- founds.value[path] = tags[locale][path]?.indexOf(term) !== -1
240
+ founds.value[path] = tags[locale][tagPath]?.indexOf(term) !== -1
82
241
  if (founds.value[path] === false && locale !== 'en-US') {
83
- founds.value[path] = tags['en-US'][path]?.indexOf(term) !== -1
242
+ founds.value[path] = tags['en-US'][tagPath]?.indexOf(term) !== -1
84
243
  }
85
244
  }
86
245
 
@@ -181,11 +340,13 @@ onBeforeUnmount(() => {
181
340
  })
182
341
 
183
342
  const buildMenuItems = () => {
184
- const routes = ($router.options.routes || []).slice(0, -2) // Delete last 2 routes
343
+ const routes = getTopRoutes()
185
344
  const activeBook = currentBookId.value
345
+ const activeVersion = activeVersionId.value
186
346
 
187
347
  const filteredRoutes = routes.filter(route => {
188
348
  const routeBook = route?.meta?.book ?? route?.meta?.type
349
+ if (activeVersion && route?.meta?.version !== activeVersion) return false
189
350
  if (!activeBook) return true
190
351
  return routeBook === activeBook
191
352
  })
@@ -201,7 +362,7 @@ const buildMenuItems = () => {
201
362
  })
202
363
 
203
364
  // # Route
204
- const basepath = route.path.split('/')[2]
365
+ const basepath = route.meta?.menuGroupPath || route.path.split('/')[2]
205
366
  const header = route.meta?.menu?.header
206
367
 
207
368
  if (header !== undefined && basepath !== nodeBasepath) {
@@ -227,7 +388,7 @@ const rebuildItems = () => {
227
388
  }
228
389
 
229
390
  rebuildItems()
230
- watch(currentBookId, rebuildItems)
391
+ watch([currentBookId, activeVersionId], rebuildItems)
231
392
  </script>
232
393
 
233
394
  <template>
@@ -254,10 +415,44 @@ watch(currentBookId, rebuildItems)
254
415
  <div class="text-weight-medium">{{ branding.name || 'Docsector' }}</div>
255
416
  <div class="text-caption q-pt-xs">{{ t('system.documentation') }}</div>
256
417
  <q-select class="q-mr-md"
257
- v-model="version" :options="versions"
418
+ v-model="version" :options="versionOptions"
419
+ emit-value map-options
258
420
  dense options-dense
259
421
  behavior="menu"
260
- />
422
+ >
423
+ <template v-slot:selected>
424
+ <div v-if="activeVersionOption" class="version-select-option">
425
+ <span class="version-select-label">{{ activeVersionOption.label }}</span>
426
+ <q-badge
427
+ v-if="activeVersionOption.badge"
428
+ class="version-select-badge"
429
+ :color="activeVersionOption.badge.color || 'warning'"
430
+ :text-color="activeVersionOption.badge.textColor || 'dark'"
431
+ :outline="activeVersionOption.badge.outline === true"
432
+ >
433
+ {{ activeVersionOption.badge.label }}
434
+ </q-badge>
435
+ </div>
436
+ </template>
437
+ <template v-slot:option="scope">
438
+ <q-item v-bind="scope.itemProps">
439
+ <q-item-section>
440
+ <div class="version-select-option">
441
+ <span class="version-select-label">{{ scope.opt.label }}</span>
442
+ <q-badge
443
+ v-if="scope.opt.badge"
444
+ class="version-select-badge"
445
+ :color="scope.opt.badge.color || 'warning'"
446
+ :text-color="scope.opt.badge.textColor || 'dark'"
447
+ :outline="scope.opt.badge.outline === true"
448
+ >
449
+ {{ scope.opt.badge.label }}
450
+ </q-badge>
451
+ </div>
452
+ </q-item-section>
453
+ </q-item>
454
+ </template>
455
+ </q-select>
261
456
  </div>
262
457
  </div>
263
458
 
@@ -470,6 +665,23 @@ body.body--light
470
665
  width: 30px
471
666
  height: 3px
472
667
 
668
+ .version-select-option
669
+ display: flex
670
+ align-items: center
671
+ gap: 6px
672
+ min-width: 0
673
+ max-width: 100%
674
+
675
+ .version-select-label
676
+ overflow: hidden
677
+ text-overflow: ellipsis
678
+ white-space: nowrap
679
+
680
+ .version-select-badge
681
+ flex: 0 0 auto
682
+ font-size: 10px
683
+ line-height: 1
684
+
473
685
  // Search
474
686
  label[for="search"]
475
687
  z-index: 2
@@ -37,6 +37,11 @@ const subpage = computed(() => {
37
37
  })
38
38
 
39
39
  const fileKey = computed(() => {
40
+ const sourcePathBase = route.matched?.[0]?.meta?.sourcePathBase
41
+ if (typeof sourcePathBase === 'string' && sourcePathBase.length > 0) {
42
+ return `${sourcePathBase}.${subpage.value}.${locale.value}.md`
43
+ }
44
+
40
45
  const base = store.state.page.base
41
46
  if (!base) return ''
42
47
  return `${base}.${subpage.value}.${locale.value}.md`
@@ -20,7 +20,17 @@ function normalizeEditBaseUrl (url = '') {
20
20
 
21
21
  const base = normalizeEditBaseUrl(docsectorConfig.github?.editBaseUrl || '')
22
22
 
23
+ function getCurrentSubpageName () {
24
+ const relative = store.state.page.relative || '/overview'
25
+ return String(relative).replace(/^\/+|\/+$/g, '') || 'overview'
26
+ }
27
+
23
28
  function routePathToSourcePath (path = '') {
29
+ const sourcePathBase = route.matched?.[0]?.meta?.sourcePathBase
30
+ if (typeof sourcePathBase === 'string' && sourcePathBase.length > 0) {
31
+ return `/${sourcePathBase}.${getCurrentSubpageName()}`
32
+ }
33
+
24
34
  const cleanPath = String(path)
25
35
  .replace(/\/index\.html$/, '')
26
36
  .replace(/\/+$/, '')
@@ -131,34 +141,38 @@ const languages = computed(() => {
131
141
  return `${i18nLocalesAvailable}/${i18nLocales.length}`
132
142
  })
133
143
 
134
- const prev = computed(() => {
135
- const base = store.state.page.base
136
- const routes = router.options.routes.slice(0, -2)
137
-
138
- for (let i = 0; i < routes.length; i++) {
139
- if ('/' + base === routes[i].path) {
140
- if (i > 0) {
141
- return routes[i - 1].path
142
- }
143
- }
144
+ const normalizeRoutePath = (path) => {
145
+ const normalized = String(path || '').trim()
146
+ if (normalized === '' || normalized === '/') {
147
+ return '/'
144
148
  }
145
149
 
146
- return ''
150
+ const sanitized = normalized.replace(/\/+$/, '')
151
+ return sanitized === '' ? '/' : sanitized
152
+ }
153
+
154
+ const getVersionSiblingPath = (offset) => {
155
+ const versionId = route.matched?.[0]?.meta?.version ?? null
156
+ const currentPath = normalizeRoutePath(route.matched?.[0]?.path || `/${store.state.page.base}`)
157
+ const routes = router.options.routes
158
+ .slice(0, -2)
159
+ .filter(item => !versionId || item?.meta?.version === versionId)
160
+
161
+ const index = routes.findIndex(item => normalizeRoutePath(item.path) === currentPath)
162
+ if (index < 0) return ''
163
+
164
+ const sibling = routes[index + offset]
165
+ if (!sibling) return ''
166
+
167
+ return sibling.path
168
+ }
169
+
170
+ const prev = computed(() => {
171
+ return getVersionSiblingPath(-1)
147
172
  })
148
173
 
149
174
  const next = computed(() => {
150
- const base = store.state.page.base
151
- const routes = router.options.routes.slice(0, -2)
152
-
153
- for (let i = 0; i < routes.length; i++) {
154
- if ('/' + base === routes[i].path) {
155
- if (typeof routes[i + 1] !== 'undefined') {
156
- return routes[i + 1].path
157
- }
158
- }
159
- }
160
-
161
- return ''
175
+ return getVersionSiblingPath(1)
162
176
  })
163
177
 
164
178
  const hideRemoteHomeFooterMeta = computed(() => {
@@ -116,12 +116,13 @@ export function filter (source) {
116
116
  * @param {Object} options.mdModules - Result of recursively globbing markdown files under ../pages with eager raw imports
117
117
  * @param {Object} [options.pages] - Legacy merged page registry from virtual:docsector-books (allPages)
118
118
  * @param {Object} [options.books] - Book registry from virtual:docsector-books (preferred, avoids path collisions)
119
+ * @param {Array} [options.pageEntries] - Version-aware page entries from virtual:docsector-books
119
120
  * @param {Object} options.boot - Boot meta from pages/boot.js
120
121
  * @param {string[]} [options.langs] - Language codes to process (auto-detected from langModules if omitted)
121
122
  * @param {Object<string,string>} [options.homePageOverride] - Optional per-language Home markdown override
122
123
  * @returns {Object} Complete i18n messages object keyed by locale
123
124
  */
124
- export function buildMessages ({ langModules, mdModules, pages, books, boot, langs, homePageOverride = {} }) {
125
+ export function buildMessages ({ langModules, mdModules, pages, books, pageEntries, boot, langs, homePageOverride = {} }) {
125
126
  // Auto-detect languages from HJSON files if not provided
126
127
  if (!langs) {
127
128
  langs = Object.keys(langModules).map(key => {
@@ -133,8 +134,9 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
133
134
 
134
135
  const i18n = {}
135
136
 
136
- function load (topPage, path, subpage, lang) {
137
- const key = `../pages/${topPage}/${path}.${subpage}.${lang}.md`
137
+ function load (topPage, path, subpage, lang, sourceRoot = '') {
138
+ const normalizedSourceRoot = String(sourceRoot || '').replace(/^\/+|\/+$/g, '')
139
+ const key = `../pages/${normalizedSourceRoot ? normalizedSourceRoot + '/' : ''}${topPage}/${path}.${subpage}.${lang}.md`
138
140
  const content = mdModules[key]
139
141
 
140
142
  if (!content) {
@@ -200,20 +202,30 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
200
202
  return match[1].trim()
201
203
  }
202
204
 
203
- const pageEntries = []
205
+ const resolvedPageEntries = []
204
206
 
205
- if (books && typeof books === 'object' && Object.keys(books).length > 0) {
207
+ if (Array.isArray(pageEntries) && pageEntries.length > 0) {
208
+ for (const entry of pageEntries) {
209
+ resolvedPageEntries.push({
210
+ key: entry.pagePath,
211
+ page: entry.page,
212
+ fallbackBook: entry.book,
213
+ sourceRoot: entry.sourceRoot || '',
214
+ i18nSegments: entry.i18nSegments
215
+ })
216
+ }
217
+ } else if (books && typeof books === 'object' && Object.keys(books).length > 0) {
206
218
  for (const [bookId, book] of Object.entries(books)) {
207
219
  const routes = book?.routes || {}
208
220
  const fallbackBook = book?.config?.id || bookId || 'manual'
209
221
 
210
222
  for (const [key, page] of Object.entries(routes)) {
211
- pageEntries.push({ key, page, fallbackBook })
223
+ resolvedPageEntries.push({ key, page, fallbackBook })
212
224
  }
213
225
  }
214
226
  } else {
215
227
  for (const [key, page] of Object.entries(pages || {})) {
216
- pageEntries.push({ key, page, fallbackBook: null })
228
+ resolvedPageEntries.push({ key, page, fallbackBook: null })
217
229
  }
218
230
  }
219
231
 
@@ -249,8 +261,8 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
249
261
  i18n[lang]._.home.overview.source = loadHomepage(lang)
250
262
 
251
263
  // @ Iterate pages
252
- for (const entry of pageEntries) {
253
- const { key, page, fallbackBook } = entry
264
+ for (const entry of resolvedPageEntries) {
265
+ const { key, page, fallbackBook, sourceRoot = '', i18nSegments: entryI18nSegments } = entry
254
266
  const path = key.startsWith('/') ? key.slice(1) : key
255
267
 
256
268
  const config = page.config
@@ -258,13 +270,13 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
258
270
  const meta = page.meta || boot.meta
259
271
 
260
272
  const topPage = config?.book ?? config?.type ?? fallbackBook ?? 'manual'
261
- if (i18n[lang]._[topPage] === undefined) {
262
- i18n[lang]._[topPage] = {}
263
- }
273
+ const i18nSegments = Array.isArray(entryI18nSegments) && entryI18nSegments.length > 0
274
+ ? entryI18nSegments
275
+ : [topPage, ...path.split('/').filter(Boolean)]
264
276
 
265
277
  // ---
266
278
 
267
- const _ = path.split('/').reduce((accumulator, current) => {
279
+ const _ = i18nSegments.reduce((accumulator, current, index) => {
268
280
  let node = accumulator[current]
269
281
 
270
282
  // Set object if not exists
@@ -275,7 +287,7 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
275
287
 
276
288
  // @ Set metadata
277
289
  // title
278
- if (node._ === undefined) {
290
+ if (index === i18nSegments.length - 1 && node._ === undefined) {
279
291
  node._ = data?.[lang]?.title || data?.['*']?.title || data?.['en-US']?.title || ''
280
292
  }
281
293
 
@@ -283,6 +295,10 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
283
295
  return node
284
296
  }
285
297
 
298
+ if (index < i18nSegments.length - 1) {
299
+ return node
300
+ }
301
+
286
302
  // Set subpages sources if not exists
287
303
  if (node.overview === undefined) {
288
304
  node.overview = {
@@ -307,7 +323,7 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
307
323
  }
308
324
 
309
325
  return node
310
- }, i18n[lang]._[topPage])
326
+ }, i18n[lang]._)
311
327
 
312
328
  // ---
313
329
 
@@ -322,14 +338,14 @@ export function buildMessages ({ langModules, mdModules, pages, books, boot, lan
322
338
 
323
339
  // @ Subpages
324
340
  // Overview
325
- _.overview.source = load(topPage, path, 'overview', lang)
341
+ _.overview.source = load(topPage, path, 'overview', lang, sourceRoot)
326
342
  // showcase
327
343
  if (config.subpages?.showcase === true) {
328
- _.showcase.source = load(topPage, path, 'showcase', lang)
344
+ _.showcase.source = load(topPage, path, 'showcase', lang, sourceRoot)
329
345
  }
330
346
  // Vs
331
347
  if (config.subpages?.vs === true) {
332
- _.vs.source = load(topPage, path, 'vs', lang)
348
+ _.vs.source = load(topPage, path, 'vs', lang, sourceRoot)
333
349
  }
334
350
  }
335
351
  }