@docsector/docsector-reader 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
73
73
  - 🧬 **Scaffolded Homepage Override Wiring** — New consumer projects automatically wire `virtual:docsector-homepage-override` into i18n message building
74
74
  - 📖 **Expandable Markdown Sections** — Use `<d-expandable title="...">...</d-expandable>` to collapse secondary content while keeping rich Markdown support inside the body
75
75
  - 📎 **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
76
+ - 🌐 **Embedded URL Blocks** — Use `<d-embedded-url url="https://...">...</d-embedded-url>` to render curated embeds for YouTube, Vimeo, Spotify, and CodePen with a safe link-card fallback for unsupported URLs
76
77
  - 🧭 **Quick Links Custom Element** — Use `<d-quick-links>` and `<d-quick-link>` in Markdown to render rich home navigation cards
77
78
  - 🗂️ **API Catalog Well-Known** — Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
78
79
  - 🗃️ **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
@@ -1036,6 +1037,21 @@ Notes:
1036
1037
  - The block body is rendered as an inline Markdown caption.
1037
1038
  - External URLs also work, so the same syntax can later point to R2 or another CDN without changing the page structure.
1038
1039
 
1040
+ ### Embedded URL Blocks
1041
+
1042
+ ```html
1043
+ <d-embedded-url url="https://www.youtube.com/watch?v=M7lc1UVf-VE" title="YouTube player demo">
1044
+ Optional caption rendered as inline Markdown.
1045
+ </d-embedded-url>
1046
+ ```
1047
+
1048
+ Notes:
1049
+
1050
+ - Supported providers currently include YouTube, Vimeo, Spotify, and CodePen.
1051
+ - The block preserves the original query string, so provider options such as `autoplay=1&loop=1` keep working when supported by the destination service.
1052
+ - Unsupported or private URLs fall back to a safe external-link card instead of attempting a generic iframe.
1053
+ - Raw HTML remains the escape hatch when you need a provider outside the curated list or full manual iframe control.
1054
+
1039
1055
  ---
1040
1056
 
1041
1057
 
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.4.0'
26
+ const VERSION = '3.5.0'
27
27
 
