@docsector/docsector-reader 3.2.1 → 3.3.1

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
@@ -41,6 +41,8 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
41
41
  ## ✨ Features
42
42
 
43
43
  - 📝 **Markdown Rendering** — Write docs in Markdown, rendered with syntax highlighting (Prism.js)
44
+ - 🔽 **Nested Markdown Lists** — Ordered and unordered lists preserve sublist hierarchy across multiple indentation levels
45
+ - 🖼️ **Block Image Captions & Zoom** — Standalone Markdown images render as zoomable figures, and raw `figure` / `picture` markup supports separate alt text and captions
44
46
  - 🧱 **Raw HTML in Markdown** — Renders inline and block HTML tags inside markdown sections (including homepage remote README content)
45
47
  - 🧩 **Mermaid Diagrams** — Native support for fenced ` ```mermaid ` blocks, with automatic dark/light theme switching
46
48
  - ➗ **Math & KaTeX** — Native support for inline `$...$` and display `$$...$$` formulas rendered with KaTeX
@@ -69,6 +71,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
69
71
  - 🔗 **GitHub-Compatible Heading Anchors** — Markdown headings use GitHub-style slugs so standard README Table of Contents links work inside Docsector
70
72
  - 🧬 **Scaffolded Homepage Override Wiring** — New consumer projects automatically wire `virtual:docsector-homepage-override` into i18n message building
71
73
  - 📖 **Expandable Markdown Sections** — Use `<d-expandable title="...">...</d-expandable>` to collapse secondary content while keeping rich Markdown support inside the body
74
+ - 📎 **File Attachment Blocks** — Use `<d-file src="/files/...">...</d-file>` in Markdown to render downloadable file cards with automatic local size detection and support for external URLs
72
75
  - 🧭 **Quick Links Custom Element** — Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
73
76
  - 🗂️ **API Catalog Well-Known** — Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
74
77
  - 🗃️ **Multi-Version History** — Archive older major versions under `src/pages/.old/<version>/` and expose them at prefixed routes (e.g. `/v0.x/guide/...`) while keeping the current docs at unprefixed routes
@@ -615,7 +618,7 @@ Docsector Reader works as a **rendering engine**: it provides the layout, compon
615
618
  │ ├── quasar.config.js ← thin wrapper │
616
619
  │ ├── src/pages/ ← Markdown + route defs │
617
620
  │ ├── src/i18n/ ← language files + tags │
618
- │ └── public/ ← logo, images, icons
621
+ │ └── public/ ← logo, images, icons, files
619
622
  │ │
620
623
  │ ┌───────────────────────────────────────────────┐ │
621
624
  │ │ @docsector/docsector-reader (engine) │ │
@@ -873,7 +876,8 @@ my-docs/
873
876
  └── public/
874
877
  ├── images/logo.png # Project logo
875
878
  ├── flags/ # Locale flag images
876
- └── icons/ # PWA icons
879
+ ├── icons/ # PWA icons
880
+ └── files/ # Downloadable attachments served as /files/...
877
881
  ```
878
882
 
879
883
  A common manual pattern is to keep core UI references under `src/pages/manual/basic/` with user-friendly page titles and focused entry pages such as Search, Branding, Version Switcher, Edit on GitHub, Translation Progress, and Previous & Next, end-user content references under `src/pages/manual/content/blocks/`, structural docs under `src/pages/manual/content/structures/`, and legacy/internal engine-specific references under `src/pages/manual/components/`.
