@docsector/docsector-reader 3.2.1 → 3.2.2

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
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.2.2'
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.2.2",
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,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,7 @@ 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'
25
26
  import DQuickLinks from './DQuickLinks.vue'
26
27
  import DPageExpandable from './DPageExpandable.vue'
27
28
  </script>
@@ -75,6 +76,12 @@ import DPageExpandable from './DPageExpandable.vue'
75
76
  v-html="token.content"
76
77
  ></div>
77
78
 
79
+ <d-page-image
80
+ v-else-if="token.tag === 'image'"
81
+ :content="token.content"
82
+ :caption-html="token.captionHtml"
83
+ />
84
+
78
85
  <p
79
86
  v-else-if="token.tag === 'p'"
80
87
  v-html="token.content"
@@ -6,15 +6,13 @@
6
6
 
7
7
  import {
8
8
  h,
9
+ Teleport,
9
10
  ref, computed,
10
11
  onMounted, onBeforeUnmount
11
12
  } from 'vue'
12
13
 
13
14
  import { useColorize } from 'q-colorize-mixin'
14
15
 
15
- import { dom } from 'quasar'
16
- const { offset } = dom
17
-
18
16
  import './QZoom.sass'
19
17
 
20
18
  // @
@@ -34,7 +32,12 @@ export default {
34
32
  initialScaleText: { type: Number, default: 100, validator: v => v >= 50 && v <= 500 },
35
33
  noCenter: Boolean,
36
34
  noWheelScale: Boolean,
37
- noEscClose: Boolean
35
+ noEscClose: Boolean,
36
+ showCloseButton: Boolean,
37
+ closeButtonLabel: {
38
+ type: String,
39
+ default: 'Close zoom'
40
+ }
38
41
  },
39
42
 
40
43
  setup (props, { emit, slots }) {
@@ -183,13 +186,29 @@ export default {
183
186
  }
184
187
  }
185
188
 
189
+ const onOverlayClick = (e) => {
190
+ if (isZoomed.value) {
191
+ hide()
192
+
193
+ e.preventDefault()
194
+ }
195
+ }
196
+
197
+ const stopEvent = (e) => {
198
+ e.stopPropagation()
199
+ }
200
+
186
201
  const getPosition = () => {
187
- const position = offset(vComponent.value)
202
+ const rect = vComponent.value.getBoundingClientRect()
203
+ const position = {
204
+ left: rect.left,
205
+ top: rect.top
206
+ }
188
207
 
189
208
  position.left = position.left + 'px'
190
209
  position.top = position.top + 'px'
191
- position.width = vComponent.value.clientWidth + 'px'
192
- position.height = vComponent.value.clientHeight + 'px'
210
+ position.width = rect.width + 'px'
211
+ position.height = rect.height + 'px'
193
212
 
194
213
  return position
195
214
  }
@@ -245,7 +264,34 @@ export default {
245
264
  fontSize: props.scaleText && !props.scale && `${scaleTextValue.value}%`
246
265
  }
247
266
  }), [
248
- slot && slot({ zoomed: isZoomed.value })
267
+ h('div', {
268
+ class: 'q-zoom__content-inner',
269
+ onClick: stopEvent
270
+ }, [
271
+ slot && slot({ zoomed: isZoomed.value })
272
+ ])
273
+ ])
274
+ }
275
+
276
+ const __renderCloseButton = () => {
277
+ if (!props.showCloseButton) {
278
+ return null
279
+ }
280
+
281
+ return h('button', {
282
+ type: 'button',
283
+ class: 'q-zoom__close-button',
284
+ 'aria-label': props.closeButtonLabel,
285
+ title: props.closeButtonLabel,
286
+ onClick: (e) => {
287
+ stopEvent(e)
288
+ hide()
289
+ }
290
+ }, [
291
+ h('span', {
292
+ class: 'q-zoom__close-icon',
293
+ 'aria-hidden': 'true'
294
+ }, '×')
249
295
  ])
250
296
  }
251
297
 
@@ -256,13 +302,18 @@ export default {
256
302
 
257
303
  return h('div', Colorize.setBackgroundColor(bgColor.value, {
258
304
  class: 'q-zoom__overlay' +
259
- (props.manual ? '' : ' q-zoom__zoom-out')
305
+ (props.manual ? '' : ' q-zoom__zoom-out'),
306
+ onClick: onOverlayClick
260
307
  }), [
308
+ __renderCloseButton(),
261
309
  __renderOverlayContent()
262
310
  ])
263
311
  }
