@docsector/docsector-reader 4.5.6 → 4.7.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,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
59
59
  - 🔗 **Anchor Navigation** — Right-side source-ordered Table of Contents tree with stable scroll tracking, resize-safe drawer state, auto-scroll to the active section, and active-heading resolution based on the last heading that crossed the content threshold
60
60
  - 🖱️ **Active Menu Item UX** — Active menu entries keep pointer cursor, clear URL hash without redundant navigation, and prevent accidental label text selection
61
61
  - 🔎 **Search** — Menu search across all documentation content and tags
62
- - 💬 **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
62
+ - 💬 **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 hover-revealed message times, and show a floating quick return to the bottom
63
63
  - 📱 **Responsive** — Mobile-friendly with collapsible sidebar and drawers
64
64
  - 🏷️ **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
65
65
  - 📚 **Book Tabs with Per-State Colors** — Define `*.book.js` tabs with icons, order, and `color.active` / `color.inactive`
@@ -67,6 +67,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
67
67
  - 🦶 **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
68
68
  - 🔀 **Internal Shortcut Pages** — Route entries can redirect with `config.link.to`, keeping localized titles while inheriting icon/status from the destination page
69
69
  - 📐 **Responsive Subpage Toolbar** — Subpage actions align with the content column on desktop and dock to the bottom on mobile
70
+ - 🆚 **Subpage Templates** — Subpages opt into a structured template via `vs: { template: 'vs' }`; the managed/strict `vs` template owns the order and localized titles of its **Features**, **Performance** and **Security** sections (one `##` heading per section, missing sections dropped gracefully), auto-colorizes `✓`/`✗`/`➕` comparison marks, and highlights the column whose header matches the consumer's `branding.name`
70
71
  - ⬆️ **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
71
72
  - 🏷️ **Status Badges** — Mark pages as `done`, `draft`, `empty`, or `new` with visual indicators
72
73
  - ✏️ **Edit on GitHub** — Direct links to edit pages on your repository
@@ -301,7 +302,7 @@ Check `checks.discovery.webMcp.status` equals `"pass"`.
301
302
 
302
303
  Docsector Reader can add an opt-in assistant panel for documentation Q&A. Users open it from the global header while reading pages and subpages; it is not a dedicated documentation route. The drawer posts to a same-origin Cloudflare Pages Function, and that function calls Cloudflare AI Search so secrets, rate-limit strategy, provider errors, and future auth stay server-side.
303
304
 
304
- The panel is disabled by default. When enabled, desktop pages get a dedicated right-side assistant rail that can sit beside the table of contents on wide screens. Mobile uses a fullscreen dialog. Conversations restore at the latest message, reveal earlier history progressively in long chats, deduplicate repeated source links, preserve the panel open state across page reloads, and provide per-message copy actions.
305
+ The panel is disabled by default. When enabled, desktop pages get a dedicated right-side assistant rail that can sit beside the table of contents on wide screens. Mobile uses a fullscreen dialog. Conversations restore at the latest message, reveal earlier history progressively in long chats, deduplicate repeated source links, preserve the panel open state across page reloads, and provide per-message copy actions with hover-revealed message times.
305
306
 
306
307
  ### Configure
307
308
 
@@ -332,8 +333,8 @@ export default {
332
333
  accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
333
334
  apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
334
335
  model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
335
- retrievalType: 'hybrid',
336
- maxResults: 6,
336
+ retrievalType: 'vector',
337
+ maxResults: 10,
337
338
  matchThreshold: 0.4,
338
339
  contextExpansion: 1,
339
340
  queryRewrite: { enabled: true },
@@ -886,8 +887,8 @@ export default {
886
887
  instanceNameEnv: 'AI_SEARCH_INSTANCE_NAME',
887
888
  accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
888
889
  apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
889
- retrievalType: 'hybrid',
890
- maxResults: 6,
890
+ retrievalType: 'vector',
891
+ maxResults: 10,
891
892
  stream: true
892
893
  }
893
894
  },
@@ -1137,6 +1138,7 @@ Notes:
1137
1138
  - In `manual.index.js`, route keys are relative to the `manual` book (for example `'/my-section/my-page'` becomes `/manual/my-section/my-page/...`).
1138
1139
  - You only need to set `config.book` when overriding the inferred book from the registry file.
1139
1140
  - When `showcase` or `vs` are enabled, the subpage toolbar aligns with the content width on desktop and becomes a bottom action bar on mobile.
1141
+ - A subpage can opt into a **structured template** with the object form `vs: { template: 'vs' }` (the boolean `true` stays `freestyle`). The built-in `vs` template is managed/strict: it owns the order and localized titles of its **Features**, **Performance** and **Security** sections — write one `##` heading per section in the Markdown and omitted sections are dropped gracefully.
1140
1142
 
1141
1143
  2️⃣ Create Markdown files:
1142
1144
 
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.5.6'
27
+ const VERSION = '4.7.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`
@@ -88,10 +88,10 @@ export default {
88
88
  matchThreshold: 0.4,
89
89
  contextExpansion: 1,
90
90
  queryRewrite: {
91
- enabled: true
91
+ enabled: false
92
92
  },
93
93
  reranking: {
94
- enabled: true,
94
+ enabled: false,
95
95
  model: '@cf/baai/bge-reranker-base',
96
96
  matchThreshold: 0.4
97
97
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "4.5.6",
3
+ "version": "4.7.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",
@@ -57,7 +57,8 @@
57
57
  "url": "https://github.com/docsector/docsector-reader/issues"
58
58
  },
59
59
  "scripts": {
60
- "dev": "npx wrangler pages dev dist/spa",
60
+ "dev": "quasar dev",
61
+ "dev:pages": "npx wrangler pages dev dist/spa",
61
62
  "build": "quasar build",
62
63
  "lint": "eslint --ext .js,.vue ./",
63
64
  "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
@@ -2,6 +2,39 @@ export function hasAssistantMessageContent(message) {
2
2
  return String(message?.content || '').trim().length > 0
3
3
  }
4
4
 
5
+ export function getAssistantMessageTimestamp(message) {
6
+ const timestamp = Number(message?.timestamp)
7
+ if (Number.isFinite(timestamp) && timestamp > 0) {
8
+ return timestamp
9
+ }
10
+
11
+ const id = String(message?.id || '')
12
+ const match = id.match(/^(?:user|assistant)-(\d{12,})-/)
13
+ if (!match) {
14
+ return null
15
+ }
16
+
17
+ const legacyTimestamp = Number(match[1])
18
+ return Number.isFinite(legacyTimestamp) && legacyTimestamp > 0 ? legacyTimestamp : null
19
+ }
20
+
21
+ export function formatAssistantMessageTime(message, locale = 'en-US') {
22
+ const timestamp = getAssistantMessageTimestamp(message)
23
+ if (!timestamp) {
24
+ return ''
25
+ }
26
+
27
+ const date = new Date(timestamp)
28
+ if (Number.isNaN(date.getTime())) {
29
+ return ''
30
+ }
31
+
32
+ return new Intl.DateTimeFormat(locale || 'en-US', {
33
+ hour: '2-digit',
34
+ minute: '2-digit'
35
+ }).format(date)
36
+ }
37
+
5
38
  export function listVisibleAssistantMessages(messages = []) {
6
39
  return messages.filter((message) => {
7
40
  if (message?.role !== 'assistant') {
@@ -12,6 +45,26 @@ export function listVisibleAssistantMessages(messages = []) {
12
45
  })
13
46
  }
14
47
 
48
+ export function hasVisibleAssistantHistoryAfter(messages = [], messageId = '') {
49
+ const targetId = String(messageId || '')
50
+ if (!targetId) {
51
+ return false
52
+ }
53
+
54
+ const targetIndex = messages.findIndex(message => String(message?.id || '') === targetId)
55
+ if (targetIndex === -1) {
56
+ return false
57
+ }
58
+
59
+ return messages.slice(targetIndex + 1).some((message) => {
60
+ if (message?.role === 'assistant') {
61
+ return hasAssistantMessageContent(message)
62
+ }
63
+
64
+ return hasAssistantMessageContent(message)
65
+ })
66
+ }
67
+
15
68
  export const ASSISTANT_MESSAGE_WINDOW_SIZE = 60
16
69
  export const ASSISTANT_MESSAGE_WINDOW_STEP = 40
17
70
 
@@ -1,4 +1,5 @@
1
1
  import { dedupeAssistantSources } from './stream'
2
+ import { getAssistantMessageTimestamp } from './panel'
2
3
 
3
4
  export const ASSISTANT_SESSION_STORAGE_KEY = 'docsector.assistant.session.v1'
4
5
 
@@ -17,6 +18,11 @@ function cleanString (value = '', maxLength = Number.MAX_SAFE_INTEGER) {
17
18
  return String(value || '').slice(0, maxLength)
18
19
  }
19
20
 
21
+ function normalizeMessageTimestamp (message) {
22
+ const timestamp = getAssistantMessageTimestamp(message)
23
+ return timestamp ? { timestamp } : {}
24
+ }
25
+
20
26
  export function normalizeAssistantSession (session = {}) {
21
27
  const messages = (Array.isArray(session?.messages) ? session.messages : [])
22
28
  .map((message, index) => {
@@ -24,11 +30,16 @@ export function normalizeAssistantSession (session = {}) {
24
30
  const content = cleanString(message?.content, MAX_MESSAGE_CONTENT_LENGTH)
25
31
  if (!VALID_ROLES.has(role) || !content.trim()) return null
26
32
 
27
- return {
33
+ const normalizedMessage = {
28
34
  id: cleanString(message?.id || `${role}-${index + 1}`, 160),
29
35
  role,
30
36
  content
31
37
  }
38
+
39
+ return {
40
+ ...normalizedMessage,
41
+ ...normalizeMessageTimestamp(message)
42
+ }
32
43
  })
33
44
  .filter(Boolean)
34
45
  .slice(-MAX_PERSISTED_MESSAGES)
@@ -90,4 +101,4 @@ export function clearAssistantSession ({ storage = null, key = ASSISTANT_SESSION
90
101
  } catch {
91
102
  // Ignore storage failures.
92
103
  }
93
- }
104
+ }
@@ -8,7 +8,9 @@ import useAssistant from '../composables/useAssistant'
8
8
  import {
9
9
  ASSISTANT_MESSAGE_WINDOW_SIZE,
10
10
  ASSISTANT_MESSAGE_WINDOW_STEP,
11
+ formatAssistantMessageTime,
11
12
  getAssistantMessageWindow,
13
+ hasVisibleAssistantHistoryAfter,
12
14
  isAssistantThinkingState
13
15
  } from '../ai-assistant/panel'
14
16
  import DPageTokens from './DPageTokens.vue'
@@ -57,6 +59,8 @@ const scrollArea = ref(null)
57
59
  const visibleMessageLimit = ref(ASSISTANT_MESSAGE_WINDOW_SIZE)
58
60
  const copiedMessageId = ref('')
59
61
  const showScrollToBottom = ref(false)
62
+ const retryHistoryDialogOpen = ref(false)
63
+ const pendingRetryMessageId = ref('')
60
64
  let scrollFrame = 0
61
65
  let copiedMessageTimer = null
62
66
  let revealingOlderMessages = false
@@ -131,6 +135,10 @@ const hasMessageContent = (message) => {
131
135
  return String(message?.content || '').trim().length > 0
132
136
  }
133
137
 
138
+ const messageTime = (message) => {
139
+ return formatAssistantMessageTime(message, locale.value)
140
+ }
141
+
134
142
  const messageHasSources = (message) => {
135
143
  return hasSources.value && String(message?.id || '') === latestAssistantMessageId.value
136
144
  }
@@ -281,6 +289,41 @@ const messageCopyIcon = (message) => {
281
289
  return copiedMessageId.value === String(message?.id || '') ? 'check' : 'content_copy'
282
290
  }
283
291
 
292
+ const runRetryMessage = async (messageId) => {
293
+ const id = String(messageId || '')
294
+ if (!id) return
295
+ await assistant.retryFromUserMessage(id)
296
+ }
297
+
298
+ const retryMessage = async (message) => {
299
+ const id = String(message?.id || '')
300
+ if (!id || assistant.loading.value) return
301
+
302
+ if (hasVisibleAssistantHistoryAfter(assistant.messages.value, id)) {
303
+ pendingRetryMessageId.value = id
304
+ retryHistoryDialogOpen.value = true
305
+ return
306
+ }
307
+
308
+ await runRetryMessage(id)
309
+ }
310
+
311
+ const clearPendingRetryMessage = () => {
312
+ pendingRetryMessageId.value = ''
313
+ }
314
+
315
+ const cancelRetryMessage = () => {
316
+ retryHistoryDialogOpen.value = false
317
+ clearPendingRetryMessage()
318
+ }
319
+
320
+ const confirmRetryMessage = async () => {
321
+ const id = pendingRetryMessageId.value
322
+ retryHistoryDialogOpen.value = false
323
+ clearPendingRetryMessage()
324
+ await runRetryMessage(id)
325
+ }
326
+
284
327
  const submit = async (value = input.value) => {
285
328
  const prompt = String(value || '').trim()
286
329
  if (!prompt) return
@@ -419,7 +462,7 @@ onBeforeUnmount(() => {
419
462
  <q-btn
420
463
  flat dense
421
464
  text-color="primary"
422
- class="d-assistant-message__copy d-assistant-message__copy--assistant"
465
+ class="d-assistant-message__action d-assistant-message__copy d-assistant-message__copy--assistant"
423
466
  :icon="messageCopyIcon(message)"
424
467
  :aria-label="t('assistant.copyMessage')"
425
468
  @click="copyMessage(message)"
@@ -483,6 +526,11 @@ onBeforeUnmount(() => {
483
526
  </q-list>
484
527
  </q-menu>
485
528
  </q-chip>
529
+
530
+ <span
531
+ v-if="messageTime(message)"
532
+ class="d-assistant-message__timestamp"
533
+ >{{ messageTime(message) }}</span>
486
534
  </div>
487
535
 
488
536
  <div
@@ -491,15 +539,30 @@ onBeforeUnmount(() => {
491
539
  >
492
540
  <div class="d-assistant-message__hoverlayer">
493
541
  <q-btn
542
+ v-if="!assistant.loading.value"
494
543
  flat dense
495
544
  text-color="primary"
496
- class="d-assistant-message__copy d-assistant-message__copy--user"
545
+ class="d-assistant-message__action d-assistant-message__retry"
546
+ icon="refresh"
547
+ :aria-label="t('assistant.retryMessage')"
548
+ @click="retryMessage(message)"
549
+ >
550
+ <q-tooltip>{{ t('assistant.retryMessage') }}</q-tooltip>
551
+ </q-btn>
552
+ <q-btn
553
+ flat dense
554
+ text-color="primary"
555
+ class="d-assistant-message__action d-assistant-message__copy d-assistant-message__copy--user"
497
556
  :icon="messageCopyIcon(message)"
498
557
  :aria-label="t('assistant.copyMessage')"
499
558
  @click="copyMessage(message)"
500
559
  >
501
560
  <q-tooltip>{{ t('assistant.copyMessage') }}</q-tooltip>
502
561
  </q-btn>
562
+ <span
563
+ v-if="messageTime(message)"
564
+ class="d-assistant-message__timestamp"
565
+ >{{ messageTime(message) }}</span>
503
566
  </div>
504
567
  </div>
505
568
  </div>
@@ -572,6 +635,33 @@ onBeforeUnmount(() => {
572
635
  </div>
573
636
  </div>
574
637
  </footer>
638
+
639
+ <q-dialog v-model="retryHistoryDialogOpen" @hide="clearPendingRetryMessage">
640
+ <q-card class="d-assistant-retry-dialog">
641
+ <q-card-section class="d-assistant-retry-dialog__header">
642
+ <q-icon name="warning_amber" size="24px" />
643
+ <div>
644
+ <h3>{{ t('assistant.retryHistoryTitle') }}</h3>
645
+ <p>{{ t('assistant.retryHistoryMessage') }}</p>
646
+ </div>
647
+ </q-card-section>
648
+ <q-card-actions align="right" class="d-assistant-retry-dialog__actions">
649
+ <q-btn
650
+ unelevated no-caps
651
+ color="grey-7"
652
+ text-color="white"
653
+ :label="t('assistant.retryHistoryCancel')"
654
+ @click="cancelRetryMessage"
655
+ />
656
+ <q-btn
657
+ unelevated no-caps
658
+ color="primary"
659
+ :label="t('assistant.retryHistoryConfirm')"
660
+ @click="confirmRetryMessage"
661
+ />
662
+ </q-card-actions>
663
+ </q-card>
664
+ </q-dialog>
575
665
  </aside>
576
666
  </template>
577
667
 
@@ -636,7 +726,7 @@ onBeforeUnmount(() => {
636
726
  color: rgba(255, 255, 255, 0.86)
637
727
  border-color: rgba(255, 255, 255, 0.12)
638
728
 
639
- .d-assistant-message__copy
729
+ .d-assistant-message__action
640
730
  color: rgba(255, 255, 255, 0.84)
641
731
 
642
732
  .d-assistant-sources-chip__avatar
@@ -862,10 +952,12 @@ onBeforeUnmount(() => {
862
952
  color: white
863
953
  background: var(--q-primary)
864
954
 
865
- &:hover .d-assistant-message__hoverlayer .d-assistant-message__copy,
866
- &:focus-within .d-assistant-message__hoverlayer .d-assistant-message__copy,
867
- .d-assistant-message__hoverlayer:hover .d-assistant-message__copy,
868
- .d-assistant-message__hoverlayer:focus-within .d-assistant-message__copy
955
+ &:hover .d-assistant-message__hoverlayer .d-assistant-message__action,
956
+ &:focus-within .d-assistant-message__hoverlayer .d-assistant-message__action,
957
+ .d-assistant-message__hoverlayer:hover .d-assistant-message__action,
958
+ .d-assistant-message__hoverlayer:focus-within .d-assistant-message__action,
959
+ &:hover .d-assistant-message__timestamp,
960
+ &:focus-within .d-assistant-message__timestamp
869
961
  opacity: 1
870
962
  pointer-events: auto
871
963
 
@@ -881,6 +973,10 @@ onBeforeUnmount(() => {
881
973
  .d-assistant-message__footer
882
974
  justify-content: flex-start
883
975
 
976
+ &:hover .d-assistant-message__timestamp,
977
+ &:focus-within .d-assistant-message__timestamp
978
+ opacity: 1
979
+
884
980
  &__content
885
981
  max-width: 88%
886
982
  min-width: 0
@@ -935,7 +1031,7 @@ onBeforeUnmount(() => {
935
1031
 
936
1032
  .d-assistant-sources-chip
937
1033
  flex: 0 1 auto
938
- max-width: calc(100% - 38px)
1034
+ max-width: calc(100% - 92px)
939
1035
 
940
1036
  &--user
941
1037
  width: auto
@@ -946,7 +1042,7 @@ onBeforeUnmount(() => {
946
1042
  align-items: center
947
1043
  justify-content: center
948
1044
 
949
- &__copy
1045
+ &__action
950
1046
  flex: 0 0 auto
951
1047
  width: 30px
952
1048
  height: 30px
@@ -960,25 +1056,45 @@ onBeforeUnmount(() => {
960
1056
 
961
1057
  i
962
1058
  font-size: 17px !important
963
- margin-left: 3px
1059
+ margin: 0
964
1060
 
1061
+ &__copy
965
1062
  &--assistant
966
1063
  margin-left: -2px
967
1064
 
1065
+ &__retry
1066
+ color: currentColor
1067
+
968
1068
  &__hoverlayer
969
1069
  display: flex
970
1070
  align-items: center
971
- justify-content: center
972
- width: 30px
1071
+ justify-content: flex-end
1072
+ gap: 6px
1073
+ width: auto
973
1074
  height: 30px
974
1075
  min-width: 30px
975
1076
  min-height: 30px
976
1077
 
977
- .d-assistant-message__copy
1078
+ .d-assistant-message__action
978
1079
  opacity: 0
979
1080
  pointer-events: none
980
1081
  transition: opacity 0.14s ease
981
1082
 
1083
+ &__timestamp
1084
+ flex: 0 0 auto
1085
+ margin-left: auto
1086
+ color: currentColor
1087
+ font-size: 0.72rem
1088
+ font-weight: 700
1089
+ font-variant-numeric: tabular-nums
1090
+ line-height: 30px
1091
+ opacity: 0
1092
+ pointer-events: none
1093
+ transition: opacity 0.14s ease
1094
+
1095
+ &__hoverlayer &__timestamp
1096
+ margin-left: 0
1097
+
982
1098
  &__thinking
983
1099
  display: flex
984
1100
  align-items: center
@@ -987,6 +1103,31 @@ onBeforeUnmount(() => {
987
1103
  opacity: 0.78
988
1104
  font-weight: 600
989
1105
 
1106
+ .d-assistant-retry-dialog
1107
+ width: min(360px, calc(100vw - 32px))
1108
+ border-radius: 8px
1109
+
1110
+ &__header
1111
+ display: flex
1112
+ align-items: flex-start
1113
+ gap: 12px
1114
+ padding: 18px 18px 10px
1115
+
1116
+ h3
1117
+ margin: 0 0 6px
1118
+ font-size: 1rem
1119
+ line-height: 1.3
1120
+ font-weight: 800
1121
+
1122
+ p
1123
+ margin: 0
1124
+ color: currentColor
1125
+ opacity: 0.72
1126
+ line-height: 1.45
1127
+
1128
+ &__actions
1129
+ padding: 8px 12px 14px
1130
+
990
1131
  .d-assistant-sources-chip
991
1132
  max-width: 100%
992
1133
  min-width: 0
@@ -7,8 +7,10 @@ import DPageTokens from './DPageTokens.vue'
7
7
  import { pageValueI18nPath } from '../i18n/path'
8
8
  import { buildPageAnchorTree } from './page-anchor-tree'
9
9
  import { tokenizePageSectionSource } from './page-section-tokens'
10
+ import { applyTemplateSections } from './page-template-sections'
11
+ import docsectorConfig from 'docsector.config.js'
10
12
 
11
- defineProps({
13
+ const props = defineProps({
12
14
  id: {
13
15
  type: Number,
14
16
  required: true
@@ -16,11 +18,15 @@ defineProps({
16
18
  renderPrimaryHeading: {
17
19
  type: Boolean,
18
20
  default: false
21
+ },
22
+ template: {
23
+ type: Object,
24
+ default: null
19
25
  }
20
26
  })
21
27
 
22
28
  const store = useStore()
23
- const { t } = useI18n()
29
+ const { t, locale } = useI18n()
24
30
 
25
31
  const tokenized = computed(() => {
26
32
  const absolute = store.state.i18n.absolute
@@ -29,7 +35,15 @@ const tokenized = computed(() => {
29
35
  return []
30
36
  }
31
37
 
32
- return tokenizePageSectionSource(t(pageValueI18nPath(absolute, 'source')))
38
+ const tokens = tokenizePageSectionSource(t(pageValueI18nPath(absolute, 'source')))
39
+
40
+ if (Array.isArray(props.template?.sections) && props.template.sections.length > 0) {
41
+ return applyTemplateSections(tokens, props.template, locale.value, {
42
+ highlightColumn: docsectorConfig?.branding?.name
43
+ })
44
+ }
45
+
46
+ return tokens
33
47
  })
34
48
 
35
49
  watch(tokenized, (tokens) => {
@@ -88,6 +88,7 @@ import DBlockApi from './DBlockApi.vue'
88
88
  <div
89
89
  v-else-if="token.tag === 'table'"
90
90
  class="d-table-wrapper"
91
+ :class="{ 'd-table-wrapper--vs': token.highlight }"
91
92
  >
92
93
  <d-page-rich-content
93
94
  tag="table"
@@ -9,10 +9,18 @@ import DPageBar from "./DPageBar.vue";
9
9
  import DH1 from "./DH1.vue";
10
10
  import DPageSection from "./DPageSection.vue";
11
11
  import { usesRemoteReadmeHomeContent } from '../home-page-mode'
12
+ import { getTemplate } from '../page-template'
12
13
 
13
14
  const route = useRoute()
14
15
  const store = useStore()
15
16
 
17
+ const template = computed(() => {
18
+ const relative = store.state.page.relative
19
+ const subpage = relative ? relative.replace(/^\//, '') : 'overview'
20
+ const templates = route.matched?.[0]?.meta?.subpageTemplates
21
+ return getTemplate(templates?.[subpage])
22
+ })
23
+
16
24
  const id = computed(() => {
17
25
  const path = route.path
18
26
 
@@ -42,7 +50,7 @@ const usesRemoteReadmeHome = computed(() => {
42
50
  </header>
43
51
 
44
52
  <main>
45
- <d-page-section :id="id" :render-primary-heading="usesRemoteReadmeHome" />
53
+ <d-page-section :id="id" :render-primary-heading="usesRemoteReadmeHome" :template="template" />
46
54
  </main>
47
55
  </d-page>
48
56
  </template>
@@ -0,0 +1,171 @@
1
+ const stripHtml = (value) => String(value ?? '').replace(/<[^>]*>/g, '')
2
+
3
+ /**
4
+ * Normalize a heading/key into a comparable slug: strip HTML and diacritics,
5
+ * lowercase, and collapse non-alphanumerics into single hyphens.
6
+ */
7
+ const normalizeKey = (value) => stripHtml(value)
8
+ .normalize('NFD')
9
+ .replace(/[̀-ͯ]/g, '')
10
+ .toLowerCase()
11
+ .trim()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+
15
+ const warnUnknownSection = (templateName, heading, allowed) => {
16
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
17
+ console.warn(
18
+ `[docsector] Unknown "${templateName}" template section: "${heading}". ` +
19
+ `Allowed sections: ${allowed.join(', ')}.`
20
+ )
21
+ }
22
+ }
23
+
24
+ // ? Comparison marks colorized in rendered content (✓ yes, ✗ no, ➕ add-on)
25
+ const MARK_CLASS = { '✓': 'vs-mark--yes', '✗': 'vs-mark--no', '➕': 'vs-mark--dep' }
26
+ const MARKABLE_TAGS = new Set(['p', 'table', 'ul', 'ol'])
27
+ const MARK_PATTERN = /[✓✗➕]/
28
+
29
+ const colorizeMarks = (html) => String(html ?? '').replace(
30
+ /[✓✗➕]/g,
31
+ (glyph) => `<span class="vs-mark ${MARK_CLASS[glyph]}">${glyph}</span>`
32
+ )
33
+
34
+ // ? Detect tables whose 2nd header matches the consumer-configured highlight column
35
+ const isHighlightColumnTable = (html, label) => {
36
+ if (!label) {
37
+ return false
38
+ }
39
+
40
+ const header = html.match(/<thead[\s\S]*?<\/thead>/i)?.[0] ?? html.match(/<tr[\s\S]*?<\/tr>/i)?.[0] ?? ''
41
+ const cells = [...header.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map(cell => cell[1].replace(/<[^>]*>/g, '').trim())
42
+
43
+ return cells[1] === label
44
+ }
45
+
46
+ const transformToken = (token, highlightColumn) => {
47
+ if (!token || typeof token.content !== 'string') {
48
+ return token
49
+ }
50
+
51
+ let content = token.content
52
+ if (MARKABLE_TAGS.has(token.tag) && MARK_PATTERN.test(content)) {
53
+ content = colorizeMarks(content)
54
+ }
55
+
56
+ // ? Flag (not inject) — the <table> element is created by DPageTokens, not in token.content
57
+ const highlight = token.tag === 'table' && isHighlightColumnTable(token.content, highlightColumn)
58
+
59
+ if (content === token.content && !highlight) {
60
+ return token
61
+ }
62
+
63
+ return highlight ? { ...token, content, highlight: true } : { ...token, content }
64
+ }
65
+
66
+ /**
67
+ * Apply a structured subpage template to a flat token stream (managed/strict).
68
+ *
69
+ * The template owns the section structure: each canonical section is rendered in
70
+ * the template's declared order, with the template's localized title overriding
71
+ * the markdown heading. Sections absent from the markdown are gracefully omitted.
72
+ * Markdown content before the first `h2` is kept as an intro. Unknown top-level
73
+ * `h2` sections are warned about and appended after the canonical sections so no
74
+ * authored content is lost.
75
+ *
76
+ * Freestyle templates (no `sections`) return the tokens unchanged.
77
+ *
78
+ * @param {Array} tokens - Tokenized page section source.
79
+ * @param {Object} template - Resolved template preset.
80
+ * @param {string} [locale] - Active locale for section titles.
81
+ * @param {Object} [options] - Render options.
82
+ * @param {string} [options.highlightColumn] - Header label whose comparison column is emphasized (consumer-provided, e.g. the project name).
83
+ * @returns {Array} Reordered/relabeled token stream.
84
+ */
85
+ export function applyTemplateSections (tokens, template, locale = 'en-US', options = {}) {
86
+ const sections = template?.sections
87
+ const highlightColumn = options.highlightColumn
88
+
89
+ if (!Array.isArray(sections) || sections.length === 0) {
90
+ return Array.isArray(tokens) ? tokens : []
91
+ }
92
+
93
+ // ? Map every accepted slug (canonical key + each localized title) to its section index
94
+ const indexBySlug = new Map()
95
+ sections.forEach((section, index) => {
96
+ indexBySlug.set(normalizeKey(section.key), index)
97
+
98
+ for (const title of Object.values(section.title || {})) {
99
+ const slug = normalizeKey(title)
100
+ if (slug && !indexBySlug.has(slug)) {
101
+ indexBySlug.set(slug, index)
102
+ }
103
+ }
104
+ })
105
+
106
+ // ! Partition tokens into intro, canonical buckets and unknown sections
107
+ const intro = []
108
+ const buckets = sections.map(() => null)
109
+ const unknowns = []
110
+ let current = null
111
+
112
+ // @ Walk the flat stream, splitting at h2 boundaries
113
+ for (const token of Array.isArray(tokens) ? tokens : []) {
114
+ if (token?.tag === 'h2') {
115
+ const slug = normalizeKey(token.anchorId ?? token.content)
116
+ const index = indexBySlug.has(slug) ? indexBySlug.get(slug) : -1
117
+
118
+ if (index >= 0) {
119
+ if (buckets[index] === null) {
120
+ buckets[index] = []
121
+ }
122
+ current = { type: 'section', index }
123
+ } else {
124
+ const unknown = { token, body: [] }
125
+ unknowns.push(unknown)
126
+ current = { type: 'unknown', unknown }
127
+ }
128
+
129
+ continue
130
+ }
131
+
132
+ if (current === null) {
133
+ intro.push(token)
134
+ } else if (current.type === 'section') {
135
+ buckets[current.index].push(token)
136
+ } else {
137
+ current.unknown.body.push(token)
138
+ }
139
+ }
140
+
141
+ // @@ Rebuild in canonical order
142
+ const output = [...intro]
143
+
144
+ sections.forEach((section, index) => {
145
+ const body = buckets[index]
146
+ if (body === null) {
147
+ return
148
+ }
149
+
150
+ output.push({
151
+ tag: 'h2',
152
+ anchorId: section.key,
153
+ content: section.title?.[locale] || section.title?.['en-US'] || section.key,
154
+ icon: section.icon
155
+ })
156
+ output.push(...body)
157
+ })
158
+
159
+ // ? Append unknown sections (non-destructive) after warning
160
+ if (unknowns.length > 0) {
161
+ const allowed = sections.map(section => section.key)
162
+
163
+ for (const unknown of unknowns) {
164
+ warnUnknownSection(template.name, stripHtml(unknown.token.content), allowed)
165
+ output.push(unknown.token, ...unknown.body)
166
+ }
167
+ }
168
+
169
+ // : colorize comparison marks (✓/✗/➕) + highlight the configured column
170
+ return output.map(token => transformToken(token, highlightColumn))
171
+ }
@@ -17,10 +17,13 @@ let assistantSessionPersistencePaused = false
17
17
  const ASSISTANT_SESSION_PERSIST_DEBOUNCE = 180
18
18
 
19
19
  function createMessage (role, content = '') {
20
+ const timestamp = Date.now()
21
+
20
22
  return {
21
- id: `${role}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
23
+ id: `${role}-${timestamp}-${Math.random().toString(16).slice(2)}`,
22
24
  role,
23
- content
25
+ content,
26
+ timestamp
24
27
  }
25
28
  }
26
29
 
@@ -121,6 +124,11 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
121
124
  message.content += content
122
125
  }
123
126
 
127
+ const appendAssistantPlaceholder = () => {
128
+ messages.value.push(createMessage('assistant'))
129
+ return messages.value[messages.value.length - 1]
130
+ }
131
+
124
132
  const consumeStream = async (response, assistantMessage) => {
125
133
  const reader = response.body.getReader()
126
134
  const decoder = new TextDecoder()
@@ -175,22 +183,14 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
175
183
  }
176
184
  }
177
185
 
178
- const send = async (content) => {
179
- const prompt = String(content || '').trim()
180
- if (!prompt || loading.value) return
181
-
186
+ const prepareRequest = () => {
182
187
  assistantSessionPersistencePaused = true
183
188
  cancelPersistAssistantSession()
184
189
  error.value = ''
185
190
  sources.value = []
191
+ }
186
192
 
187
- const userMessage = createMessage('user', prompt)
188
- const assistantMessage = createMessage('assistant')
189
- messages.value.push(userMessage, assistantMessage)
190
- // Use the reactive proxy from the array so streamed mutations trigger
191
- // live re-renders (raw object mutations bypass Vue reactivity).
192
- const liveAssistantMessage = messages.value[messages.value.length - 1]
193
-
193
+ const requestAssistantResponse = async (liveAssistantMessage) => {
194
194
  abortController.value = new AbortController()
195
195
  loading.value = true
196
196
 
@@ -249,6 +249,36 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
249
249
  }
250
250
  }
251
251
 
252
+ const send = async (content) => {
253
+ const prompt = String(content || '').trim()
254
+ if (!prompt || loading.value) return
255
+
256
+ prepareRequest()
257
+
258
+ messages.value.push(createMessage('user', prompt))
259
+ const liveAssistantMessage = appendAssistantPlaceholder()
260
+
261
+ await requestAssistantResponse(liveAssistantMessage)
262
+ }
263
+
264
+ const retryFromUserMessage = async (messageId) => {
265
+ const targetId = String(messageId || '')
266
+ if (!targetId || loading.value) return
267
+
268
+ const targetIndex = messages.value.findIndex(message => String(message?.id || '') === targetId)
269
+ const targetMessage = messages.value[targetIndex]
270
+ if (targetIndex === -1 || targetMessage?.role !== 'user' || !String(targetMessage?.content || '').trim()) {
271
+ return
272
+ }
273
+
274
+ prepareRequest()
275
+
276
+ messages.value = messages.value.slice(0, targetIndex + 1)
277
+ const liveAssistantMessage = appendAssistantPlaceholder()
278
+
279
+ await requestAssistantResponse(liveAssistantMessage)
280
+ }
281
+
252
282
  return {
253
283
  config: assistantConfig,
254
284
  messages,
@@ -257,6 +287,7 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
257
287
  error,
258
288
  hasMessages,
259
289
  send,
290
+ retryFromUserMessage,
260
291
  stop,
261
292
  clear
262
293
  }
package/src/css/app.sass CHANGED
@@ -84,6 +84,20 @@ body.body--dark
84
84
  color: #B19248
85
85
  section
86
86
  color: var(--q-light-in-dark-2)
87
+ // VS comparison marks (dark)
88
+ .vs-mark--yes
89
+ color: #3fb950
90
+ .vs-mark--no
91
+ color: #ff7b72
92
+ .vs-mark--dep
93
+ color: #d29922
94
+ // VS comparison: highlight the configured column (dark)
95
+ .d-table-wrapper--vs
96
+ th:nth-child(2),
97
+ td:nth-child(2)
98
+ background: rgba(193, 166, 103, 0.12)
99
+ border-left-color: rgba(193, 166, 103, 0.45)
100
+ border-right-color: rgba(193, 166, 103, 0.45)
87
101
  // token
88
102
  code:not(pre > code)
89
103
  color: var(--q-primary-in-dark-bg) !important
@@ -102,7 +116,8 @@ body.body--dark
102
116
  border-bottom: 1px dotted #1b4a6c
103
117
 
104
118
  &.overview,
105
- &.showcase
119
+ &.showcase,
120
+ &.vs
106
121
  blockquote
107
122
  border-left-color: #3d444d
108
123
  background-color: #161b22 !important
@@ -157,6 +172,25 @@ body.body--dark
157
172
  --big-play-button-background: #000 !important
158
173
  --big-play-button-background-dark: #000 !important
159
174
 
175
+ // VS comparison marks
176
+ .vs-mark
177
+ font-weight: 700
178
+ .vs-mark--yes
179
+ color: #1a7f37
180
+ .vs-mark--no
181
+ color: #cf222e
182
+ .vs-mark--dep
183
+ color: #9a6700
184
+
185
+ // VS comparison: highlight the configured column
186
+ .d-table-wrapper--vs
187
+ th:nth-child(2),
188
+ td:nth-child(2)
189
+ background: rgba(105, 86, 43, 0.07)
190
+ font-weight: 600
191
+ border-left: 2px solid rgba(105, 86, 43, 0.35)
192
+ border-right: 2px solid rgba(105, 86, 43, 0.35)
193
+
160
194
  h1, h2, h3, h4, h5, h6
161
195
  font-weight: 600
162
196
  padding: 6px
@@ -281,7 +315,8 @@ body.body--dark
281
315
  transition: all 0.3s ease
282
316
 
283
317
  &.overview,
284
- &.showcase
318
+ &.showcase,
319
+ &.vs
285
320
  blockquote
286
321
  margin: 1.5em 0
287
322
  padding: 0.85em 1.1em
@@ -84,6 +84,11 @@ const engineDefaults = {
84
84
  sources: 'Sources',
85
85
  sourcesCount: '{count} sources',
86
86
  copyMessage: 'Copy message',
87
+ retryMessage: 'Reload message',
88
+ retryHistoryTitle: 'Reload from this message?',
89
+ retryHistoryMessage: 'Messages after this question will be removed from the conversation.',
90
+ retryHistoryConfirm: 'Reload',
91
+ retryHistoryCancel: 'Cancel',
87
92
  copied: 'Message copied',
88
93
  loadEarlier: 'Load earlier messages',
89
94
  thinking: 'Searching the docs…',
@@ -157,6 +162,11 @@ const engineDefaults = {
157
162
  sources: 'Fontes',
158
163
  sourcesCount: '{count} fontes',
159
164
  copyMessage: 'Copiar mensagem',
165
+ retryMessage: 'Reenviar mensagem',
166
+ retryHistoryTitle: 'Reenviar desta mensagem?',
167
+ retryHistoryMessage: 'As mensagens depois desta pergunta serão removidas da conversa.',
168
+ retryHistoryConfirm: 'Reenviar',
169
+ retryHistoryCancel: 'Cancelar',
160
170
  copied: 'Mensagem copiada',
161
171
  loadEarlier: 'Carregar mensagens anteriores',
162
172
  thinking: 'Consultando a documentação…',
@@ -438,11 +448,11 @@ export function buildMessages ({ langModules, mdModules, pages, books, pageEntri
438
448
  // Overview
439
449
  _.overview.source = load(topPage, path, 'overview', lang, sourceRoot)
440
450
  // showcase
441
- if (config.subpages?.showcase === true) {
451
+ if (config.subpages?.showcase) {
442
452
  _.showcase.source = load(topPage, path, 'showcase', lang, sourceRoot)
443
453
  }
444
454
  // Vs
445
- if (config.subpages?.vs === true) {
455
+ if (config.subpages?.vs) {
446
456
  _.vs.source = load(topPage, path, 'vs', lang, sourceRoot)
447
457
  }
448
458
  }
@@ -119,6 +119,11 @@
119
119
  sources: 'Sources',
120
120
  sourcesCount: '{count} sources',
121
121
  copyMessage: 'Copy message',
122
+ retryMessage: 'Reload message',
123
+ retryHistoryTitle: 'Reload from this message?',
124
+ retryHistoryMessage: 'Messages after this question will be removed from the conversation.',
125
+ retryHistoryConfirm: 'Reload',
126
+ retryHistoryCancel: 'Cancel',
122
127
  copied: 'Message copied',
123
128
  loadEarlier: 'Load earlier messages',
124
129
  thinking: 'Searching the docs…',
@@ -118,6 +118,11 @@
118
118
  sources: 'Fontes',
119
119
  sourcesCount: '{count} fontes',
120
120
  copyMessage: 'Copiar mensagem',
121
+ retryMessage: 'Reenviar mensagem',
122
+ retryHistoryTitle: 'Reenviar desta mensagem?',
123
+ retryHistoryMessage: 'As mensagens depois desta pergunta serão removidas da conversa.',
124
+ retryHistoryConfirm: 'Reenviar',
125
+ retryHistoryCancel: 'Cancelar',
121
126
  copied: 'Mensagem copiada',
122
127
  loadEarlier: 'Carregar mensagens anteriores',
123
128
  thinking: 'Consultando a documentação…',
@@ -0,0 +1,118 @@
1
+ export const PAGE_TEMPLATE_FREESTYLE = 'freestyle'
2
+ export const PAGE_TEMPLATE_VS = 'vs'
3
+
4
+ /**
5
+ * Subpage template presets.
6
+ *
7
+ * A template defines the fixed structure a subpage must follow. `freestyle`
8
+ * (the default) imposes no structure — the markdown renders exactly as written.
9
+ * Structured templates declare an ordered `sections` list; the renderer slots
10
+ * the markdown bodies into that fixed structure (canonical order, template-owned
11
+ * localized titles, graceful omission of absent sections).
12
+ */
13
+ const PAGE_TEMPLATE_PRESETS = Object.freeze({
14
+ [PAGE_TEMPLATE_FREESTYLE]: Object.freeze({
15
+ name: PAGE_TEMPLATE_FREESTYLE,
16
+ sections: null
17
+ }),
18
+ [PAGE_TEMPLATE_VS]: Object.freeze({
19
+ name: PAGE_TEMPLATE_VS,
20
+ sections: Object.freeze([
21
+ Object.freeze({
22
+ key: 'features',
23
+ icon: 'checklist',
24
+ title: Object.freeze({ 'en-US': 'Features', 'pt-BR': 'Recursos' })
25
+ }),
26
+ Object.freeze({
27
+ key: 'performance',
28
+ icon: 'speed',
29
+ title: Object.freeze({ 'en-US': 'Performance', 'pt-BR': 'Desempenho' })
30
+ }),
31
+ Object.freeze({
32
+ key: 'security',
33
+ icon: 'security',
34
+ title: Object.freeze({ 'en-US': 'Security', 'pt-BR': 'Segurança' })
35
+ })
36
+ ])
37
+ })
38
+ })
39
+
40
+ const normalizeTemplateName = (value) => {
41
+ const normalized = String(value || '')
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/[\s_-]+/g, '-')
45
+
46
+ if (normalized && Object.prototype.hasOwnProperty.call(PAGE_TEMPLATE_PRESETS, normalized)) {
47
+ return normalized
48
+ }
49
+
50
+ return PAGE_TEMPLATE_FREESTYLE
51
+ }
52
+
53
+ const firstDefined = (source, keys) => {
54
+ for (const key of keys) {
55
+ if (source?.[key] !== undefined) {
56
+ return source[key]
57
+ }
58
+ }
59
+
60
+ return undefined
61
+ }
62
+
63
+ const toTemplateName = (source) => {
64
+ if (source === undefined || source === null) {
65
+ return undefined
66
+ }
67
+
68
+ if (typeof source === 'boolean') {
69
+ return source ? PAGE_TEMPLATE_FREESTYLE : undefined
70
+ }
71
+
72
+ if (typeof source === 'string') {
73
+ return normalizeTemplateName(source)
74
+ }
75
+
76
+ if (typeof source === 'object' && !Array.isArray(source)) {
77
+ const name = firstDefined(source, ['template', 'name', 'type', 'preset'])
78
+ return name === undefined ? undefined : normalizeTemplateName(name)
79
+ }
80
+
81
+ return undefined
82
+ }
83
+
84
+ /**
85
+ * Whether a subpage config value enables that subpage.
86
+ *
87
+ * Accepts the boolean shorthand (`true`) or the object form (`{ template }`).
88
+ */
89
+ export function isSubpageEnabled (value) {
90
+ return value === true || (typeof value === 'object' && value !== null && !Array.isArray(value))
91
+ }
92
+
93
+ /**
94
+ * Resolve a subpage template from one or more config sources.
95
+ *
96
+ * Sources may be a template name string, the subpage config value
97
+ * (`true` | `{ template }`), or undefined. Later sources win; the default is
98
+ * `freestyle`. Returns the resolved template preset object.
99
+ */
100
+ export function resolveSubpageTemplate (...sources) {
101
+ let name = PAGE_TEMPLATE_FREESTYLE
102
+
103
+ for (const source of sources) {
104
+ const resolved = toTemplateName(source)
105
+ if (resolved !== undefined) {
106
+ name = resolved
107
+ }
108
+ }
109
+
110
+ return PAGE_TEMPLATE_PRESETS[name] || PAGE_TEMPLATE_PRESETS[PAGE_TEMPLATE_FREESTYLE]
111
+ }
112
+
113
+ /**
114
+ * Get a template preset object by name, falling back to `freestyle`.
115
+ */
116
+ export function getTemplate (name) {
117
+ return PAGE_TEMPLATE_PRESETS[normalizeTemplateName(name)] || PAGE_TEMPLATE_PRESETS[PAGE_TEMPLATE_FREESTYLE]
118
+ }
@@ -8,6 +8,43 @@ Under the hood, routed documentation uses `DSubpage` for this composition.
8
8
 
9
9
  The implementation generates a deterministic numeric ID from the current route path using a hash function. This ID is passed to `DPageSection` to keep per-page renderer indexes stable across page navigations.
10
10
 
11
+ ## Subpage templates
12
+
13
+ Subpages render free-form Markdown by default (the `freestyle` template). A subpage can instead opt into a **structured template** that owns a fixed set of sections, declared per subpage in the page registry:
14
+
15
+ ```javascript
16
+ // src/pages/manual.index.js
17
+ '/WPI/HTTP/HTTP_Server_CLI': &#123;
18
+ config: &#123;
19
+ subpages: &#123;
20
+ vs: &#123; template: 'vs' &#125; // enable the `vs` subpage with the `vs` template
21
+ &#125;
22
+ &#125;
23
+ &#125;
24
+ ```
25
+
26
+ The boolean shorthand (`showcase: true`) still means "enabled, freestyle". The object form `&#123; template: '<name>' &#125;` selects a template.
27
+
28
+ ### Built-in templates
29
+
30
+ | Template | Structure |
31
+ |---|---|
32
+ | `freestyle` (default) | No fixed structure — Markdown renders as written |
33
+ | `vs` | Fixed comparison sections: Features, Performance, Security |
34
+
35
+ ### Managed (strict) rendering
36
+
37
+ Structured templates are **managed**: the template owns the section titles, icons and order. In the Markdown you write one `##` heading per section (its slug must match a section key — or one of its localized titles); the renderer then:
38
+
39
+ - renders sections in the template's canonical order,
40
+ - replaces each heading with the template's localized title,
41
+ - gracefully omits sections you do not include (so a page with partial data just leaves them out),
42
+ - warns in the dev console about unknown headings and appends them after the canonical sections.
43
+
44
+ Every `vs` subpage therefore shares the same structure. Templates are resolved by `resolveSubpageTemplate()` (`src/page-template.js`) and applied by `applyTemplateSections()` (`src/components/page-template-sections.js`).
45
+
46
+ Inside comparison tables the engine colorizes the marks `✓` / `✗` / `➕` and highlights the column whose header matches your project's `branding.name`. The engine stays product-agnostic — the highlighted column is configured by the consumer (via `branding.name` in `docsector.config.js`), not hardcoded.
47
+
11
48
  ## Template
12
49
 
13
50
  ```html
@@ -16,7 +53,7 @@ The implementation generates a deterministic numeric ID from the current route p
16
53
  <d-h1 :id="0" />
17
54
  </header>
18
55
  <main>
19
- <d-page-section :id="id" />
56
+ <d-page-section :id="id" :template="template" />
20
57
  </main>
21
58
  </d-page>
22
59
  ```
@@ -8,6 +8,43 @@ Na implementação, a documentação roteada usa `DSubpage` para essa composiç
8
8
 
9
9
  A implementação gera um ID numérico determinístico a partir do caminho da rota atual usando uma função hash. Esse ID é passado ao `DPageSection` para manter estáveis os índices internos do renderer em cada página.
10
10
 
11
+ ## Templates de subpágina
12
+
13
+ As subpáginas renderizam Markdown livre por padrão (o template `freestyle`). Uma subpágina pode, em vez disso, optar por um **template estruturado** que controla um conjunto fixo de seções, declarado por subpágina no registro de páginas:
14
+
15
+ ```javascript
16
+ // src/pages/manual.index.js
17
+ '/WPI/HTTP/HTTP_Server_CLI': &#123;
18
+ config: &#123;
19
+ subpages: &#123;
20
+ vs: &#123; template: 'vs' &#125; // habilita a subpágina `vs` com o template `vs`
21
+ &#125;
22
+ &#125;
23
+ &#125;
24
+ ```
25
+
26
+ O atalho booleano (`showcase: true`) continua significando "habilitada, freestyle". A forma de objeto `&#123; template: '<nome>' &#125;` seleciona um template.
27
+
28
+ ### Templates nativos
29
+
30
+ | Template | Estrutura |
31
+ |---|---|
32
+ | `freestyle` (padrão) | Sem estrutura fixa — o Markdown renderiza como foi escrito |
33
+ | `vs` | Seções de comparação fixas: Recursos, Desempenho, Segurança |
34
+
35
+ ### Renderização gerenciada (estrita)
36
+
37
+ Templates estruturados são **gerenciados**: o template controla os títulos, ícones e a ordem das seções. No Markdown você escreve um heading `##` por seção (cujo slug deve corresponder à chave da seção — ou a um de seus títulos localizados); o renderer então:
38
+
39
+ - renderiza as seções na ordem canônica do template,
40
+ - substitui cada heading pelo título localizado do template,
41
+ - omite com elegância as seções que você não inclui (uma página com dados parciais simplesmente as deixa de fora),
42
+ - avisa no console de dev sobre headings desconhecidos e os anexa após as seções canônicas.
43
+
44
+ Toda subpágina `vs` compartilha, portanto, a mesma estrutura. Os templates são resolvidos por `resolveSubpageTemplate()` (`src/page-template.js`) e aplicados por `applyTemplateSections()` (`src/components/page-template-sections.js`).
45
+
46
+ Dentro das tabelas de comparação o engine coloriza as marcas `✓` / `✗` / `➕` e destaca a coluna cujo header corresponde ao `branding.name` do seu projeto. O engine permanece agnóstico ao produto — a coluna destacada é configurada pelo consumidor (via `branding.name` no `docsector.config.js`), não fica hardcoded.
47
+
11
48
  ## Template
12
49
 
13
50
  ```html
@@ -16,7 +53,7 @@ A implementação gera um ID numérico determinístico a partir do caminho da ro
16
53
  <d-h1 :id="0" />
17
54
  </header>
18
55
  <main>
19
- <d-page-section :id="id" />
56
+ <d-page-section :id="id" :template="template" />
20
57
  </main>
21
58
  </d-page>
22
59
  ```
@@ -2,6 +2,7 @@ import { pageEntries, versions } from 'virtual:docsector-books'
2
2
  import boot from 'pages/boot'
3
3
  import docsectorConfig from 'docsector.config.js'
4
4
  import { resolveHomePageLayout, resolvePageLayout } from '../page-layout'
5
+ import { isSubpageEnabled, resolveSubpageTemplate } from '../page-template'
5
6
 
6
7
  const normalizeInternalLink = (linkTo) => {
7
8
  const normalized = String(linkTo || '').trim()
@@ -85,8 +86,13 @@ for (const entry of pageEntries || []) {
85
86
  const config = rawConfig || {}
86
87
  const menu = (typeof config.menu === 'object' && config.menu !== null) ? config.menu : {}
87
88
  const subpages = {
88
- showcase: config?.subpages?.showcase === true,
89
- vs: config?.subpages?.vs === true
89
+ showcase: isSubpageEnabled(config?.subpages?.showcase),
90
+ vs: isSubpageEnabled(config?.subpages?.vs)
91
+ }
92
+ const subpageTemplates = {
93
+ overview: resolveSubpageTemplate(config?.subpages?.overview).name,
94
+ showcase: resolveSubpageTemplate(config?.subpages?.showcase).name,
95
+ vs: resolveSubpageTemplate(config?.subpages?.vs).name
90
96
  }
91
97
 
92
98
  const topPage = config.book ?? config.type ?? entry?.book ?? 'manual'
@@ -159,6 +165,7 @@ for (const entry of pageEntries || []) {
159
165
  pageVersion,
160
166
  menu,
161
167
  subpages,
168
+ subpageTemplates,
162
169
  data: page.data,
163
170
  book: topPage,
164
171
  // legacy compatibility