@@ -1016,8 +1020,24 @@ Notes:
1016
1020
  Supported alert types: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`.
1017
1021
  Regular blockquotes without `[!TYPE]` continue to work normally.
1018
1022
 
1023
+ ### File Attachment Blocks
1024
+
1025
+ ```html
1026
+ <d-file src="/files/manual/release-checklist.txt" title="Release checklist" size="1 KB">
1027
+ Download the example file bundled with the docs.
1028
+ </d-file>
1029
+ ```
1030
+
1031
+ Notes:
1032
+
1033
+ - Store small repo-tracked attachments in `public/files/` and link them with absolute paths such as `/files/manual/release-checklist.txt`.
1034
+ - `title` and `size` are optional. If `title` is omitted, the rendered card falls back to the filename from `src`.
1035
+ - The block body is rendered as an inline Markdown caption.
1036
+ - External URLs also work, so the same syntax can later point to R2 or another CDN without changing the page structure.
1037
+
1019
1038
  ---
1020
1039
 
1040
+
1021
1041
  ## 🖥️ CLI Commands
1022
1042
 
1023
1043
  ```bash
package/bin/docsector.js CHANGED
@@ -23,7 +23,7 @@ const packageRoot = resolve(__dirname, '..')
23
23
  const args = process.argv.slice(2)
24
24
  const command = args[0]
25
25
 
26
- const VERSION = '3.2.1'
26
+ const VERSION = '3.3.1'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
package/jsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "ignoreDeprecations": "6.0",
3
4
  "baseUrl": ".",
4
5
  "noUnusedLocals": false,
5
6
  "noUnusedParameters": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "3.2.1",