264
312
 
265
- return () => h('div', {
313
+ return () => {
314
+ const overlay = __renderOverlay()
315
+
316
+ return h('div', {
266
317
  class: 'q-zoom' +
267
318
  (props.manual ? '' : ' q-zoom__zoom-in'),
268
319
 
@@ -270,9 +321,12 @@ export default {
270
321
  onWheel: wheelEvent,
271
322
 
272
323
  ref: vComponent
273
- }, [
274
- slots.default && slots.default({ zoomed: isZoomed.value }),
275
- __renderOverlay()
276
- ])
324
+ }, [
325
+ slots.default && slots.default({ zoomed: isZoomed.value }),
326
+ overlay
327
+ ? h(Teleport, { to: 'body' }, overlay)
328
+ : null
329
+ ])
330
+ }
277
331
  }
278
332
  }
@@ -22,19 +22,54 @@ $box-shadow: 1px 1px 7px 1px rgba(0,0,0,.2) !default
22
22
  padding: 0
23
23
  margin: 0
24
24
  z-index: 6000
25
+ overflow: hidden
25
26
 
26
27
  &__content
27
- position: relative
28
- display: block
28
+ position: absolute
29
+ display: flex
30
+ align-items: center
31
+ justify-content: center
29
32
  transition: all .5s cubic-bezier(.2,0,.2,1)
30
33
  text-align: center
31
- vertical-align: middle
32
34
  width: 100%
33
35
  height: 0
34
36
  max-width: 100%
35
37
  max-height: 100%
36
38
  overflow: hidden
37
39
 
40
+ & > *
41
+ max-width: 100%
42
+ max-height: 100%
43
+
44
+ &__content-inner
45
+ display: inline-flex
46
+ align-items: center
47
+ justify-content: center
48
+ max-width: 100%
49
+ max-height: 100%
50
+
51
+ &__close-button
52
+ position: absolute
53
+ top: 24px
54
+ right: 24px
55
+ width: 56px
56
+ height: 56px
57
+ display: inline-flex
58
+ align-items: center
59
+ justify-content: center
60
+ border: 0
61
+ border-radius: 999px
62
+ background: rgba(255,255,255,.12)
63
+ color: #fff
64
+ cursor: pointer
65
+ z-index: 6001
66
+ box-shadow: $box-shadow
67
+ backdrop-filter: blur(8px)
68
+
69
+ &__close-icon
70
+ font-size: 34px
71
+ line-height: 1
72
+
38
73
  &__no-center
39
74
  text-align: unset
40
75
  vertical-align: unset
@@ -215,6 +215,97 @@ const parseTokenAttributes = (element) => {
215
215
  return parsed
216
216
  }
217
217
 