28
28
  const HELP = `
29
29
  Docsector Reader v${VERSION}
package/jsconfig.json CHANGED
@@ -13,5 +13,5 @@
13
13
  "stores/*": ["src/stores/*"]
14
14
  }
15
15
  },
16
- "exclude": ["node_modules", "dist"]
16
+ "exclude": ["node_modules", "dist", "tmp"]
17
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "3.4.0",
3
+ "version": "3.5.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",
@@ -0,0 +1,375 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+
5
+ import { resolveEmbeddedUrl } from '../composables/useEmbeddedUrl'
6
+
7
+ defineOptions({
8
+ name: 'DPageEmbeddedUrl'
9
+ })
10
+
11
+ const props = defineProps({
12
+ url: {
13
+ type: String,
14
+ default: ''
15
+ },
16
+ title: {
17
+ type: String,
18
+ default: ''
19
+ },
20
+ caption: {
21
+ type: String,
22
+ default: ''
23
+ }
24
+ })
25
+
26
+ const { t } = useI18n()
27
+
28
+ const resolved = computed(() => {
29
+ return resolveEmbeddedUrl(props.url, {
30
+ title: props.title
31
+ })
32
+ })
33
+
34
+ const isEmbedded = computed(() => {
35
+ return resolved.value.mode === 'embed' && resolved.value.embedSrc !== ''
36
+ })
37
+
38
+ const isCompactEmbed = computed(() => {
39
+ return isEmbedded.value && resolved.value.provider === 'spotify'
40
+ })
41
+
42
+ const displayTitle = computed(() => {
43
+ return resolved.value.title || props.title || resolved.value.providerLabel || props.url
44
+ })
45
+
46
+ const displayUrl = computed(() => {
47
+ return resolved.value.displayUrl || props.url
48
+ })
49
+
50
+ const compactUrl = computed(() => {
51
+ return String(displayUrl.value || '').replace(/^https?:\/\//i, '')
52
+ })
53
+
54
+ const frameStyle = computed(() => {
55
+ const style = {}
56
+
57
+ if (resolved.value.aspectRatio) {
58
+ style.aspectRatio = resolved.value.aspectRatio
59
+ } else {
60
+ style.aspectRatio = 'auto'
61
+ }
62
+
63
+ if (resolved.value.frameHeight > 0) {
64
+ style.height = `${resolved.value.frameHeight}px`
65
+ style.minHeight = `${resolved.value.frameHeight}px`
66
+ }
67
+
68
+ return style
69
+ })
70
+
71
+ const openHref = computed(() => {
72
+ return resolved.value.canonicalUrl || props.url
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <div
78
+ class="d-page-embedded-url"
79
+ :class="{
80
+ 'd-page-embedded-url--compact': isCompactEmbed,
81
+ 'd-page-embedded-url--embedded': isEmbedded,
82
+ 'd-page-embedded-url--fallback': !isEmbedded
83
+ }"
84
+ >
85
+ <div
86
+ v-if="isEmbedded"
87
+ class="d-page-embedded-url__frame-shell"
88
+ :style="frameStyle"
89
+ >
90
+ <iframe
91
+ class="d-page-embedded-url__frame"
92
+ :src="resolved.embedSrc"
93
+ :title="displayTitle"
94
+ :allow="resolved.allow || undefined"
95
+ :allowfullscreen="resolved.allowFullscreen"
96
+ loading="lazy"
97
+ referrerpolicy="strict-origin-when-cross-origin"
98
+ ></iframe>
99
+ </div>
100
+
101
+ <div
102
+ v-if="!isCompactEmbed || caption"
103
+ class="d-page-embedded-url__body"
104
+ >
105
+ <template v-if="!isCompactEmbed">
106
+ <div
107
+ v-if="!isEmbedded"
108
+ class="d-page-embedded-url__media"
109
+ aria-hidden="true"
110
+ >
111
+ <q-icon
112
+ :name="resolved.icon || 'link'"
113
+ size="28px"
114
+ />
115
+ </div>
116
+
117
+ <div class="d-page-embedded-url__content">
118
+ <div
119
+ v-if="resolved.providerLabel"
120
+ class="d-page-embedded-url__provider"
121
+ >
122
+ <q-icon
123
+ :name="resolved.icon || 'link'"
124
+ size="16px"
125
+ />
126
+ <span>{{ resolved.providerLabel }}</span>
127
+ </div>
128
+
129
+ <div class="d-page-embedded-url__title">{{ displayTitle }}</div>
130
+
131
+ <div
132
+ v-if="compactUrl"
133
+ class="d-page-embedded-url__url"
134
+ >{{ compactUrl }}</div>
135
+
136
+ <div
137
+ v-if="caption"
138
+ class="d-page-embedded-url__caption"
139
+ v-html="caption"
140
+ ></div>
141
+ </div>
142
+
143
+ <div class="d-page-embedded-url__actions">
144
+ <q-btn
145
+ no-caps
146
+ unelevated
147
+ padding="8px 12px"
148
+ class="d-page-embedded-url__action-button"
149
+ icon="open_in_new"
150
+ :label="t('page.file.open')"
151
+ :href="openHref"
152
+ target="_blank"
153
+ rel="noopener noreferrer"
154
+ />
155
+ </div>
156
+ </template>
157
+
158
+ <div
159
+ v-else-if="caption"
160
+ class="d-page-embedded-url__compact-caption"
161
+ v-html="caption"
162
+ ></div>
163
+ </div>
164
+ </div>
165
+ </template>
166
+
167
+ <style lang="sass">
168
+ body.body--light
169
+ --d-page-embedded-url-bg: linear-gradient(180deg, #f7faf5 0%, #ffffff 100%)
170
+ --d-page-embedded-url-border: rgba(52, 85, 54, 0.14)
171
+ --d-page-embedded-url-shadow: rgba(52, 85, 54, 0.08)
172
+ --d-page-embedded-url-frame-bg: rgba(44, 60, 45, 0.04)
173
+ --d-page-embedded-url-media-bg: rgba(255, 255, 255, 0.92)
174
+ --d-page-embedded-url-media-border: rgba(52, 85, 54, 0.08)
175
+ --d-page-embedded-url-accent: #30583a
176
+ --d-page-embedded-url-meta: #56715c
177
+ --d-page-embedded-url-caption: #405148
178
+ --d-page-embedded-url-action-border: rgba(52, 85, 54, 0.18)
179
+ --d-page-embedded-url-action-hover: rgba(52, 85, 54, 0.06)
180
+
181
+ body.body--dark
182
+ --d-page-embedded-url-bg: linear-gradient(180deg, rgba(226, 255, 234, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%)
183
+ --d-page-embedded-url-border: rgba(214, 245, 224, 0.14)
184
+ --d-page-embedded-url-shadow: rgba(0, 0, 0, 0.3)
185
+ --d-page-embedded-url-frame-bg: rgba(255, 255, 255, 0.03)
186
+ --d-page-embedded-url-media-bg: rgba(255, 255, 255, 0.06)
187
+ --d-page-embedded-url-media-border: rgba(255, 255, 255, 0.1)
188
+ --d-page-embedded-url-accent: #b9e2c5
189
+ --d-page-embedded-url-meta: rgba(255, 255, 255, 0.72)
190
+ --d-page-embedded-url-caption: rgba(255, 255, 255, 0.9)
191
+ --d-page-embedded-url-action-border: rgba(214, 245, 224, 0.16)
192
+ --d-page-embedded-url-action-hover: rgba(214, 245, 224, 0.08)
193
+
194
+ .d-page-embedded-url
195
+ margin: 1.5rem 0
196
+ border: 1px solid var(--d-page-embedded-url-border)
197
+ border-radius: 18px
198
+ background: var(--d-page-embedded-url-bg)
199
+ box-shadow: 0 16px 36px var(--d-page-embedded-url-shadow)
200
+ overflow: hidden
201
+
202
+ .d-page-embedded-url__frame-shell
203
+ position: relative
204
+ width: 100%
205
+ min-height: 240px
206
+ aspect-ratio: 16 / 9
207
+ background: var(--d-page-embedded-url-frame-bg)
208
+ border-bottom: 1px solid var(--d-page-embedded-url-border)
209
+
210
+ .d-page-embedded-url__frame
211
+ position: absolute
212
+ inset: 0
213
+ width: 100%
214
+ height: 100%
215
+ border: 0
216
+ background: transparent
217
+
218
+ .d-page-embedded-url__body
219
+ display: flex
220
+ align-items: center
221
+ gap: 1rem
222
+ padding: 0.95rem 1rem
223
+
224
+ .d-page-embedded-url--embedded
225
+ .d-page-embedded-url__body
226
+ padding-top: 1.25rem
227
+
228
+ .d-page-embedded-url__content
229
+ padding-top: 0.1rem
230
+
231
+ .d-page-embedded-url--compact
232
+ border: 0
233
+ border-radius: 0
234
+ background: transparent
235
+ box-shadow: none
236
+ overflow: visible
237
+
238
+ .d-page-embedded-url__frame-shell
239
+ border: 0
240
+ background: transparent
241
+
242
+ .d-page-embedded-url__body
243
+ display: block
244
+ padding: 0.6rem 0 0
245
+
246
+ .d-page-embedded-url__compact-caption
247
+ margin-top: 0.65rem
248
+ color: var(--d-page-embedded-url-caption)
249
+
250
+ > :first-child
251
+ margin-top: 0
252
+
253
+ > :last-child
254
+ margin-bottom: 0
255
+
256
+ .d-page-embedded-url__media
257
+ width: 56px
258
+ height: 56px
259
+ flex: 0 0 56px
260
+ display: flex
261
+ align-items: center
262
+ justify-content: center
263
+ border-radius: 16px
264
+ background: var(--d-page-embedded-url-media-bg)
265
+ box-shadow: inset 0 0 0 1px var(--d-page-embedded-url-media-border)
266
+ color: var(--d-page-embedded-url-accent)
267
+
268
+ .d-page-embedded-url__content
269
+ min-width: 0
270
+ flex: 1 1 auto
271
+
272
+ .d-page-embedded-url__provider
273
+ display: inline-flex
274
+ align-items: center
275
+ gap: 0.35rem
276
+ margin-bottom: 0.35rem
277
+ color: var(--d-page-embedded-url-meta)
278
+ font-size: 0.82rem
279
+ font-weight: 700
280
+ line-height: 1
281
+ text-transform: uppercase
282
+ letter-spacing: 0.06em
283
+
284
+ .d-page-embedded-url__title
285
+ font-size: 1rem
286
+ font-weight: 700
287
+ line-height: 1.4
288
+ word-break: break-word
289
+
290
+ .d-page-embedded-url__url
291
+ margin-top: 0.2rem
292
+ color: var(--d-page-embedded-url-meta)
293
+ font-size: 0.84rem
294
+ line-height: 1.35
295
+ word-break: break-all
296
+
297
+ .d-page-embedded-url__caption
298
+ margin-top: 0.45rem
299
+ color: var(--d-page-embedded-url-caption)
300
+
301
+ > :first-child
302
+ margin-top: 0
303
+
304
+ > :last-child
305
+ margin-bottom: 0
306
+
307
+ .d-page-embedded-url__actions
308
+ flex: 0 0 auto
309
+ display: flex
310
+ align-items: center
311
+ align-self: stretch
312
+
313
+ .d-page-embedded-url__action-button
314
+ min-height: 40px
315
+ border-radius: 10px
316
+ border: 1px solid var(--d-page-embedded-url-action-border)
317
+ background: transparent !important
318
+ color: var(--d-page-embedded-url-accent) !important
319
+ transition: transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease
320
+
321
+ .q-btn__content
322
+ gap: 0.45rem
323
+ font-size: 0.9rem
324
+ font-weight: 600
325
+ line-height: 1
326
+
327
+ .q-focus-helper,
328
+ &:before,
329
+ &:after
330
+ display: none
331
+
332
+ &:hover
333
+ transform: translateY(-1px)
334
+ background: var(--d-page-embedded-url-action-hover) !important
335
+
336
+ &:focus-visible
337
+ outline: 2px solid var(--d-page-embedded-url-accent)
338
+ outline-offset: 2px
339
+
340
+ .d-page-embedded-url--fallback
341
+ .d-page-embedded-url__body
342
+ padding-block: 1.05rem
343
+
344
+ @media (max-width: 720px)
345
+ .d-page-embedded-url__frame-shell
346
+ min-height: 200px
347
+
348
+ .d-page-embedded-url__body
349
+ flex-wrap: wrap
350
+ align-items: flex-start
351
+
352
+ .d-page-embedded-url__actions
353
+ width: 100%
354
+ justify-content: flex-start
355
+
356
+ .d-page-embedded-url__action-button
357
+ width: 100%
358
+ justify-content: center
359
+
360
+ @media (max-width: 520px)
361
+ .d-page-embedded-url__body
362
+ gap: 0.85rem
363
+ padding: 0.9rem
364
+
365
+ .d-page-embedded-url__media
366
+ width: 48px
367
+ height: 48px
368
+ flex-basis: 48px
369
+
370
+ .d-page-embedded-url__provider
371
+ font-size: 0.76rem
372
+
373
+ .d-page-embedded-url__title
374
+ font-size: 0.95rem
375
+ </style>
@@ -24,6 +24,7 @@ import DMermaidDiagram from './DMermaidDiagram.vue'
24
24
  import DPageBlockquote from './DPageBlockquote.vue'
25
25
  import DPageImage from './DPageImage.vue'
26
26
  import DPageFile from './DPageFile.vue'
27
+ import DPageEmbeddedUrl from './DPageEmbeddedUrl.vue'
27
28
  import DQuickLinks from './DQuickLinks.vue'
28
29
  import DPageExpandable from './DPageExpandable.vue'
29
30
  </script>
@@ -105,6 +106,13 @@ import DPageExpandable from './DPageExpandable.vue'
105
106
  :caption="token.caption"
106
107
  />
107
108
 
109
+ <d-page-embedded-url
110
+ v-else-if="token.tag === 'embedded-url'"
111
+ :url="token.url"
112
+ :title="token.title"
113
+ :caption="token.caption"
114
+ />
115
+
108
116
  <d-page-source-code
109
117
  v-else-if="token.tag === 'code'"
110
118
  :index="id + token.codeIndex"
@@ -16,6 +16,7 @@ const ALERT_MESSAGE_TYPES = new Set([
16
16
  const QUICK_LINKS_MARKER_PREFIX = '@@DOCSECTOR_QUICK_LINKS_'
17
17
  const EXPANDABLE_MARKER_PREFIX = '@@DOCSECTOR_EXPANDABLE_'
18
18
  const FILE_MARKER_PREFIX = '@@DOCSECTOR_FILE_'
19
+ const EMBEDDED_URL_MARKER_PREFIX = '@@DOCSECTOR_EMBEDDED_URL_'
19
20
  const CODE_SEGMENT_MARKER_PREFIX = '@@DOCSECTOR_CODE_SEGMENT_'
20
21
  const MATH_KATEX_OPTIONS = {
21
22
  throwOnError: false,
@@ -274,6 +275,41 @@ const extractFileBlocks = (source = '') => {
274
275
  }
275
276
  }
276
277
 
278
+ const extractEmbeddedUrlBlocks = (source = '') => {
279
+ const map = new Map()
280
+ let index = 0
281
+
282
+ const replaceBlock = (match, rawAttrs, rawCaption = '') => {
283
+ const attrs = parseCustomTagAttributes(rawAttrs)
284
+ const url = decodeHtmlEntities(attrs.url || attrs.href || attrs.src || '').trim()
285
+
286
+ if (!url) {
287
+ return match
288
+ }
289
+
290
+ const marker = `${EMBEDDED_URL_MARKER_PREFIX}${index}@@`
291
+ index++
292
+
293
+ map.set(marker, {
294
+ url,
295
+ title: decodeHtmlEntities(attrs.title || '').trim(),
296
+ caption: String(rawCaption).trim()
297
+ })
298
+
299
+ return `\n${marker}\n`
300
+ }
301
+
302
+ const replacedSelfClosing = String(source).replace(/<d-embedded-url\b([^>]*)\/\s*>/gi, (match, rawAttrs) => {
303
+ return replaceBlock(match, rawAttrs)
304
+ })
305
+ const replaced = replacedSelfClosing.replace(/<d-embedded-url\b([^>]*)>([\s\S]*?)<\/d-embedded-url>/gi, replaceBlock)
306
+
307
+ return {
308
+ source: replaced,
309
+ embeddedUrlMap: map
310
+ }
311
+ }
312
+
277
313
  const parseFenceAttributes = (raw = '') => {
278
314
  const parsed = {}
279
315
  const pattern = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s;]+))/g
@@ -567,6 +603,7 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
567
603
 
568
604
  const { source: sourceWithQuickLinks, quickLinksMap } = extractQuickLinksBlocks(sourceWithExpandables)
569
605
  const { source: sourceWithFiles, fileMap } = extractFileBlocks(sourceWithQuickLinks)
606
+ const { source: sourceWithEmbeddedUrls, embeddedUrlMap } = extractEmbeddedUrlBlocks(sourceWithFiles)
570
607
 
571
608
  fileMap.forEach((data, marker) => {
572
609
  fileMap.set(marker, {
@@ -575,10 +612,17 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
575
612
  })
576
613
  })
577
614
 
615
+ embeddedUrlMap.forEach((data, marker) => {
616
+ embeddedUrlMap.set(marker, {
617
+ ...data,
618
+ caption: restoreShieldedCodeSegments(data.caption, codeSegmentsMap)
619
+ })
620
+ })
621
+
578
622
  const markdown = createMarkdownBlockParser()
579
623
  const markdownInline = createMarkdownInlineParser()
580
624
  const markdownEnv = {}
581
- const parsed = markdown.parse(restoreShieldedCodeSegments(sourceWithFiles, codeSegmentsMap), markdownEnv)
625
+ const parsed = markdown.parse(restoreShieldedCodeSegments(sourceWithEmbeddedUrls, codeSegmentsMap), markdownEnv)
582
626
  const tokens = []
583
627
 
584
628
  let level = 0
@@ -762,6 +806,21 @@ export const tokenizePageSectionSource = (source = '', options = {}) => {
762
806
  break
763
807
  }
764
808
 
809
+ if (embeddedUrlMap.has(element.content.trim())) {
810
+ const data = embeddedUrlMap.get(element.content.trim())
811
+
812
+ tokens.push({
813
+ tag: 'embedded-url',
814
+ map: element.map,
815
+ url: data.url,
816
+ title: data.title,
817
+ caption: data.caption !== ''
818
+ ? markdownInline.renderInline(data.caption, markdownEnv)
819
+ : ''
820
+ })
821
+ break
822
+ }
823
+
765
824
  if (tag === 'p') {
766
825
  const imageToken = parseStandaloneImageToken(element.content)
767
826
 
@@ -0,0 +1,365 @@
1
+ const DEFAULT_EMBED = {
2
+ mode: 'link',
3
+ provider: 'link',
4
+ kind: 'link',
5
+ icon: 'link',
6
+ title: '',
7
+ providerLabel: '',
8
+ canonicalUrl: '',
9
+ displayUrl: '',
10
+ embedSrc: '',
11
+ aspectRatio: '',
12
+ frameHeight: 0,
13
+ allow: '',
14
+ allowFullscreen: false
15
+ }
16
+
17
+ const YOUTUBE_HOSTS = new Set([
18
+ 'youtu.be',
19
+ 'youtube.com',
20
+ 'www.youtube.com',
21
+ 'm.youtube.com',
22
+ 'music.youtube.com',
23
+ 'youtube-nocookie.com',
24
+ 'www.youtube-nocookie.com'
25
+ ])
26
+
27
+ const VIMEO_HOSTS = new Set([
28
+ 'vimeo.com',
29
+ 'www.vimeo.com',
30
+ 'player.vimeo.com'
31
+ ])
32
+
33
+ const SPOTIFY_HOSTS = new Set([
34
+ 'spotify.com',
35
+ 'www.spotify.com',
36
+ 'open.spotify.com'
37
+ ])
38
+
39
+ const CODEPEN_HOSTS = new Set([
40
+ 'codepen.io',
41
+ 'www.codepen.io'
42
+ ])
43
+
44
+ const SPOTIFY_PROVIDER_BY_KIND = {
45
+ track: {
46
+ kind: 'audio',
47
+ icon: 'music_note',
48
+ title: 'Spotify track',
49
+ frameHeight: 152
50
+ },
51
+ episode: {
52
+ kind: 'audio',
53
+ icon: 'podcasts',
54
+ title: 'Spotify episode',
55
+ frameHeight: 232
56
+ },
57
+ artist: {
58
+ kind: 'music',
59
+ icon: 'person',
60
+ title: 'Spotify artist',
61
+ frameHeight: 352
62
+ },
63
+ album: {
64
+ kind: 'music',
65
+ icon: 'album',
66
+ title: 'Spotify album',
67
+ frameHeight: 352
68
+ },
69
+ playlist: {
70
+ kind: 'music',
71
+ icon: 'queue_music',
72
+ title: 'Spotify playlist',
73
+ frameHeight: 352
74
+ },
75
+ show: {
76
+ kind: 'podcast',
77
+ icon: 'radio',
78
+ title: 'Spotify show',
79
+ frameHeight: 352
80
+ }
81
+ }
82
+
83
+ const createFallbackResult = (rawUrl = '', title = '') => {
84
+ const trimmed = String(rawUrl || '').trim()
85
+ const normalized = parseHttpUrl(trimmed)
86
+
87
+ return {
88
+ ...DEFAULT_EMBED,
89
+ title: title || normalized?.hostname || trimmed,
90
+ providerLabel: normalized?.hostname || '',
91
+ canonicalUrl: normalized?.toString() || trimmed,
92
+ displayUrl: normalized?.toString() || trimmed
93
+ }
94
+ }
95
+
96
+ const parseHttpUrl = (rawUrl = '') => {
97
+ const trimmed = String(rawUrl || '').trim()
98
+
99
+ if (!trimmed) {
100
+ return null
101
+ }
102
+
103
+ try {
104
+ const url = new URL(trimmed)
105
+
106
+ if (!['http:', 'https:'].includes(url.protocol)) {
107
+ return null
108
+ }
109
+
110
+ return url
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ const mergeSearchParams = (url, options = {}) => {
117
+ const params = new URLSearchParams(url.search)
118
+
119
+ ;(options.remove || []).forEach((key) => {
120
+ params.delete(key)
121
+ })
122
+
123
+ Object.entries(options.set || {}).forEach(([key, value]) => {
124
+ if (value === '' || value === null || value === undefined) {
125
+ params.delete(key)
126
+ return
127
+ }
128
+
129
+ params.set(key, String(value))
130
+ })
131
+
132
+ const query = params.toString()
133
+ return query ? `?${query}` : ''
134
+ }
135
+
136
+ const parseTimeToSeconds = (value = '') => {
137
+ const trimmed = String(value || '').trim()
138
+
139
+ if (!trimmed) {
140
+ return ''
141
+ }
142
+
143
+ if (/^\d+$/.test(trimmed)) {
144
+ return trimmed
145
+ }
146
+
147
+ const match = trimmed.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i)
148
+ if (!match) {
149
+ return ''
150
+ }
151
+
152
+ const hours = Number(match[1] || 0)
153
+ const minutes = Number(match[2] || 0)
154
+ const seconds = Number(match[3] || 0)
155
+ const total = (hours * 3600) + (minutes * 60) + seconds
156
+
157
+ return total > 0 ? String(total) : ''
158
+ }
159
+
160
+ const resolveYouTubeVideoId = (url) => {
161
+ const segments = url.pathname.split('/').filter(Boolean)
162
+
163
+ if (url.hostname === 'youtu.be') {
164
+ return segments[0] || ''
165
+ }
166
+
167
+ if (segments[0] === 'watch') {
168
+ return url.searchParams.get('v') || ''
169
+ }
170
+
171
+ if (['embed', 'shorts', 'live', 'v'].includes(segments[0])) {
172
+ return segments[1] || ''
173
+ }
174
+
175
+ return url.searchParams.get('v') || ''
176
+ }
177
+
178
+ const resolveYouTubeEmbed = (url, title = '') => {
179
+ const videoId = resolveYouTubeVideoId(url)
180
+
181
+ if (!videoId) {
182
+ return null
183
+ }
184
+
185
+ const start = parseTimeToSeconds(url.searchParams.get('t'))
186
+ const query = mergeSearchParams(url, {
187
+ remove: ['v', 't'],
188
+ set: {
189
+ ...(start ? { start } : {}),
190
+ ...((url.searchParams.get('loop') === '1' || url.searchParams.get('loop') === 'true') && !url.searchParams.get('playlist')
191
+ ? { playlist: videoId }
192
+ : {})
193
+ }
194
+ })
195
+
196
+ return {
197
+ ...DEFAULT_EMBED,
198
+ mode: 'embed',
199
+ provider: 'youtube',
200
+ kind: 'video',
201
+ icon: 'smart_display',
202
+ title: title || 'YouTube video',
203
+ providerLabel: 'YouTube',
204
+ canonicalUrl: url.toString(),
205
+ displayUrl: url.toString(),
206
+ embedSrc: `https://www.youtube.com/embed/${videoId}${query}`,
207
+ aspectRatio: '16 / 9',
208
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
209
+ allowFullscreen: true
210
+ }
211
+ }
212
+
213
+ const resolveVimeoVideoId = (url) => {
214
+ const segments = url.pathname.split('/').filter(Boolean)
215
+
216
+ if (segments[0] === 'video') {
217
+ return segments[1] || ''
218
+ }
219
+
220
+ return segments.find((segment) => /^\d+$/.test(segment)) || ''
221
+ }
222
+
223
+ const resolveVimeoEmbed = (url, title = '') => {
224
+ const videoId = resolveVimeoVideoId(url)
225
+
226
+ if (!videoId) {
227
+ return null
228
+ }
229
+
230
+ return {
231
+ ...DEFAULT_EMBED,
232
+ mode: 'embed',
233
+ provider: 'vimeo',
234
+ kind: 'video',
235
+ icon: 'movie',
236
+ title: title || 'Vimeo video',
237
+ providerLabel: 'Vimeo',
238
+ canonicalUrl: url.toString(),
239
+ displayUrl: url.toString(),
240
+ embedSrc: `https://player.vimeo.com/video/${videoId}${mergeSearchParams(url)}`,
241
+ aspectRatio: '16 / 9',
242
+ allow: 'autoplay; fullscreen; picture-in-picture',
243
+ allowFullscreen: true
244
+ }
245
+ }
246
+
247
+ const resolveSpotifyParts = (url) => {
248
+ const segments = url.pathname.split('/').filter(Boolean)
249
+ const embedIndex = segments[0] === 'embed' ? 1 : 0
250
+ const type = segments[embedIndex] || ''
251
+ const id = segments[embedIndex + 1] || ''
252
+
253
+ if (!SPOTIFY_PROVIDER_BY_KIND[type] || !id) {
254
+ return null
255
+ }
256
+
257
+ return { type, id }
258
+ }
259
+
260
+ const resolveSpotifyEmbed = (url, title = '') => {
261
+ const parts = resolveSpotifyParts(url)
262
+
263
+ if (parts === null) {
264
+ return null
265
+ }
266
+
267
+ const definition = SPOTIFY_PROVIDER_BY_KIND[parts.type]
268
+
269
+ return {
270
+ ...DEFAULT_EMBED,
271
+ mode: 'embed',
272
+ provider: 'spotify',
273
+ kind: definition.kind,
274
+ icon: definition.icon,
275
+ title: title || definition.title,
276
+ providerLabel: 'Spotify',
277
+ canonicalUrl: url.toString(),
278
+ displayUrl: url.toString(),
279
+ embedSrc: `https://open.spotify.com/embed/${parts.type}/${parts.id}${mergeSearchParams(url)}`,
280
+ aspectRatio: '',
281
+ frameHeight: definition.frameHeight,
282
+ allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
283
+ allowFullscreen: false
284
+ }
285
+ }
286
+
287
+ const resolveCodePenParts = (url) => {
288
+ const segments = url.pathname.split('/').filter(Boolean)
289
+
290
+ if (segments[0] === 'team') {
291
+ const team = segments[1] || ''
292
+ const penType = segments[2] || ''
293
+ const penId = segments[3] || ''
294
+
295
+ if (!team || !penId || !['pen', 'full', 'details', 'embed', 'debug'].includes(penType)) {
296
+ return null
297
+ }
298
+
299
+ return {
300
+ user: `team/${team}`,
301
+ penId
302
+ }
303
+ }
304
+
305
+ const user = segments[0] || ''
306
+ const penType = segments[1] || ''
307
+ const penId = segments[2] || ''
308
+
309
+ if (!user || !penId || !['pen', 'full', 'details', 'embed', 'debug'].includes(penType)) {
310
+ return null
311
+ }
312
+
313
+ return { user, penId }
314
+ }
315
+
316
+ const resolveCodePenEmbed = (url, title = '') => {
317
+ const parts = resolveCodePenParts(url)
318
+
319
+ if (parts === null) {
320
+ return null
321
+ }
322
+
323
+ return {
324
+ ...DEFAULT_EMBED,
325
+ mode: 'embed',
326
+ provider: 'codepen',
327
+ kind: 'code',
328
+ icon: 'code',
329
+ title: title || 'CodePen embed',
330
+ providerLabel: 'CodePen',
331
+ canonicalUrl: url.toString(),
332
+ displayUrl: url.toString(),
333
+ embedSrc: `https://codepen.io/${parts.user}/embed/${parts.penId}${mergeSearchParams(url)}`,
334
+ aspectRatio: '16 / 9',
335
+ allow: 'accelerometer; camera; clipboard-write; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; web-share',
336
+ allowFullscreen: true
337
+ }
338
+ }
339
+
340
+ export const resolveEmbeddedUrl = (rawUrl = '', options = {}) => {
341
+ const title = String(options.title || '').trim()
342
+ const normalized = parseHttpUrl(rawUrl)
343
+
344
+ if (normalized === null) {
345
+ return createFallbackResult(rawUrl, title)
346
+ }
347
+
348
+ if (YOUTUBE_HOSTS.has(normalized.hostname)) {
349
+ return resolveYouTubeEmbed(normalized, title) || createFallbackResult(rawUrl, title)
350
+ }
351
+
352
+ if (VIMEO_HOSTS.has(normalized.hostname)) {
353
+ return resolveVimeoEmbed(normalized, title) || createFallbackResult(rawUrl, title)
354
+ }
355
+
356
+ if (SPOTIFY_HOSTS.has(normalized.hostname)) {
357
+ return resolveSpotifyEmbed(normalized, title) || createFallbackResult(rawUrl, title)
358
+ }
359
+
360
+ if (CODEPEN_HOSTS.has(normalized.hostname)) {
361
+ return resolveCodePenEmbed(normalized, title) || createFallbackResult(rawUrl, title)
362
+ }
363
+
364
+ return createFallbackResult(rawUrl, title)
365
+ }
@@ -0,0 +1,33 @@
1
+ ## Overview
2
+
3
+ Embedded URLs turn supported public links into responsive embeds directly inside Markdown pages.
4
+
5
+ This block is a higher-level alternative to raw iframe markup when the source is a supported provider and the page should keep a consistent card + preview treatment.
6
+
7
+ ## Markdown Syntax
8
+
9
+ ```html
10
+ <d-embedded-url url="https://www.youtube.com/watch?v=M7lc1UVf-VE" title="YouTube player demo">
11
+ Optional caption rendered as inline Markdown.
12
+ </d-embedded-url>
13
+ ```
14
+
15
+ You can also omit the caption body when the provider preview already gives enough context:
16
+
17
+ ```html
18
+ <d-embedded-url url="https://open.spotify.com/track/7ouMYWpwJ422jRcDASZB7P" />
19
+ ```
20
+
21
+ ## Supported Providers
22
+
23
+ - YouTube
24
+ - Vimeo
25
+ - Spotify
26
+ - CodePen
27
+
28
+ ## Notes
29
+
30
+ - `url` is required. `title` is optional.
31
+ - The block preserves the original query string, so provider options such as `autoplay=1&loop=1` continue to work when the provider supports them.
32
+ - Unsupported or private URLs fall back to a safe external-link card instead of attempting a generic iframe.
33
+ - Use Raw HTML when you need a provider outside the curated list or when you need full manual iframe control.
@@ -0,0 +1,33 @@
1
+ ## Apresentação
2
+
3
+ URLs embutidas transformam links públicos suportados em embeds responsivos diretamente dentro das páginas Markdown.
4
+
5
+ Este block é uma alternativa de nível mais alto ao uso manual de iframe quando a origem pertence a um provider suportado e a página deve manter um tratamento consistente de preview + card.
6
+
7
+ ## Sintaxe Markdown
8
+
9
+ ```html
10
+ <d-embedded-url url="https://www.youtube.com/watch?v=M7lc1UVf-VE" title="Demo do player do YouTube">
11
+ Legenda opcional renderizada como Markdown inline.
12
+ </d-embedded-url>
13
+ ```
14
+
15
+ Você também pode omitir o corpo quando o preview do provider já entrega contexto suficiente:
16
+
17
+ ```html
18
+ <d-embedded-url url="https://open.spotify.com/track/7ouMYWpwJ422jRcDASZB7P" />
19
+ ```
20
+
21
+ ## Providers suportados
22
+
23
+ - YouTube
24
+ - Vimeo
25
+ - Spotify
26
+ - CodePen
27
+
28
+ ## Observações
29
+
30
+ - `url` é obrigatório. `title` é opcional.
31
+ - O block preserva a query string original, então opções do provider como `autoplay=1&loop=1` continuam funcionando quando houver suporte.
32
+ - URLs não suportadas ou privadas fazem fallback para um card seguro com link externo, sem tentar um iframe genérico.
33
+ - Use HTML bruto quando você precisar de um provider fora da lista curada ou de controle manual total sobre o iframe.
@@ -0,0 +1,25 @@
1
+ ## Showcase
2
+
3
+ ### YouTube Video
4
+
5
+ <d-embedded-url url="https://www.youtube.com/watch?v=M7lc1UVf-VE&autoplay=1&loop=1" title="YouTube player demo">
6
+ The original playback query parameters stay attached to the provider URL.
7
+ </d-embedded-url>
8
+
9
+ ### Spotify Track
10
+
11
+ <d-embedded-url url="https://open.spotify.com/track/7ouMYWpwJ422jRcDASZB7P?si=1234">
12
+ Spotify links render with the provider-specific compact player height.
13
+ </d-embedded-url>
14
+
15
+ ### CodePen Demo
16
+
17
+ <d-embedded-url url="https://codepen.io/team/codepen/pen/PNaGbb?default-tab=result" title="Interactive demo">
18
+ Use the same block for live front-end demos without dropping to raw HTML.
19
+ </d-embedded-url>
20
+
21
+ ### Unsupported URL Fallback
22
+
23
+ <d-embedded-url url="https://example.com/docs/embed-me" title="External API docs">
24
+ Unsupported providers render a safe link card so the reading flow still keeps the URL visible and actionable.
25
+ </d-embedded-url>
@@ -0,0 +1,25 @@
1
+ ## Demonstração
2
+
3
+ ### Vídeo do YouTube
4
+
5
+ <d-embedded-url url="https://www.youtube.com/watch?v=M7lc1UVf-VE&autoplay=1&loop=1" title="Demo do player do YouTube">
6
+ Os parâmetros originais de reprodução continuam anexados à URL do provider.
7
+ </d-embedded-url>
8
+
9
+ ### Faixa do Spotify
10
+
11
+ <d-embedded-url url="https://open.spotify.com/track/7ouMYWpwJ422jRcDASZB7P?si=1234">
12
+ Links do Spotify renderizam com a altura compacta específica do provider.
13
+ </d-embedded-url>
14
+
15
+ ### Demo no CodePen
16
+
17
+ <d-embedded-url url="https://codepen.io/team/codepen/pen/PNaGbb?default-tab=result" title="Demo interativa">
18
+ Use o mesmo block para demos front-end ao vivo sem cair para HTML bruto.
19
+ </d-embedded-url>
20
+
21
+ ### Fallback para URL não suportada
22
+
23
+ <d-embedded-url url="https://example.com/docs/embed-me" title="Documentação externa da API">
24
+ Providers não suportados renderizam um card seguro com link externo, mantendo a URL visível e acionável no fluxo de leitura.
25
+ </d-embedded-url>
@@ -599,6 +599,34 @@ export default {
599
599
  }
600
600
  },
601
601
 
602
+ '/content/blocks/embedded-urls': {
603
+ config: {
604
+ icon: 'link',
605
+ status: 'new',
606
+ meta: {
607
+ description: {
608
+ 'en-US': 'Embedded URLs — Documentation of Docsector Reader',
609
+ 'pt-BR': 'URLs embutidas — Documentação do Docsector Reader'
610
+ }
611
+ },
612
+ book: 'manual',
613
+ menu: {},
614
+ subpages: {
615
+ showcase: true
616
+ }
617
+ },
618
+ data: {
619
+ 'en-US': { title: 'Embedded URLs' },
620
+ 'pt-BR': { title: 'URLs embutidas' }
621
+ },
622
+ metadata: {
623
+ tags: {
624
+ 'en-US': 'embed embedded url youtube vimeo spotify codepen iframe preview external link gitbook',
625
+ 'pt-BR': 'embed url embutida youtube vimeo spotify codepen iframe preview link externo gitbook'
626
+ }
627
+ }
628
+ },
629
+
602
630
  '/content/blocks/math-and-tex': {
603
631
  config: {
604
632
  icon: 'functions',