3
+ "version": "3.3.1",
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",
@@ -0,0 +1,7 @@
1
+ Docsector Reader Release Checklist
2
+ =================================
3
+
4
+ 1. Confirm the target route and page registry entry.
5
+ 2. Update the overview and showcase markdown pages.
6
+ 3. Run the focused test suite for the touched parser or helper.
7
+ 4. Run a production build and inspect the published artifact.
@@ -0,0 +1,430 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useQuasar } from 'quasar'
4
+ import { useI18n } from 'vue-i18n'
5
+
6
+ import { resolveFileIconUrl } from '../composables/useFileIcon'
7
+
8
+ const BASE_URL = import.meta.env.BASE_URL || '/'
9
+
10
+ defineOptions({
11
+ name: 'DPageFile'
12
+ })
13
+
14
+ const props = defineProps({
15
+ src: {
16
+ type: String,
17
+ default: ''
18
+ },
19
+ title: {
20
+ type: String,
21
+ default: ''
22
+ },
23
+ size: {
24
+ type: String,
25
+ default: ''
26
+ },
27
+ caption: {
28
+ type: String,
29
+ default: ''
30
+ }
31
+ })
32
+
33
+ const $q = useQuasar()
34
+ const { t } = useI18n()
35
+ const resolvedSize = ref(String(props.size || '').trim())
36
+
37
+ const isExternal = computed(() => /^https?:\/\//i.test(props.src || ''))
38
+ const resolvedHref = computed(() => {
39
+ const raw = String(props.src || '').trim()
40
+
41
+ if (!raw) {
42
+ return ''
43
+ }
44
+
45
+ if (/^(?:[a-z]+:)?\/\//i.test(raw) || /^(?:mailto:|tel:|data:)/i.test(raw)) {
46
+ return raw
47
+ }
48
+
49
+ const trimmedBase = String(BASE_URL).replace(/\/$/, '')
50
+
51
+ if (raw.startsWith('/')) {
52
+ return `${trimmedBase}${raw}` || raw
53
+ }
54
+
55
+ const normalized = raw.replace(/^\.\//, '')
56
+ return `${trimmedBase}/${normalized}`.replace(/\/+/g, '/')
57
+ })
58
+ const absoluteHref = computed(() => {
59
+ const href = resolvedHref.value
60
+
61
+ if (!href || /^(?:[a-z]+:)?\/\//i.test(href)) {
62
+ return href
63
+ }
64
+
65
+ if (typeof window === 'undefined') {
66
+ return href
67
+ }
68
+
69
+ return new URL(href, window.location.origin).toString()
70
+ })
71
+ const fileName = computed(() => {
72
+ const normalized = String(props.src || '')
73
+ .split('#')[0]
74
+ .split('?')[0]
75
+ const rawSegment = normalized
76
+ .split('/')
77
+ .filter(Boolean)
78
+ .pop() || ''
79
+
80
+ if (!rawSegment) {
81
+ return ''
82
+ }
83
+
84
+ try {
85
+ return decodeURIComponent(rawSegment)
86
+ } catch {
87
+ return rawSegment
88
+ }
89
+ })
90
+ const displayTitle = computed(() => {
91
+ return props.title || fileName.value || t('page.file.defaultTitle')
92
+ })
93
+ const displayHeading = computed(() => {
94
+ if (props.title && fileName.value && fileName.value !== props.title) {
95
+ return `${props.title} (${fileName.value})`
96
+ }
97
+
98
+ return displayTitle.value
99
+ })
100
+ const iconUrl = computed(() => {
101
+ return resolveFileIconUrl(fileName.value || displayTitle.value || props.src, {
102
+ preferLight: !$q.dark.isActive
103
+ })
104
+ })
105
+
106
+ const formatFileSize = (bytes) => {
107
+ const value = Number(bytes)
108
+
109
+ if (!Number.isFinite(value) || value <= 0) {
110
+ return ''
111
+ }
112
+
113
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
114
+ let size = value
115
+ let unitIndex = 0
116
+
117
+ while (size >= 1024 && unitIndex < units.length - 1) {
118
+ size /= 1024
119
+ unitIndex++
120
+ }
121
+
122
+ const maximumFractionDigits = size >= 100 || unitIndex === 0 ? 0 : (size >= 10 ? 1 : 2)
123
+ return `${new Intl.NumberFormat(undefined, {
124
+ maximumFractionDigits,
125
+ minimumFractionDigits: 0
126
+ }).format(size)} ${units[unitIndex]}`
127
+ }
128
+
129
+ const readContentLength = async (href, options = {}) => {
130
+ const response = await fetch(href, options)
131
+
132
+ if (!response.ok) {
133
+ throw new Error(`HTTP ${response.status}`)
134
+ }
135
+
136
+ const contentLength = response.headers.get('content-length') || response.headers.get('Content-Length')
137
+ return formatFileSize(contentLength)
138
+ }
139
+
140
+ const hydrateSize = async () => {
141
+ const manualSize = String(props.size || '').trim()
142
+ resolvedSize.value = manualSize
143
+
144
+ if (manualSize || !absoluteHref.value || typeof window === 'undefined') {
145
+ return
146
+ }
147
+
148
+ try {
149
+ const automaticSize = await readContentLength(absoluteHref.value, {
150
+ method: 'HEAD'
151
+ })
152
+
153
+ if (automaticSize) {
154
+ resolvedSize.value = automaticSize
155
+ return
156
+ }
157
+
158
+ if (!isExternal.value) {
159
+ const fallbackSize = await readContentLength(absoluteHref.value)
160
+ if (fallbackSize) {
161
+ resolvedSize.value = fallbackSize
162
+ }
163
+ }
164
+ } catch {
165
+ // External URLs may block access to Content-Length via CORS.
166
+ }
167
+ }
168
+
169
+ watch([
170
+ () => props.size,
171
+ absoluteHref
172
+ ], () => {
173
+ hydrateSize()
174
+ }, {
175
+ immediate: true
176
+ })
177
+
178
+ const openFile = () => {
179
+ const href = absoluteHref.value
180
+
181
+ if (!href || typeof window === 'undefined') {
182
+ return
183
+ }
184
+
185
+ const link = document.createElement('a')
186
+
187
+ link.href = href
188
+ link.target = '_blank'
189
+ link.rel = 'noopener noreferrer'
190
+ document.body.appendChild(link)
191
+ link.click()
192
+ link.remove()
193
+ }
194
+
195
+ const downloadFile = async () => {
196
+ const href = absoluteHref.value
197
+
198
+ if (!href || typeof window === 'undefined') {
199
+ return
200
+ }
201
+
202
+ if (isExternal.value) {
203
+ openFile()
204
+ return
205
+ }
206
+
207
+ try {
208
+ const response = await fetch(href)
209
+
210
+ if (!response.ok) {
211
+ throw new Error(`HTTP ${response.status}`)
212
+ }
213
+
214
+ const blob = await response.blob()
215
+ const objectUrl = window.URL.createObjectURL(blob)
216
+ const link = document.createElement('a')
217
+
218
+ link.href = objectUrl
219
+ link.download = fileName.value || displayTitle.value || 'download'
220
+ link.rel = 'noopener noreferrer'
221
+ document.body.appendChild(link)
222
+ link.click()
223
+ link.remove()
224
+
225
+ window.setTimeout(() => {
226
+ window.URL.revokeObjectURL(objectUrl)
227
+ }, 1000)
228
+ } catch {
229
+ window.location.assign(href)
230
+ }
231
+ }
232
+ </script>
233
+
234
+ <template>
235
+ <div class="d-page-file">
236
+ <div class="d-page-file__body">
237
+ <div class="d-page-file__media-column">
238
+ <div class="d-page-file__media" aria-hidden="true">
239
+ <img
240
+ v-if="iconUrl"
241
+ class="d-page-file__icon"
242
+ :src="iconUrl"
243
+ alt=""
244
+ loading="lazy"
245
+ />
246
+ <q-icon
247
+ v-else
248
+ name="attach_file"
249
+ size="30px"
250
+ />
251
+ </div>
252
+
253
+ <div v-if="resolvedSize" class="d-page-file__size">{{ resolvedSize }}</div>
254
+ </div>
255
+
256
+ <div class="d-page-file__content">
257
+ <div class="d-page-file__title">{{ displayHeading }}</div>
258
+
259
+ <div
260
+ v-if="caption"
261
+ class="d-page-file__caption"
262
+ v-html="caption"
263
+ ></div>
264
+ </div>
265
+
266
+ <div class="d-page-file__actions">
267
+ <q-btn
268
+ no-caps
269
+ unelevated
270
+ padding="8px 12px"
271
+ class="d-page-file__action-button d-page-file__action-button--primary"
272
+ icon="download"
273
+ :label="t('page.file.download')"
274
+ @click="downloadFile"
275
+ />
276
+
277
+ <q-btn
278
+ no-caps
279
+ unelevated
280
+ padding="8px 12px"
281
+ class="d-page-file__action-button d-page-file__action-button--secondary"
282
+ icon="open_in_new"
283
+ :label="t('page.file.open')"
284
+ @click="openFile"
285
+ />
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </template>
290
+
291
+ <style lang="sass">
292
+ body.body--light
293
+ --d-page-file-bg: linear-gradient(180deg, #faf8f2 0%, #ffffff 100%)
294
+ --d-page-file-border: rgba(101, 85, 41, 0.18)
295
+ --d-page-file-shadow: rgba(101, 85, 41, 0.08)
296
+ --d-page-file-media-bg: rgba(255, 255, 255, 0.82)
297
+ --d-page-file-media-border: rgba(101, 85, 41, 0.1)
298
+ --d-page-file-meta: #665f4f
299
+ --d-page-file-caption: #4d5563
300
+ --d-page-file-accent: #655529
301
+ --d-page-file-action-text: #4d4020
302
+ --d-page-file-action-border: rgba(101, 85, 41, 0.22)
303
+ --d-page-file-action-bg-hover: rgba(101, 85, 41, 0.06)
304
+
305
+ body.body--dark
306
+ --d-page-file-bg: linear-gradient(180deg, rgba(255, 248, 235, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%)
307
+ --d-page-file-border: rgba(255, 235, 194, 0.14)
308
+ --d-page-file-shadow: rgba(0, 0, 0, 0.28)
309
+ --d-page-file-media-bg: rgba(255, 255, 255, 0.94)
310
+ --d-page-file-media-border: rgba(255, 255, 255, 0.18)
311
+ --d-page-file-meta: rgba(255, 255, 255, 0.7)
312
+ --d-page-file-caption: rgba(255, 255, 255, 0.9)
313
+ --d-page-file-accent: #d7bc7e
314
+ --d-page-file-action-text: rgba(255, 255, 255, 0.92)
315
+ --d-page-file-action-border: rgba(255, 235, 194, 0.18)
316
+ --d-page-file-action-bg-hover: rgba(255, 235, 194, 0.08)
317
+
318
+ .d-page-file
319
+ margin: 1.5rem 0
320
+ border: 1px solid var(--d-page-file-border)
321
+ border-radius: 18px
322
+ background: var(--d-page-file-bg)
323
+ box-shadow: 0 16px 36px var(--d-page-file-shadow)
324
+ overflow: hidden
325
+
326
+ .d-page-file__body
327
+ display: flex
328
+ align-items: center
329
+ gap: 1rem
330
+ padding: 0.82rem 0.92rem
331
+
332
+ .d-page-file__media-column
333
+ display: flex
334
+ flex: 0 0 68px
335
+ flex-direction: column
336
+ align-items: center
337
+ gap: 0.16rem
338
+
339
+ .d-page-file__media
340
+ width: 56px
341
+ height: 56px
342
+ display: flex
343
+ align-items: center
344
+ justify-content: center
345
+ border-radius: 16px
346
+ background: var(--d-page-file-media-bg)
347
+ box-shadow: inset 0 0 0 1px var(--d-page-file-media-border)
348
+ color: var(--d-page-file-accent)
349
+
350
+ .d-page-file__size
351
+ width: 100%
352
+ text-align: center
353
+ color: var(--d-page-file-meta)
354
+ font-size: 0.8rem
355
+ font-weight: 700
356
+ line-height: 1.05
357
+
358
+ .d-page-file__icon
359
+ display: block
360
+ width: 32px
361
+ height: 32px
362
+
363
+ .d-page-file__content
364
+ min-width: 0
365
+ flex: 1 1 auto
366
+
367
+ .d-page-file__title
368
+ font-size: 1rem
369
+ font-weight: 700
370
+ line-height: 1.4
371
+ word-break: break-word
372
+
373
+ .d-page-file__caption
374
+ margin-top: 0.35rem
375
+ color: var(--d-page-file-caption)
376
+
377
+ > :first-child
378
+ margin-top: 0
379
+
380
+ > :last-child
381
+ margin-bottom: 0
382
+
383
+ .d-page-file__actions
384
+ display: flex
385
+ flex: 0 0 auto
386
+ align-items: center
387
+ gap: 0.6rem
388
+ align-self: center
389
+
390
+ .d-page-file__action-button
391
+ min-height: 40px
392
+ border-radius: 10px
393
+ border: 1px solid var(--d-page-file-action-border)
394
+ background: transparent !important
395
+ color: var(--d-page-file-action-text) !important
396
+ transition: transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease
397
+
398
+ .q-btn__content
399
+ gap: 0.45rem
400
+ font-size: 0.9rem
401
+ font-weight: 600
402
+ line-height: 1
403
+
404
+ .q-focus-helper,
405
+ &:before,
406
+ &:after
407
+ display: none
408
+
409
+ &:hover
410
+ transform: translateY(-1px)
411
+ background: var(--d-page-file-action-bg-hover) !important
412
+
413
+ &:focus-visible
414
+ outline: 2px solid var(--d-page-file-accent)
415
+ outline-offset: 2px
416
+
417
+ .d-page-file__action-button--primary
418
+ border-color: var(--d-page-file-action-border)
419
+
420
+ @media (max-width: 720px)
421
+ .d-page-file__body
422
+ flex-wrap: wrap
423
+ align-items: flex-start
424
+
425
+ .d-page-file__actions
426
+ width: 100%
427
+
428
+ .d-page-file__action-button
429
+ flex: 1 1 0
430
+ </style>
@@ -0,0 +1,80 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ defineOptions({
5
+ name: 'DPageImage'
6
+ })
7
+
8
+ const props = defineProps({
9
+ content: {
10
+ type: String,
11
+ default: ''
12
+ },
13
+ captionHtml: {
14
+ type: String,
15
+ default: ''
16
+ }
17
+ })
18
+
19
+ const hasCaption = computed(() => {
20
+ return String(props.captionHtml || '').trim() !== ''
21
+ })
22
+ </script>
23
+
24
+ <template>
25
+ <figure class="d-page-image">
26
+ <q-zoom
27
+ class="d-page-image__zoom"
28
+ background-color="rgba(18, 18, 20, 0.96)"
29
+ show-close-button
30
+ >
31
+ <div class="d-page-image__media" v-html="content"></div>
32
+ </q-zoom>
33
+
34
+ <figcaption v-if="hasCaption" class="d-page-image__caption" v-html="captionHtml"></figcaption>
35
+ </figure>
36
+ </template>
37
+
38
+ <style lang="sass" scoped>
39
+ .d-page-image
40
+ display: flex
41
+ flex-direction: column
42
+ align-items: center
43
+ gap: 0.75rem
44
+ width: 100%
45
+ margin: 1.75rem auto
46
+ text-align: center
47
+
48
+ .d-page-image__zoom
49
+ display: inline-block
50
+ width: fit-content
51
+ max-width: 100%
52
+
53
+ .d-page-image__media
54
+ display: inline-flex
55
+ align-items: center
56
+ justify-content: center
57
+ line-height: 0
58
+ max-width: 100%
59
+
60
+ :deep(img),
61
+ :deep(picture)
62
+ display: block
63
+ max-width: 100%
64
+
65
+ .d-page-image__caption
66
+ max-width: min(100%, 42rem)
67
+ margin: 0
68
+ padding: 0 1rem
69
+ color: inherit
70
+ opacity: 0.72
71
+ font-size: 0.92rem
72
+ line-height: 1.45
73
+ text-align: center
74
+
75
+ :deep(p)
76
+ margin: 0
77
+
78
+ :deep(*)
79
+ color: inherit
80
+ </style>
@@ -22,6 +22,8 @@ import DH6 from './DH6.vue'
22
22
  import DPageSourceCode from './DPageSourceCode.vue'
23
23
  import DMermaidDiagram from './DMermaidDiagram.vue'
24
24
  import DPageBlockquote from './DPageBlockquote.vue'
25
+ import DPageImage from './DPageImage.vue'
26
+ import DPageFile from './DPageFile.vue'
25
27
  import DQuickLinks from './DQuickLinks.vue'
26
28
  import DPageExpandable from './DPageExpandable.vue'
27
29
  </script>
@@ -75,6 +77,12 @@ import DPageExpandable from './DPageExpandable.vue'
75
77
  v-html="token.content"
76
78
  ></div>
77
79
 
80
+ <d-page-image
81
+ v-else-if="token.tag === 'image'"
82
+ :content="token.content"
83
+ :caption-html="token.captionHtml"
84
+ />
85
+
78
86
  <p
79
87
  v-else-if="token.tag === 'p'"
80
88
  v-html="token.content"
@@ -87,6 +95,14 @@ import DPageExpandable from './DPageExpandable.vue'
87
95
  <div v-html="token.content"></div>
88
96
  </d-page-blockquote>
89
97
 
98
+ <d-page-file
99
+ v-else-if="token.tag === 'file'"
100
+ :src="token.src"
101
+ :title="token.title"
102
+ :size="token.size"
103
+ :caption="token.caption"
104
+ />
105
+
90
106
  <d-page-source-code
91
107
  v-else-if="token.tag === 'code'"
92
108
  :index="id + token.codeIndex"