218
+ const decodeHtmlEntities = (value = '') => {
219
+ return String(value)
220
+ .replace(/&quot;/g, '"')
221
+ .replace(/&#39;|&apos;/g, "'")
222
+ .replace(/&lt;/g, '<')
223
+ .replace(/&gt;/g, '>')
224
+ .replace(/&amp;/g, '&')
225
+ }
226
+
227
+ const escapeHtml = (value = '') => {
228
+ return String(value)
229
+ .replace(/&/g, '&amp;')
230
+ .replace(/</g, '&lt;')
231
+ .replace(/>/g, '&gt;')
232
+ .replace(/"/g, '&quot;')
233
+ .replace(/'/g, '&#39;')
234
+ }
235
+
236
+ const extractTagAttributes = (html = '', tagName = '') => {
237
+ const match = String(html).match(new RegExp(`<${tagName}\\b([^>]*)\\/?>(?![\\s\\S]*<${tagName}\\b)`, 'i'))
238
+
239
+ if (!match) {
240
+ return {}
241
+ }
242
+
243
+ return parseCustomTagAttributes(match[1])
244
+ }
245
+
246
+ const createImageTokenData = (mediaHtml = '', options = {}) => {
247
+ const {
248
+ captionHtml = '',
249
+ fallbackCaptionFromAlt = false
250
+ } = options
251
+ const attrs = extractTagAttributes(mediaHtml, 'img')
252
+ const alt = decodeHtmlEntities(attrs.alt || '')
253
+ const title = decodeHtmlEntities(attrs.title || '')
254
+
255
+ return {
256
+ tag: 'image',
257
+ content: String(mediaHtml).trim(),
258
+ alt,
259
+ title,
260
+ captionHtml: captionHtml !== ''
261
+ ? captionHtml
262
+ : (fallbackCaptionFromAlt ? escapeHtml(alt) : '')
263
+ }
264
+ }
265
+
266
+ const parseStandaloneImageToken = (content = '') => {
267
+ const trimmed = String(content).trim()
268
+
269
+ if (!trimmed.match(/^<img\b[^>]*\/?>(?:\s*)$/i)) {
270
+ return null
271
+ }
272
+
273
+ return createImageTokenData(trimmed, {
274
+ fallbackCaptionFromAlt: true
275
+ })
276
+ }
277
+
278
+ const parseFigureImageToken = (content = '') => {
279
+ const trimmed = String(content).trim()
280
+
281
+ if (!trimmed.match(/^<figure\b[\s\S]*<\/figure>$/i)) {
282
+ return null
283
+ }
284
+
285
+ const figureBody = trimmed
286
+ .replace(/^<figure\b[^>]*>/i, '')
287
+ .replace(/<\/figure>$/i, '')
288
+ .trim()
289
+ const figcaptionMatch = figureBody.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/i)
290
+ const mediaBody = figcaptionMatch
291
+ ? figureBody.replace(figcaptionMatch[0], '').trim()
292
+ : figureBody
293
+ const pictureMatch = mediaBody.match(/^<picture\b[\s\S]*?<\/picture>$/i)
294
+ const imgMatch = pictureMatch
295
+ ? pictureMatch[0].match(/<img\b[^>]*\/?>/i)
296
+ : mediaBody.match(/^<img\b[^>]*\/?>$/i)
297
+
298
+ if (!imgMatch) {
299
+ return null
300
+ }
301
+
302
+ const mediaHtml = pictureMatch ? pictureMatch[0] : imgMatch[0]
303
+
304
+ return createImageTokenData(mediaHtml, {
305
+ captionHtml: figcaptionMatch ? figcaptionMatch[1].trim() : ''
306
+ })
307
+ }
308
+
218
309
  const parseFenceLanguage = (raw = '') => {
219
310
  const cleaned = String(raw)
220
311
  .replace(/:[^;]*;/g, ' ')
@@ -520,7 +611,7 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
520
611
  }
521
612
 
522
613
  switch (element.type) {
523
- case 'inline':
614
+ case 'inline': {
524
615
  const anchorId = getHeadingAnchorId(markdown, tag, element, markdownEnv, parserState)
525
616
 
526
617
  if (expandableMap.has(element.content.trim())) {
@@ -549,6 +640,18 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
549
640
  break
550
641
  }
551
642
 
643
+ if (tag === 'p') {
644
+ const imageToken = parseStandaloneImageToken(element.content)
645
+
646
+ if (imageToken !== null) {
647
+ tokens.push({
648
+ ...imageToken,
649
+ map: element.map
650
+ })
651
+ break
652
+ }
653
+ }
654
+
552
655
  tokens.push({
553
656
  tag,
554
657
  map: element.map,
@@ -557,6 +660,7 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
557
660
  info: element.info
558
661
  })
559
662
  break
663
+ }
560
664
 
561
665
  case 'fence':
562
666
  pushSourceCodeToken(tokens, element, parserState)
@@ -569,36 +673,59 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
569
673
  })
570
674
  break
571
675
 
572
- case 'html_block':
676
+ case 'html_block': {
677
+ const figureImageToken = parseFigureImageToken(element.content)
678
+
679
+ if (figureImageToken !== null) {
680
+ tokens.push({
681
+ ...figureImageToken,
682
+ map: element.map
683
+ })
684
+ break
685
+ }
686
+
573
687
  tokens.push({
574
688
  tag: 'html',
575
689
  content: element.content
576
690
  })
577
691
  break
692
+ }
578
693
  }
