@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 +8 -6
- package/bin/docsector.js +1 -1
- package/docsector.config.js +2 -2
- package/package.json +3 -2
- package/src/ai-assistant/panel.js +53 -0
- package/src/ai-assistant/session.js +13 -2
- package/src/components/DAssistantPanel.vue +154 -13
- package/src/components/DPageSection.vue +17 -3
- package/src/components/DPageTokens.vue +1 -0
- package/src/components/DSubpage.vue +9 -1
- package/src/components/page-template-sections.js +171 -0
- package/src/composables/useAssistant.js +44 -13
- package/src/css/app.sass +37 -2
- package/src/i18n/helpers.js +12 -2
- package/src/i18n/languages/en-US.hjson +5 -0
- package/src/i18n/languages/pt-BR.hjson +5 -0
- package/src/page-template.js +118 -0
- package/src/pages/manual/content/structures/subpage.overview.en-US.md +38 -1
- package/src/pages/manual/content/structures/subpage.overview.pt-BR.md +38 -1
- package/src/router/routes.js +9 -2
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: '
|
|
336
|
-
maxResults:
|
|
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: '
|
|
890
|
-
maxResults:
|
|
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.
|
|
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`
|
package/docsector.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docsector/docsector-reader",
|
|
3
|
-
"version": "4.
|
|
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": "
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
866
|
-
&:focus-within .d-assistant-message__hoverlayer .d-assistant-
|
|
867
|
-
.d-assistant-message__hoverlayer:hover .d-assistant-
|
|
868
|
-
.d-assistant-message__hoverlayer:focus-within .d-assistant-
|
|
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% -
|
|
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
|
-
&
|
|
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
|
|
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:
|
|
972
|
-
|
|
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-
|
|
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
|
-
|
|
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) => {
|
|
@@ -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}-${
|
|
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
|
|
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
|
-
|
|
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
|
package/src/i18n/helpers.js
CHANGED
|
@@ -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
|
|
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
|
|
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': {
|
|
18
|
+
config: {
|
|
19
|
+
subpages: {
|
|
20
|
+
vs: { template: 'vs' } // enable the `vs` subpage with the `vs` template
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The boolean shorthand (`showcase: true`) still means "enabled, freestyle". The object form `{ template: '<name>' }` 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': {
|
|
18
|
+
config: {
|
|
19
|
+
subpages: {
|
|
20
|
+
vs: { template: 'vs' } // habilita a subpágina `vs` com o template `vs`
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
O atalho booleano (`showcase: true`) continua significando "habilitada, freestyle". A forma de objeto `{ template: '<nome>' }` 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
|
```
|
package/src/router/routes.js
CHANGED
|
@@ -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
|
|
89
|
-
vs: config?.subpages?.vs
|
|
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
|