@blogmcp/mdx-render-vue 0.3.0 → 0.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.
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TripMap Default Render Component
4
+ *
5
+ * SSR-safe facade → lazy MapLibre island. The server (and any no-JS
6
+ * surface, incl. the sanitized article-review embed) sees a static strip
7
+ * of photo thumbnails + waypoint list + a placeholder box; when the block
8
+ * scrolls near the viewport we fetch the geo nodes and dynamically import
9
+ * @blogmcp/tripmap-core (the ONLY path to maplibre-gl — see useTripMap's
10
+ * module docblock for why this must never become a static import). If the
11
+ * import or fetch fails, the facade simply stays.
12
+ */
13
+
14
+ import { onBeforeUnmount, onMounted, ref } from 'vue'
15
+ import { tripMapStyles } from '@blogmcp/mdx-schema/styles'
16
+ import type { TripMapProps } from '@blogmcp/mdx-schema'
17
+ import { tripMapFormatTime, useTripMap, type TripMapGeoResponse } from '../../composables/useTripMap'
18
+
19
+ // withDefaults is load-bearing for the boolean props: Vue boolean-casts an
20
+ // ABSENT `filmstrip?: boolean` prop to false, which would silently flip all
21
+ // the default-true toggles off for every author who doesn't spell them out.
22
+ const props = withDefaults(defineProps<TripMapProps>(), {
23
+ line: true,
24
+ filmstrip: true,
25
+ playback: true,
26
+ cluster: true,
27
+ theme: 'auto',
28
+ detail: 'popup',
29
+ })
30
+
31
+ const { slugList, handNodes, facadeThumbs, facadeStops, heightStyle, geoUrl, isConfigOnly, labels, texts } =
32
+ useTripMap(props)
33
+
34
+ const rootEl = ref<HTMLElement | null>(null)
35
+ const mapEl = ref<HTMLElement | null>(null)
36
+ // detail="panel": the engine mounts the photo panel here, below the sized map box.
37
+ const detailEl = ref<HTMLElement | null>(null)
38
+ const status = ref<'facade' | 'loading' | 'live' | 'empty'>('facade')
39
+ const skippedCount = ref(0)
40
+
41
+ let destroyMap: (() => void) | null = null
42
+ let observer: IntersectionObserver | null = null
43
+ let started = false
44
+
45
+ async function fetchGeoNodes(): Promise<TripMapGeoResponse> {
46
+ try {
47
+ const res = await fetch(geoUrl.value, { headers: { accept: 'application/json' } })
48
+ if (!res.ok) throw new Error(`media/geo ${res.status}`)
49
+ return (await res.json()) as TripMapGeoResponse
50
+ } catch (err) {
51
+ // Config-only call (hand-made nodes, nothing selected): the map still has
52
+ // its nodes — run on the core default style rather than staying a facade.
53
+ // With a selection the nodes ARE the response, so the failure propagates
54
+ // and the static facade stays (designed degradation).
55
+ if (isConfigOnly.value) return { nodes: [] }
56
+ throw err
57
+ }
58
+ }
59
+
60
+ async function start(): Promise<void> {
61
+ if (started) return
62
+ started = true
63
+ status.value = 'loading'
64
+ try {
65
+ const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeoNodes()])
66
+ const mediaNodes = (geo.nodes ?? [])
67
+ .filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
68
+ .map((n) => ({
69
+ id: n.slug,
70
+ lat: n.latitude,
71
+ lng: n.longitude,
72
+ capturedAt: n.capturedAt ?? undefined,
73
+ thumbnailUrl: n.thumbnailUrl ?? undefined,
74
+ imageUrl: n.url ?? undefined,
75
+ largeImageUrl: n.largeUrl ?? undefined,
76
+ altText: n.altText ?? undefined,
77
+ title: n.placeName ?? undefined,
78
+ }))
79
+ const nodes = core.buildTripNodes(mediaNodes, handNodes.value)
80
+ skippedCount.value = geo.skipped ?? 0
81
+ if (nodes.length === 0 || !mapEl.value) {
82
+ status.value = 'empty'
83
+ return
84
+ }
85
+ const map = new core.TripMap(mapEl.value, {
86
+ styleUrl: props.styleUrl || geo.config?.styleUrl || '',
87
+ darkStyleUrl: geo.config?.darkStyleUrl,
88
+ theme: props.theme,
89
+ cluster: props.cluster,
90
+ showLine: props.line,
91
+ showFilmstrip: props.filmstrip,
92
+ showPlayback: props.playback,
93
+ title: props.title,
94
+ detail: props.detail,
95
+ detailHost: props.detail === 'panel' ? (detailEl.value ?? undefined) : undefined,
96
+ extraAttribution: geo.config?.attribution,
97
+ labels: labels.value,
98
+ initialView: initialViewFromProps(),
99
+ events: {},
100
+ })
101
+ map.setNodes(nodes)
102
+ await map.init()
103
+ destroyMap = () => map.destroy()
104
+ status.value = 'live'
105
+ } catch {
106
+ // Core unavailable (review-app iframe, offline) or fetch failed —
107
+ // the static facade stays, which is the designed degradation.
108
+ status.value = started && status.value === 'loading' ? 'facade' : status.value
109
+ }
110
+ }
111
+
112
+ function initialViewFromProps(): { center?: [number, number]; zoom?: number } | undefined {
113
+ if (!props.center) return undefined
114
+ const [lat, lng] = props.center.split(',').map((v) => Number.parseFloat(v.trim()))
115
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
116
+ return { center: [lng, lat], zoom: props.zoom }
117
+ }
118
+
119
+ onMounted(() => {
120
+ const el = rootEl.value
121
+ if (!el) return
122
+ if (typeof IntersectionObserver === 'undefined') {
123
+ void start()
124
+ return
125
+ }
126
+ observer = new IntersectionObserver(
127
+ (entries) => {
128
+ if (entries.some((e) => e.isIntersecting)) {
129
+ observer?.disconnect()
130
+ observer = null
131
+ void start()
132
+ }
133
+ },
134
+ { rootMargin: '300px' },
135
+ )
136
+ observer.observe(el)
137
+ })
138
+
139
+ onBeforeUnmount(() => {
140
+ observer?.disconnect()
141
+ destroyMap?.()
142
+ })
143
+ </script>
144
+
145
+ <template>
146
+ <div ref="rootEl" :class="tripMapStyles.container" data-mdx-component="TripMap" :aria-label="title">
147
+ <p v-if="title" :class="tripMapStyles.head">{{ title }}</p>
148
+
149
+ <!--
150
+ Facade thumb strip — server-rendered, with alt text when the host
151
+ provided the article media (crawlers read THIS, never the canvas);
152
+ replaced by the live filmstrip once the engine mounts.
153
+ -->
154
+ <ol v-if="facadeThumbs.length && status !== 'live'" :class="tripMapStyles.strip">
155
+ <li v-for="thumb in facadeThumbs" :key="thumb.slug" :class="tripMapStyles.stripItem">
156
+ <img :src="thumb.src" :alt="thumb.alt" width="80" height="80" loading="lazy" decoding="async" :class="tripMapStyles.stripImg" />
157
+ </li>
158
+ </ol>
159
+
160
+ <!--
161
+ Sized wrapper: the engine host is a static absolutely-positioned child
162
+ that Vue never re-patches (reactive bindings on the engine's container
163
+ would clobber the classes/children the engine adds); the facade box is
164
+ an overlay that simply unmounts when the map goes live.
165
+ -->
166
+ <div :class="tripMapStyles.mapWrap" :style="heightStyle">
167
+ <div ref="mapEl" class="absolute inset-0"></div>
168
+ <div v-if="status !== 'live'" :class="[tripMapStyles.mapOverlay]">
169
+ <div :class="tripMapStyles.mapBox">
170
+ <span :class="tripMapStyles.mapHint">
171
+ {{ status === 'empty' ? texts.empty : texts.hint }}
172
+ </span>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ <!-- detail="panel": engine-owned photo panel host (static, never re-patched). -->
177
+ <div v-if="props.detail === 'panel'" ref="detailEl" :class="tripMapStyles.detailHost"></div>
178
+
179
+ <!-- Named stops (place-labelled photos + hand-made nodes) for the static facade. -->
180
+ <ol v-if="facadeStops.length && status !== 'live'" :class="tripMapStyles.stopList">
181
+ <li v-for="stop in facadeStops" :key="stop.key">
182
+ <span v-if="stop.capturedAt" :class="tripMapStyles.stopTime">{{ tripMapFormatTime(stop.capturedAt) }}</span>
183
+ <span>{{ stop.title }}</span>
184
+ </li>
185
+ </ol>
186
+
187
+ <p v-if="status !== 'live'" :class="tripMapStyles.note">{{ texts.unavailable }}</p>
188
+ </div>
189
+ </template>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * TripMap Render Component Variants
3
+ */
4
+
5
+ export { default as TripMapDefault } from './TripMapDefault.vue'
6
+
7
+ // Default export for convenience
8
+ export { default } from './TripMapDefault.vue'