579
- } else if (level === 1) {
694
+ } else if (level > 0) {
580
695
  const parent = tokens[tokens.length - 1]
581
696
 
582
697
  switch (element.type) {
583
698
  case 'bullet_list_open':
584
- tokens.push({
585
- tag: 'ul',
586
- content: ''
587
- })
699
+ if (level === 1) {
700
+ tokens.push({
701
+ tag: 'ul',
702
+ content: ''
703
+ })
704
+ } else {
705
+ parent.content += '<ul>'
706
+ }
588
707
  break
589
708
 
590
709
  case 'ordered_list_open':
591
- tokens.push({
592
- tag: 'ol',
593
- content: ''
594
- })
710
+ if (level === 1) {
711
+ tokens.push({
712
+ tag: 'ol',
713
+ content: ''
714
+ })
715
+ } else {
716
+ parent.content += '<ol>'
717
+ }
595
718
  break
596
719
 
597
720
  case 'table_open':
598
- tokens.push({
599
- tag: 'table',
600
- content: ''
601
- })
721
+ if (level === 1) {
722
+ tokens.push({
723
+ tag: 'table',
724
+ content: ''
725
+ })
726
+ } else {
727
+ parent.content += '<table>'
728
+ }
602
729
  break
603
730
 
604
731
  case 'list_item_open':
@@ -636,6 +763,24 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
636
763
  parent.content += '</li>'
637
764
  break
638
765
 
766
+ case 'bullet_list_close':
767
+ if (level > 1) {
768
+ parent.content += '</ul>'
769
+ }
770
+ break
771
+
772
+ case 'ordered_list_close':
773
+ if (level > 1) {
774
+ parent.content += '</ol>'
775
+ }
776
+ break
777
+
778
+ case 'table_close':
779
+ if (level > 1) {
780
+ parent.content += '</table>'
781
+ }
782
+ break
783
+
639
784
  case 'thead_close':
640
785
  parent.content += '</thead>'
641
786
  break
@@ -2,15 +2,33 @@
2
2
 
3
3
  Use standard Markdown images to place screenshots, illustrations, diagrams, and product UI directly inside the reading flow.
4
4
 
5
+ When an image appears on its own line, Docsector renders it as a block figure with click-to-zoom behavior.
6
+ For plain Markdown images, the label is used as both the visible caption and the alt text, matching GitBook's block image model.
7
+
5
8
  ## Markdown Syntax
6
9
 
7
10
  ```markdown
8
11
  ![Dashboard overview](/images/example-dashboard.png)
9
12
  ```
10
13
 
14
+ ## Separate Alt Text And Caption
15
+
16
+ Use raw HTML when the accessibility text and the visible caption should be different, or when you need `<picture>` / `<source>` for light and dark mode variants.
17
+
18
+ ```html
19
+ <figure>
20
+ <picture>
21
+ <source srcset="/images/logo-dark.png" media="(prefers-color-scheme: dark)">
22
+ <img src="/images/logo-light.png" alt="Docsector Reader logo">
23
+ </picture>
24
+ <figcaption><p>Docsector Reader brand mark</p></figcaption>
25
+ </figure>
26
+ ```
27
+
11
28
  ## Good Practices
12
29
 
13
- - Write descriptive alt text so the image still has meaning without the visual.
30
+ - Write labels that still make sense without the visual, because plain Markdown uses the same text for caption and alt.
31
+ - Use `<figure>` with `<figcaption>` when the visible caption should differ from the accessibility text.
14
32
  - Prefer absolute site paths such as `/images/...` for assets stored in `public/images/`.
15
33
  - Use screenshots to support the text, not replace the explanation.
16
- - When default Markdown syntax is not enough, raw HTML can be used for custom wrappers or sizing.
34
+ - Click standalone block images to zoom; inline images that stay inside a paragraph remain on the inline path.
@@ -1,11 +1,19 @@
1
1
  ## Showcase
2
2
 
3
- ### Basic Image
3
+ ### Markdown Image With Caption
4
4
 
5
5
  ![Docsector Reader logo](/images/logo.png)
6
6
 
7
+ The label above is rendered as the visible caption and remains the image alt text.
8
+
7
9
  ### Image with Title
8
10
 
9
11
  ![Docsector Reader logo](/images/logo.png "Docsector Reader")
10
12
 
11
- The alt text remains part of the content model, so the page still communicates meaning if the image cannot be seen.
13
+ The title attribute is preserved on the rendered image, while the block still keeps the same visible caption.
14
+
15
+ ### Raw HTML Figure With Separate Alt And Caption
16
+
17
+ <figure><img src="/images/logo.png" alt="Docsector Reader brand mark"><figcaption><p>Docsector Reader logo used as a standalone brand image.</p></figcaption></figure>
18
+
19
+ Click any standalone image block on this page to open the zoom view.