@blogmcp/mdx-render-vue 0.2.2 → 0.4.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.
@@ -9,3 +9,4 @@ export { useGallery, gridColsClasses, isMediaObject, isVideoUrl } from './useGal
9
9
  export { useYouTube, YOUTUBE_EMBED_BASE, YOUTUBE_IFRAME_ALLOW, } from './useYouTube';
10
10
  export { useAudioPlayer } from './useAudioPlayer';
11
11
  export { useOptimizedImg, IMG_COMPONENT_KEY } from './useMedia';
12
+ export { useTripMap, parseHandNodes, tripMapFormatTime, MDX_ARTICLE_CONTEXT_KEY, type MdxArticleContext, type TripMapGeoNode, type TripMapGeoResponse, } from './useTripMap';
@@ -4,8 +4,11 @@ import { gridColsClasses, isMediaObject, isVideoUrl, useGallery } from "./useGal
4
4
  import { YOUTUBE_EMBED_BASE, YOUTUBE_IFRAME_ALLOW, useYouTube } from "./useYouTube.js";
5
5
  import { useAudioPlayer } from "./useAudioPlayer.js";
6
6
  import { IMG_COMPONENT_KEY, useOptimizedImg } from "./useMedia.js";
7
+ import { MDX_ARTICLE_CONTEXT_KEY, useTripMap } from "./useTripMap.js";
8
+ import { parseTripMapHandNodes, tripMapFormatTime } from "@blogmcp/mdx-schema/tripmap";
7
9
  export {
8
10
  IMG_COMPONENT_KEY,
11
+ MDX_ARTICLE_CONTEXT_KEY,
9
12
  YOUTUBE_EMBED_BASE,
10
13
  YOUTUBE_IFRAME_ALLOW,
11
14
  calloutConfigs,
@@ -15,10 +18,13 @@ export {
15
18
  isMediaObject,
16
19
  isVideoUrl,
17
20
  languageLabels,
21
+ parseTripMapHandNodes as parseHandNodes,
22
+ tripMapFormatTime,
18
23
  useAudioPlayer,
19
24
  useCallout,
20
25
  useCodeBlock,
21
26
  useGallery,
22
27
  useOptimizedImg,
28
+ useTripMap,
23
29
  useYouTube
24
30
  };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * TripMap composable — Vue binding over the shared framework-free helpers
3
+ * in @blogmcp/mdx-schema/tripmap (prop parsing, geo-URL contract, texts).
4
+ *
5
+ * HARD CONSTRAINT: this module (and every module statically reachable from
6
+ * the TripMap SFCs) must NOT statically import '@blogmcp/tripmap-core' —
7
+ * not even its pure '/nodes' subpath. The article-review MCP-App bundles
8
+ * this package from source into ONE file with that package externalized;
9
+ * a top-level bare import would break the whole bundle at load time. The
10
+ * core is reached exclusively via `import('@blogmcp/tripmap-core')` inside
11
+ * the SFC's intersection handler (rejects harmlessly in the iframe).
12
+ */
13
+ import { type ComputedRef, type InjectionKey } from 'vue';
14
+ import type { TripMapHandNode, TripMapProps } from '@blogmcp/mdx-schema';
15
+ import { parseTripMapHandNodes, tripMapFormatTime, type TripMapGeoNode, type TripMapGeoResponse, type TripMapUiTexts } from '@blogmcp/mdx-schema/tripmap';
16
+ export { tripMapFormatTime };
17
+ export { parseTripMapHandNodes as parseHandNodes };
18
+ export type { TripMapGeoNode, TripMapGeoResponse };
19
+ /**
20
+ * Article identity for AUTO mode (map every geotagged media of the article).
21
+ * The host app provides it around its MDX render tree (the Nuxt blog does it
22
+ * in `components/article/MdxContent.vue` for article + preview pages; a bare
23
+ * consumer that doesn't provide it simply has no auto mode — TripMap then
24
+ * needs explicit `slugs` or `nodes`).
25
+ */
26
+ export interface MdxArticleContext {
27
+ filename: string;
28
+ language?: string;
29
+ }
30
+ export declare const MDX_ARTICLE_CONTEXT_KEY: InjectionKey<MdxArticleContext | null>;
31
+ export interface UseTripMapResult {
32
+ slugList: ComputedRef<string[]>;
33
+ excludeList: ComputedRef<string[]>;
34
+ handNodes: ComputedRef<TripMapHandNode[]>;
35
+ /** Deterministic SSR thumbs for explicit-slugs mode (author order). */
36
+ facadeThumbs: ComputedRef<{
37
+ slug: string;
38
+ src: string;
39
+ }[]>;
40
+ heightStyle: ComputedRef<{
41
+ height: string;
42
+ }>;
43
+ articleContext: MdxArticleContext | null;
44
+ isAutoMode: ComputedRef<boolean>;
45
+ /**
46
+ * Nothing selects media (no slugs, no article context): the geo call only
47
+ * fetches the tenant basemap config, so its failure must not sink a map
48
+ * of hand-made nodes — the wrapper falls back to the core default style.
49
+ */
50
+ isConfigOnly: ComputedRef<boolean>;
51
+ /** Always set: slugs / article selection, or the config-only URL. */
52
+ geoUrl: ComputedRef<string>;
53
+ labels: ComputedRef<{
54
+ play: string;
55
+ stop: string;
56
+ } | undefined>;
57
+ texts: ComputedRef<TripMapUiTexts>;
58
+ }
59
+ export declare function useTripMap(props: TripMapProps): UseTripMapResult;
@@ -0,0 +1,41 @@
1
+ import { inject, computed } from "vue";
2
+ import { splitTripMapList, parseTripMapHandNodes, TRIPMAP_DEFAULT_HEIGHT, buildTripMapGeoUrl, tripMapPlaybackLabels, tripMapUiTexts } from "@blogmcp/mdx-schema/tripmap";
3
+ import { parseTripMapHandNodes as parseTripMapHandNodes2, tripMapFormatTime } from "@blogmcp/mdx-schema/tripmap";
4
+ const MDX_ARTICLE_CONTEXT_KEY = /* @__PURE__ */ Symbol.for("blogmcp:mdx-article-context");
5
+ function useTripMap(props) {
6
+ const articleContext = inject(MDX_ARTICLE_CONTEXT_KEY, null);
7
+ const slugList = computed(() => splitTripMapList(props.slugs));
8
+ const excludeList = computed(() => splitTripMapList(props.exclude));
9
+ const handNodes = computed(() => parseTripMapHandNodes(props.nodes));
10
+ const isAutoMode = computed(() => slugList.value.length === 0);
11
+ const isConfigOnly = computed(() => slugList.value.length === 0 && !articleContext?.filename);
12
+ const facadeThumbs = computed(
13
+ () => slugList.value.map((slug) => ({
14
+ slug,
15
+ src: `/api/media/thumb/${encodeURIComponent(slug)}?width=192`
16
+ }))
17
+ );
18
+ const heightStyle = computed(() => ({ height: props.height || TRIPMAP_DEFAULT_HEIGHT }));
19
+ const geoUrl = computed(() => buildTripMapGeoUrl(props, articleContext));
20
+ const labels = computed(() => tripMapPlaybackLabels(articleContext?.language));
21
+ const texts = computed(() => tripMapUiTexts(articleContext?.language));
22
+ return {
23
+ slugList,
24
+ excludeList,
25
+ handNodes,
26
+ facadeThumbs,
27
+ heightStyle,
28
+ articleContext,
29
+ isAutoMode,
30
+ isConfigOnly,
31
+ geoUrl,
32
+ labels,
33
+ texts
34
+ };
35
+ }
36
+ export {
37
+ MDX_ARTICLE_CONTEXT_KEY,
38
+ parseTripMapHandNodes2 as parseHandNodes,
39
+ tripMapFormatTime,
40
+ useTripMap
41
+ };
@@ -0,0 +1,3 @@
1
+ import type { TripMapProps } from '@blogmcp/mdx-schema';
2
+ declare const _default: import("vue").DefineComponent<TripMapProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<TripMapProps> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
3
+ export default _default;
@@ -0,0 +1,50 @@
1
+ import { defineComponent, computed, createElementBlock, openBlock, createElementVNode, toDisplayString, createCommentVNode, Fragment, createTextVNode } from "vue";
2
+ import { parseTripMapHandNodes } from "@blogmcp/mdx-schema/tripmap";
3
+ const _hoisted_1 = {
4
+ class: "flex items-center gap-3 rounded-lg border border-blue-500/40 bg-blue-500/5 px-3 py-2",
5
+ "data-mdx-component": "TripMap"
6
+ };
7
+ const _hoisted_2 = { class: "font-medium" };
8
+ const _hoisted_3 = { class: "text-sm opacity-70" };
9
+ const _sfc_main = /* @__PURE__ */ defineComponent({
10
+ __name: "TripMapEdit",
11
+ props: {
12
+ slugs: {},
13
+ exclude: {},
14
+ nodes: {},
15
+ height: {},
16
+ line: { type: Boolean },
17
+ filmstrip: { type: Boolean },
18
+ playback: { type: Boolean },
19
+ cluster: { type: Boolean },
20
+ theme: {},
21
+ styleUrl: {},
22
+ center: {},
23
+ zoom: {},
24
+ title: {}
25
+ },
26
+ setup(__props) {
27
+ const props = __props;
28
+ const slugCount = computed(() => (props.slugs ?? "").split(",").filter((s) => s.trim()).length);
29
+ const nodeCount = computed(() => parseTripMapHandNodes(props.nodes).length);
30
+ return (_ctx, _cache) => {
31
+ return openBlock(), createElementBlock("div", _hoisted_1, [
32
+ _cache[0] || (_cache[0] = createElementVNode("span", { "aria-hidden": "true" }, "🗺", -1)),
33
+ createElementVNode("span", _hoisted_2, toDisplayString(__props.title || "TripMap"), 1),
34
+ createElementVNode("span", _hoisted_3, [
35
+ slugCount.value > 0 ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [
36
+ createTextVNode(toDisplayString(slugCount.value) + " photos", 1)
37
+ ], 64)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [
38
+ createTextVNode("auto (article media)")
39
+ ], 64)),
40
+ nodeCount.value > 0 ? (openBlock(), createElementBlock(Fragment, { key: 2 }, [
41
+ createTextVNode(" · " + toDisplayString(nodeCount.value) + " waypoints", 1)
42
+ ], 64)) : createCommentVNode("", true)
43
+ ])
44
+ ]);
45
+ };
46
+ }
47
+ });
48
+ export {
49
+ _sfc_main as default
50
+ };
@@ -0,0 +1,4 @@
1
+ import _sfc_main from "./TripMapEdit.vue.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -16,6 +16,7 @@ export { default as AlertEdit } from './AlertEdit.vue';
16
16
  export { default as BadgeEdit } from './BadgeEdit.vue';
17
17
  export { default as IconEdit } from './IconEdit.vue';
18
18
  export { default as TooltipEdit } from './TooltipEdit.vue';
19
+ export { default as TripMapEdit } from './TripMapEdit.vue';
19
20
  export { default as Callout } from './CalloutEdit.vue';
20
21
  export { default as CodeBlock } from './CodeBlockEdit.vue';
21
22
  export { default as Gallery } from './GalleryEdit.vue';
@@ -27,3 +28,4 @@ export { default as Alert } from './AlertEdit.vue';
27
28
  export { default as Badge } from './BadgeEdit.vue';
28
29
  export { default as Icon } from './IconEdit.vue';
29
30
  export { default as Tooltip } from './TooltipEdit.vue';
31
+ export { default as TripMap } from './TripMapEdit.vue';
@@ -9,6 +9,7 @@ import { default as default16, default as default17 } from "./AlertEdit.vue2.js"
9
9
  import { default as default18, default as default19 } from "./BadgeEdit.vue2.js";
10
10
  import { default as default20, default as default21 } from "./IconEdit.vue2.js";
11
11
  import { default as default22, default as default23 } from "./TooltipEdit.vue2.js";
12
+ import { default as default24, default as default25 } from "./TripMapEdit.vue.js";
12
13
  export {
13
14
  default16 as Alert,
14
15
  default17 as AlertEdit,
@@ -30,6 +31,8 @@ export {
30
31
  default21 as IconEdit,
31
32
  default22 as Tooltip,
32
33
  default23 as TooltipEdit,
34
+ default24 as TripMap,
35
+ default25 as TripMapEdit,
33
36
  default8 as YouTube,
34
37
  default9 as YouTubeEdit
35
38
  };
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * md-editor edit preview (dark theme) and KB blog (light theme, variants).
7
7
  */
8
8
  export * from './composables';
9
- export { CalloutEdit, CodeBlockEdit, GalleryEdit, YouTubeEdit, AudioPlayerEdit, Callout as EditCallout, CodeBlock as EditCodeBlock, Gallery as EditGallery, YouTube as EditYouTube, AudioPlayer as EditAudioPlayer, } from './edit';
10
- export { Callout as RenderCallout, CalloutDefault, CalloutMinimal, CodeBlock as RenderCodeBlock, CodeBlockDefault, Gallery as RenderGallery, GalleryGrid, YouTube as RenderYouTube, YouTubeDefault, AudioPlayer as RenderAudioPlayer, AudioPlayerDefault, getRenderComponent, getComponentVariants, } from './render';
9
+ export { CalloutEdit, CodeBlockEdit, GalleryEdit, YouTubeEdit, AudioPlayerEdit, TripMapEdit, Callout as EditCallout, CodeBlock as EditCodeBlock, Gallery as EditGallery, YouTube as EditYouTube, AudioPlayer as EditAudioPlayer, } from './edit';
10
+ export { Callout as RenderCallout, CalloutDefault, CalloutMinimal, CodeBlock as RenderCodeBlock, CodeBlockDefault, Gallery as RenderGallery, GalleryGrid, YouTube as RenderYouTube, YouTubeDefault, AudioPlayer as RenderAudioPlayer, AudioPlayerDefault, TripMap as RenderTripMap, TripMapDefault, getRenderComponent, getComponentVariants, } from './render';
11
11
  export { Callout, CodeBlock, Gallery, YouTube, AudioPlayer, } from './edit';
12
12
  export { componentRegistry, getComponentVariants as getRegistryVariants, getRenderComponentAsync, getEditComponentAsync, getEditorConfig, type ComponentRegistration, type EditorConfig, } from './registry';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { gridColsClasses, isMediaObject, isVideoUrl, useGallery } from "./compos
4
4
  import { YOUTUBE_EMBED_BASE, YOUTUBE_IFRAME_ALLOW, useYouTube } from "./composables/useYouTube.js";
5
5
  import { useAudioPlayer } from "./composables/useAudioPlayer.js";
6
6
  import { IMG_COMPONENT_KEY, useOptimizedImg } from "./composables/useMedia.js";
7
+ import { MDX_ARTICLE_CONTEXT_KEY, useTripMap } from "./composables/useTripMap.js";
7
8
  import { default as default2, default as default3, default as default4 } from "./edit/CalloutEdit.vue.js";
8
9
  import { default as default5, default as default6, default as default7 } from "./edit/CodeBlockEdit.vue.js";
9
10
  import { default as default8, default as default9, default as default10 } from "./edit/GalleryEdit.vue.js";
@@ -14,24 +15,27 @@ import "@blogmcp/mdx-schema/styles";
14
15
  import "./packages/sanitize/dist/html.js";
15
16
  import "./_virtual/index.js";
16
17
  import "./node_modules/@iconify/vue/dist/iconify.js";
18
+ import { default as default17 } from "./edit/TripMapEdit.vue.js";
17
19
  import { getRenderComponent } from "./render/index.js";
18
20
  import { componentRegistry, getComponentVariants, getEditComponentAsync, getEditorConfig, getComponentVariants as getComponentVariants2, getRenderComponentAsync } from "./registry.js";
19
- import { default as default17, default as default18 } from "./render/callout/CalloutDefault.vue.js";
20
- import { default as default19 } from "./render/callout/CalloutMinimal.vue.js";
21
- import { default as default20, default as default21 } from "./render/gallery/GalleryGrid.vue.js";
22
- import { default as default22, default as default23 } from "./render/youtube/YouTubeDefault.vue.js";
23
- import { default as default24, default as default25 } from "./render/audioplayer/AudioPlayerDefault.vue.js";
24
- import { default as default26, default as default27 } from "./render/codeblock/CodeBlockDefault.vue.js";
21
+ import { default as default18, default as default19 } from "./render/callout/CalloutDefault.vue.js";
22
+ import { default as default20 } from "./render/callout/CalloutMinimal.vue.js";
23
+ import { default as default21, default as default22 } from "./render/gallery/GalleryGrid.vue.js";
24
+ import { default as default23, default as default24 } from "./render/youtube/YouTubeDefault.vue.js";
25
+ import { default as default25, default as default26 } from "./render/audioplayer/AudioPlayerDefault.vue.js";
26
+ import { default as default27, default as default28 } from "./render/tripmap/TripMapDefault.vue.js";
27
+ import { default as default29, default as default30 } from "./render/codeblock/CodeBlockDefault.vue.js";
28
+ import { parseTripMapHandNodes, tripMapFormatTime } from "@blogmcp/mdx-schema/tripmap";
25
29
  export {
26
30
  default14 as AudioPlayer,
27
- default24 as AudioPlayerDefault,
31
+ default25 as AudioPlayerDefault,
28
32
  default15 as AudioPlayerEdit,
29
33
  default2 as Callout,
30
- default17 as CalloutDefault,
34
+ default18 as CalloutDefault,
31
35
  default3 as CalloutEdit,
32
- default19 as CalloutMinimal,
36
+ default20 as CalloutMinimal,
33
37
  default5 as CodeBlock,
34
- default26 as CodeBlockDefault,
38
+ default29 as CodeBlockDefault,
35
39
  default6 as CodeBlockEdit,
36
40
  default16 as EditAudioPlayer,
37
41
  default4 as EditCallout,
@@ -40,17 +44,21 @@ export {
40
44
  default11 as EditYouTube,
41
45
  default9 as Gallery,
42
46
  default10 as GalleryEdit,
43
- default20 as GalleryGrid,
47
+ default21 as GalleryGrid,
44
48
  IMG_COMPONENT_KEY,
45
- default25 as RenderAudioPlayer,
46
- default18 as RenderCallout,
47
- default27 as RenderCodeBlock,
48
- default21 as RenderGallery,
49
- default22 as RenderYouTube,
49
+ MDX_ARTICLE_CONTEXT_KEY,
50
+ default26 as RenderAudioPlayer,
51
+ default19 as RenderCallout,
52
+ default30 as RenderCodeBlock,
53
+ default22 as RenderGallery,
54
+ default27 as RenderTripMap,
55
+ default23 as RenderYouTube,
56
+ default28 as TripMapDefault,
57
+ default17 as TripMapEdit,
50
58
  YOUTUBE_EMBED_BASE,
51
59
  YOUTUBE_IFRAME_ALLOW,
52
60
  default12 as YouTube,
53
- default23 as YouTubeDefault,
61
+ default24 as YouTubeDefault,
54
62
  default13 as YouTubeEdit,
55
63
  calloutConfigs,
56
64
  calloutIconPaths,
@@ -66,10 +74,13 @@ export {
66
74
  isMediaObject,
67
75
  isVideoUrl,
68
76
  languageLabels,
77
+ parseTripMapHandNodes as parseHandNodes,
78
+ tripMapFormatTime,
69
79
  useAudioPlayer,
70
80
  useCallout,
71
81
  useCodeBlock,
72
82
  useGallery,
73
83
  useOptimizedImg,
84
+ useTripMap,
74
85
  useYouTube
75
86
  };
package/dist/registry.js CHANGED
@@ -82,6 +82,13 @@ const vueWiring = {
82
82
  default: () => import("./render/tooltip/Tooltip.vue.js")
83
83
  },
84
84
  edit: () => import("./edit/TooltipEdit.vue.js")
85
+ },
86
+ TripMap: {
87
+ editor: { icon: "map", color: "#3b82f6", borderColor: "#3b82f6", bgColor: "rgba(59, 130, 246, 0.08)" },
88
+ render: {
89
+ default: () => import("./render/tripmap/TripMapDefault.vue2.js")
90
+ },
91
+ edit: () => import("./edit/TripMapEdit.vue2.js")
85
92
  }
86
93
  };
87
94
  const componentRegistry = Object.fromEntries(
@@ -18,12 +18,14 @@ import { Alert as AlertDefault } from './alert';
18
18
  import { Badge as BadgeDefault } from './badge';
19
19
  import { Icon as IconDefault } from './icon';
20
20
  import { Tooltip as TooltipDefault } from './tooltip';
21
+ import { TripMapDefault } from './tripmap';
21
22
  export { CalloutDefault, CalloutMinimal };
22
23
  export { CodeBlockDefault };
23
24
  export { GalleryGrid, GalleryLightbox };
24
25
  export { YouTubeDefault };
25
26
  export { AudioPlayerDefault };
26
27
  export { ButtonDefault, CardDefault, AlertDefault, BadgeDefault, IconDefault, TooltipDefault };
28
+ export { TripMapDefault };
27
29
  export { default as Callout } from './callout';
28
30
  export { default as CodeBlock } from './codeblock';
29
31
  export { default as Gallery } from './gallery';
@@ -35,6 +37,7 @@ export { default as Alert } from './alert';
35
37
  export { default as Badge } from './badge';
36
38
  export { default as Icon } from './icon';
37
39
  export { default as Tooltip } from './tooltip';
40
+ export { default as TripMap } from './tripmap';
38
41
  export { getRenderComponentAsync, getComponentVariants, getEditorConfig, componentRegistry, } from '../registry';
39
42
  /** @deprecated Use getRenderComponentAsync from registry instead */
40
43
  export declare function getRenderComponent(componentName: string, variant?: string): unknown;
@@ -1,29 +1,31 @@
1
- import _sfc_main$a from "./callout/CalloutDefault.vue.js";
2
- import _sfc_main$9 from "./callout/CalloutMinimal.vue.js";
1
+ import _sfc_main$b from "./callout/CalloutDefault.vue.js";
2
+ import _sfc_main$a from "./callout/CalloutMinimal.vue.js";
3
3
  import CodeBlockDefault from "./codeblock/CodeBlockDefault.vue.js";
4
- import _sfc_main$8 from "./gallery/GalleryGrid.vue.js";
4
+ import _sfc_main$9 from "./gallery/GalleryGrid.vue.js";
5
5
  import { default as default2 } from "./gallery/GalleryLightbox.vue.js";
6
- import _sfc_main$7 from "./youtube/YouTubeDefault.vue.js";
7
- import _sfc_main$6 from "./audioplayer/AudioPlayerDefault.vue.js";
8
- import _sfc_main$5 from "./button/Button.vue2.js";
9
- import _sfc_main$4 from "./card/Card.vue2.js";
10
- import _sfc_main$3 from "./alert/Alert.vue2.js";
11
- import _sfc_main$2 from "./badge/Badge.vue2.js";
12
- import _sfc_main$1 from "./icon/Icon.vue2.js";
13
- import _sfc_main from "./tooltip/Tooltip.vue2.js";
6
+ import _sfc_main$8 from "./youtube/YouTubeDefault.vue.js";
7
+ import _sfc_main$7 from "./audioplayer/AudioPlayerDefault.vue.js";
8
+ import _sfc_main$6 from "./button/Button.vue2.js";
9
+ import _sfc_main$5 from "./card/Card.vue2.js";
10
+ import _sfc_main$4 from "./alert/Alert.vue2.js";
11
+ import _sfc_main$3 from "./badge/Badge.vue2.js";
12
+ import _sfc_main$2 from "./icon/Icon.vue2.js";
13
+ import _sfc_main$1 from "./tooltip/Tooltip.vue2.js";
14
+ import _sfc_main from "./tripmap/TripMapDefault.vue.js";
14
15
  import { componentRegistry, getComponentVariants, getEditorConfig, getRenderComponentAsync } from "../registry.js";
15
16
  const syncRegistry = {
16
- Callout: { default: _sfc_main$a, minimal: _sfc_main$9 },
17
+ Callout: { default: _sfc_main$b, minimal: _sfc_main$a },
17
18
  CodeBlock: { default: CodeBlockDefault },
18
- Gallery: { default: _sfc_main$8, grid: _sfc_main$8 },
19
- YouTube: { default: _sfc_main$7 },
20
- AudioPlayer: { default: _sfc_main$6 },
21
- Button: { default: _sfc_main$5 },
22
- Card: { default: _sfc_main$4 },
23
- Alert: { default: _sfc_main$3 },
24
- Badge: { default: _sfc_main$2 },
25
- Icon: { default: _sfc_main$1 },
26
- Tooltip: { default: _sfc_main }
19
+ Gallery: { default: _sfc_main$9, grid: _sfc_main$9 },
20
+ YouTube: { default: _sfc_main$8 },
21
+ AudioPlayer: { default: _sfc_main$7 },
22
+ Button: { default: _sfc_main$6 },
23
+ Card: { default: _sfc_main$5 },
24
+ Alert: { default: _sfc_main$4 },
25
+ Badge: { default: _sfc_main$3 },
26
+ Icon: { default: _sfc_main$2 },
27
+ Tooltip: { default: _sfc_main$1 },
28
+ TripMap: { default: _sfc_main }
27
29
  };
28
30
  function getRenderComponent(componentName, variant = "default") {
29
31
  const componentVariants = syncRegistry[componentName];
@@ -31,30 +33,32 @@ function getRenderComponent(componentName, variant = "default") {
31
33
  return componentVariants[variant] || componentVariants.default;
32
34
  }
33
35
  export {
34
- _sfc_main$3 as Alert,
35
- _sfc_main$3 as AlertDefault,
36
- _sfc_main$6 as AudioPlayer,
37
- _sfc_main$6 as AudioPlayerDefault,
38
- _sfc_main$2 as Badge,
39
- _sfc_main$2 as BadgeDefault,
40
- _sfc_main$5 as Button,
41
- _sfc_main$5 as ButtonDefault,
42
- _sfc_main$a as Callout,
43
- _sfc_main$a as CalloutDefault,
44
- _sfc_main$9 as CalloutMinimal,
45
- _sfc_main$4 as Card,
46
- _sfc_main$4 as CardDefault,
36
+ _sfc_main$4 as Alert,
37
+ _sfc_main$4 as AlertDefault,
38
+ _sfc_main$7 as AudioPlayer,
39
+ _sfc_main$7 as AudioPlayerDefault,
40
+ _sfc_main$3 as Badge,
41
+ _sfc_main$3 as BadgeDefault,
42
+ _sfc_main$6 as Button,
43
+ _sfc_main$6 as ButtonDefault,
44
+ _sfc_main$b as Callout,
45
+ _sfc_main$b as CalloutDefault,
46
+ _sfc_main$a as CalloutMinimal,
47
+ _sfc_main$5 as Card,
48
+ _sfc_main$5 as CardDefault,
47
49
  CodeBlockDefault as CodeBlock,
48
50
  CodeBlockDefault,
49
- _sfc_main$8 as Gallery,
50
- _sfc_main$8 as GalleryGrid,
51
+ _sfc_main$9 as Gallery,
52
+ _sfc_main$9 as GalleryGrid,
51
53
  default2 as GalleryLightbox,
52
- _sfc_main$1 as Icon,
53
- _sfc_main$1 as IconDefault,
54
- _sfc_main as Tooltip,
55
- _sfc_main as TooltipDefault,
56
- _sfc_main$7 as YouTube,
57
- _sfc_main$7 as YouTubeDefault,
54
+ _sfc_main$2 as Icon,
55
+ _sfc_main$2 as IconDefault,
56
+ _sfc_main$1 as Tooltip,
57
+ _sfc_main$1 as TooltipDefault,
58
+ _sfc_main as TripMap,
59
+ _sfc_main as TripMapDefault,
60
+ _sfc_main$8 as YouTube,
61
+ _sfc_main$8 as YouTubeDefault,
58
62
  componentRegistry,
59
63
  getComponentVariants,
60
64
  getEditorConfig,
@@ -0,0 +1,9 @@
1
+ import type { TripMapProps } from '@blogmcp/mdx-schema';
2
+ declare const _default: import("vue").DefineComponent<TripMapProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<TripMapProps> & Readonly<{}>, {
3
+ line: boolean;
4
+ filmstrip: boolean;
5
+ playback: boolean;
6
+ cluster: boolean;
7
+ theme: "light" | "dark" | "auto";
8
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
9
+ export default _default;
@@ -0,0 +1,197 @@
1
+ import { defineComponent, ref, onMounted, onBeforeUnmount, createElementBlock, openBlock, normalizeClass, unref, createCommentVNode, createElementVNode, toDisplayString, Fragment, renderList, normalizeStyle } from "vue";
2
+ import { tripMapStyles } from "@blogmcp/mdx-schema/styles";
3
+ import { useTripMap } from "../../composables/useTripMap.js";
4
+ import { tripMapFormatTime } from "@blogmcp/mdx-schema/tripmap";
5
+ const _hoisted_1 = ["aria-label"];
6
+ const _hoisted_2 = ["src"];
7
+ const _sfc_main = /* @__PURE__ */ defineComponent({
8
+ __name: "TripMapDefault",
9
+ props: {
10
+ slugs: {},
11
+ exclude: {},
12
+ nodes: {},
13
+ height: {},
14
+ line: { type: Boolean, default: true },
15
+ filmstrip: { type: Boolean, default: true },
16
+ playback: { type: Boolean, default: true },
17
+ cluster: { type: Boolean, default: true },
18
+ theme: { default: "auto" },
19
+ styleUrl: {},
20
+ center: {},
21
+ zoom: {},
22
+ title: {}
23
+ },
24
+ setup(__props) {
25
+ const props = __props;
26
+ const { slugList, handNodes, facadeThumbs, heightStyle, geoUrl, isConfigOnly, labels, texts } = useTripMap(props);
27
+ const rootEl = ref(null);
28
+ const mapEl = ref(null);
29
+ const status = ref("facade");
30
+ const skippedCount = ref(0);
31
+ let destroyMap = null;
32
+ let observer = null;
33
+ let started = false;
34
+ async function fetchGeoNodes() {
35
+ try {
36
+ const res = await fetch(geoUrl.value, { headers: { accept: "application/json" } });
37
+ if (!res.ok) throw new Error(`media/geo ${res.status}`);
38
+ return await res.json();
39
+ } catch (err) {
40
+ if (isConfigOnly.value) return { nodes: [] };
41
+ throw err;
42
+ }
43
+ }
44
+ async function start() {
45
+ if (started) return;
46
+ started = true;
47
+ status.value = "loading";
48
+ try {
49
+ const [core, geo] = await Promise.all([import("@blogmcp/tripmap-core"), fetchGeoNodes()]);
50
+ const mediaNodes = (geo.nodes ?? []).filter((n) => typeof n.latitude === "number" && typeof n.longitude === "number").map((n) => ({
51
+ id: n.slug,
52
+ lat: n.latitude,
53
+ lng: n.longitude,
54
+ capturedAt: n.capturedAt ?? void 0,
55
+ thumbnailUrl: n.thumbnailUrl ?? void 0,
56
+ imageUrl: n.url ?? void 0,
57
+ altText: n.altText ?? void 0,
58
+ title: n.placeName ?? void 0
59
+ }));
60
+ const nodes = core.buildTripNodes(mediaNodes, handNodes.value);
61
+ skippedCount.value = geo.skipped ?? 0;
62
+ if (nodes.length === 0 || !mapEl.value) {
63
+ status.value = "empty";
64
+ return;
65
+ }
66
+ const map = new core.TripMap(mapEl.value, {
67
+ styleUrl: props.styleUrl || geo.config?.styleUrl || "",
68
+ darkStyleUrl: geo.config?.darkStyleUrl,
69
+ theme: props.theme,
70
+ cluster: props.cluster,
71
+ showLine: props.line,
72
+ showFilmstrip: props.filmstrip,
73
+ showPlayback: props.playback,
74
+ extraAttribution: geo.config?.attribution,
75
+ labels: labels.value,
76
+ initialView: initialViewFromProps(),
77
+ events: {}
78
+ });
79
+ map.setNodes(nodes);
80
+ await map.init();
81
+ destroyMap = () => map.destroy();
82
+ status.value = "live";
83
+ } catch {
84
+ status.value = started && status.value === "loading" ? "facade" : status.value;
85
+ }
86
+ }
87
+ function initialViewFromProps() {
88
+ if (!props.center) return void 0;
89
+ const [lat, lng] = props.center.split(",").map((v) => Number.parseFloat(v.trim()));
90
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return void 0;
91
+ return { center: [lng, lat], zoom: props.zoom };
92
+ }
93
+ onMounted(() => {
94
+ const el = rootEl.value;
95
+ if (!el) return;
96
+ if (typeof IntersectionObserver === "undefined") {
97
+ void start();
98
+ return;
99
+ }
100
+ observer = new IntersectionObserver(
101
+ (entries) => {
102
+ if (entries.some((e) => e.isIntersecting)) {
103
+ observer?.disconnect();
104
+ observer = null;
105
+ void start();
106
+ }
107
+ },
108
+ { rootMargin: "300px" }
109
+ );
110
+ observer.observe(el);
111
+ });
112
+ onBeforeUnmount(() => {
113
+ observer?.disconnect();
114
+ destroyMap?.();
115
+ });
116
+ return (_ctx, _cache) => {
117
+ return openBlock(), createElementBlock("div", {
118
+ ref_key: "rootEl",
119
+ ref: rootEl,
120
+ class: normalizeClass(unref(tripMapStyles).container),
121
+ "data-mdx-component": "TripMap",
122
+ "aria-label": __props.title
123
+ }, [
124
+ __props.title ? (openBlock(), createElementBlock("p", {
125
+ key: 0,
126
+ class: normalizeClass(unref(tripMapStyles).head)
127
+ }, toDisplayString(__props.title), 3)) : createCommentVNode("", true),
128
+ unref(facadeThumbs).length && status.value !== "live" ? (openBlock(), createElementBlock("ol", {
129
+ key: 1,
130
+ class: normalizeClass(unref(tripMapStyles).strip)
131
+ }, [
132
+ (openBlock(true), createElementBlock(Fragment, null, renderList(unref(facadeThumbs), (thumb) => {
133
+ return openBlock(), createElementBlock("li", {
134
+ key: thumb.slug,
135
+ class: normalizeClass(unref(tripMapStyles).stripItem)
136
+ }, [
137
+ createElementVNode("img", {
138
+ src: thumb.src,
139
+ alt: "",
140
+ width: "80",
141
+ height: "80",
142
+ loading: "lazy",
143
+ decoding: "async",
144
+ class: normalizeClass(unref(tripMapStyles).stripImg)
145
+ }, null, 10, _hoisted_2)
146
+ ], 2);
147
+ }), 128))
148
+ ], 2)) : createCommentVNode("", true),
149
+ createElementVNode("div", {
150
+ class: normalizeClass(unref(tripMapStyles).mapWrap),
151
+ style: normalizeStyle(unref(heightStyle))
152
+ }, [
153
+ createElementVNode("div", {
154
+ ref_key: "mapEl",
155
+ ref: mapEl,
156
+ class: "absolute inset-0"
157
+ }, null, 512),
158
+ status.value !== "live" ? (openBlock(), createElementBlock("div", {
159
+ key: 0,
160
+ class: normalizeClass([unref(tripMapStyles).mapOverlay])
161
+ }, [
162
+ createElementVNode("div", {
163
+ class: normalizeClass(unref(tripMapStyles).mapBox)
164
+ }, [
165
+ createElementVNode("span", {
166
+ class: normalizeClass(unref(tripMapStyles).mapHint)
167
+ }, toDisplayString(status.value === "empty" ? unref(texts).empty : unref(texts).hint), 3)
168
+ ], 2)
169
+ ], 2)) : createCommentVNode("", true)
170
+ ], 6),
171
+ unref(handNodes).length && status.value !== "live" ? (openBlock(), createElementBlock("ol", {
172
+ key: 2,
173
+ class: normalizeClass(unref(tripMapStyles).stopList)
174
+ }, [
175
+ (openBlock(true), createElementBlock(Fragment, null, renderList(unref(handNodes), (node, i) => {
176
+ return openBlock(), createElementBlock("li", {
177
+ key: node.id || i
178
+ }, [
179
+ node.capturedAt ? (openBlock(), createElementBlock("span", {
180
+ key: 0,
181
+ class: normalizeClass(unref(tripMapStyles).stopTime)
182
+ }, toDisplayString(unref(tripMapFormatTime)(node.capturedAt)), 3)) : createCommentVNode("", true),
183
+ createElementVNode("span", null, toDisplayString(node.title || `${node.lat}, ${node.lng}`), 1)
184
+ ]);
185
+ }), 128))
186
+ ], 2)) : createCommentVNode("", true),
187
+ status.value !== "live" ? (openBlock(), createElementBlock("p", {
188
+ key: 3,
189
+ class: normalizeClass(unref(tripMapStyles).note)
190
+ }, toDisplayString(unref(texts).unavailable), 3)) : createCommentVNode("", true)
191
+ ], 10, _hoisted_1);
192
+ };
193
+ }
194
+ });
195
+ export {
196
+ _sfc_main as default
197
+ };
@@ -0,0 +1,4 @@
1
+ import _sfc_main from "./TripMapDefault.vue.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * TripMap Render Component Variants
3
+ */
4
+ export { default as TripMapDefault } from './TripMapDefault.vue';
5
+ export { default } from './TripMapDefault.vue';
@@ -0,0 +1,5 @@
1
+ import { default as default2, default as default3 } from "./TripMapDefault.vue.js";
2
+ export {
3
+ default2 as TripMapDefault,
4
+ default3 as default
5
+ };
@@ -0,0 +1,64 @@
1
+ import { ComputedRef } from 'vue';
2
+ import { InjectionKey } from 'vue';
3
+ import { parseTripMapHandNodes as parseHandNodes } from '@blogmcp/mdx-schema/tripmap';
4
+ import { tripMapFormatTime } from '@blogmcp/mdx-schema/tripmap';
5
+ import { TripMapGeoNode } from '@blogmcp/mdx-schema/tripmap';
6
+ import { TripMapGeoResponse } from '@blogmcp/mdx-schema/tripmap';
7
+ import { TripMapHandNode } from '@blogmcp/mdx-schema';
8
+ import { TripMapProps } from '@blogmcp/mdx-schema';
9
+ import { TripMapUiTexts } from '@blogmcp/mdx-schema/tripmap';
10
+
11
+ export declare const MDX_ARTICLE_CONTEXT_KEY: InjectionKey<MdxArticleContext | null>;
12
+
13
+ /**
14
+ * Article identity for AUTO mode (map every geotagged media of the article).
15
+ * The host app provides it around its MDX render tree (the Nuxt blog does it
16
+ * in `components/article/MdxContent.vue` for article + preview pages; a bare
17
+ * consumer that doesn't provide it simply has no auto mode — TripMap then
18
+ * needs explicit `slugs` or `nodes`).
19
+ */
20
+ export declare interface MdxArticleContext {
21
+ filename: string;
22
+ language?: string;
23
+ }
24
+
25
+ export { parseHandNodes }
26
+
27
+ export { tripMapFormatTime }
28
+
29
+ export { TripMapGeoNode }
30
+
31
+ export { TripMapGeoResponse }
32
+
33
+ export declare function useTripMap(props: TripMapProps): UseTripMapResult;
34
+
35
+ export declare interface UseTripMapResult {
36
+ slugList: ComputedRef<string[]>;
37
+ excludeList: ComputedRef<string[]>;
38
+ handNodes: ComputedRef<TripMapHandNode[]>;
39
+ /** Deterministic SSR thumbs for explicit-slugs mode (author order). */
40
+ facadeThumbs: ComputedRef<{
41
+ slug: string;
42
+ src: string;
43
+ }[]>;
44
+ heightStyle: ComputedRef<{
45
+ height: string;
46
+ }>;
47
+ articleContext: MdxArticleContext | null;
48
+ isAutoMode: ComputedRef<boolean>;
49
+ /**
50
+ * Nothing selects media (no slugs, no article context): the geo call only
51
+ * fetches the tenant basemap config, so its failure must not sink a map
52
+ * of hand-made nodes — the wrapper falls back to the core default style.
53
+ */
54
+ isConfigOnly: ComputedRef<boolean>;
55
+ /** Always set: slugs / article selection, or the config-only URL. */
56
+ geoUrl: ComputedRef<string>;
57
+ labels: ComputedRef<{
58
+ play: string;
59
+ stop: string;
60
+ } | undefined>;
61
+ texts: ComputedRef<TripMapUiTexts>;
62
+ }
63
+
64
+ export { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogmcp/mdx-render-vue",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Vue renderer for MDX components — edit + render SFCs wired to @blogmcp/mdx-schema",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -73,6 +73,14 @@
73
73
  "./composables/audioplayer": {
74
74
  "types": "./src/composables/useAudioPlayer.ts",
75
75
  "import": "./dist/composables/useAudioPlayer.js"
76
+ },
77
+ "./render/tripmap": {
78
+ "types": "./src/render/tripmap/index.ts",
79
+ "import": "./dist/render/tripmap/index.js"
80
+ },
81
+ "./composables/tripmap": {
82
+ "types": "./src/composables/useTripMap.ts",
83
+ "import": "./dist/composables/useTripMap.js"
76
84
  }
77
85
  },
78
86
  "files": [
@@ -90,8 +98,9 @@
90
98
  "typecheck": "vue-tsc --noEmit"
91
99
  },
92
100
  "dependencies": {
93
- "@blogmcp/mdx-schema": "^0.2.2",
94
- "@blogmcp/sanitize": "^0.2.2",
101
+ "@blogmcp/mdx-schema": "^0.4.0",
102
+ "@blogmcp/sanitize": "^0.3.0",
103
+ "@blogmcp/tripmap-core": "^0.1.0",
95
104
  "@ark-ui/vue": "^5.0.0",
96
105
  "@iconify/vue": "^5.0.0"
97
106
  },
@@ -14,3 +14,12 @@ export {
14
14
  } from './useYouTube'
15
15
  export { useAudioPlayer } from './useAudioPlayer'
16
16
  export { useOptimizedImg, IMG_COMPONENT_KEY } from './useMedia'
17
+ export {
18
+ useTripMap,
19
+ parseHandNodes,
20
+ tripMapFormatTime,
21
+ MDX_ARTICLE_CONTEXT_KEY,
22
+ type MdxArticleContext,
23
+ type TripMapGeoNode,
24
+ type TripMapGeoResponse,
25
+ } from './useTripMap'
@@ -0,0 +1,107 @@
1
+ /**
2
+ * TripMap composable — Vue binding over the shared framework-free helpers
3
+ * in @blogmcp/mdx-schema/tripmap (prop parsing, geo-URL contract, texts).
4
+ *
5
+ * HARD CONSTRAINT: this module (and every module statically reachable from
6
+ * the TripMap SFCs) must NOT statically import '@blogmcp/tripmap-core' —
7
+ * not even its pure '/nodes' subpath. The article-review MCP-App bundles
8
+ * this package from source into ONE file with that package externalized;
9
+ * a top-level bare import would break the whole bundle at load time. The
10
+ * core is reached exclusively via `import('@blogmcp/tripmap-core')` inside
11
+ * the SFC's intersection handler (rejects harmlessly in the iframe).
12
+ */
13
+
14
+ import { computed, inject, type ComputedRef, type InjectionKey } from 'vue'
15
+ import type { TripMapHandNode, TripMapProps } from '@blogmcp/mdx-schema'
16
+ import {
17
+ buildTripMapGeoUrl,
18
+ parseTripMapHandNodes,
19
+ splitTripMapList,
20
+ TRIPMAP_DEFAULT_HEIGHT,
21
+ tripMapFormatTime,
22
+ tripMapPlaybackLabels,
23
+ tripMapUiTexts,
24
+ type TripMapGeoNode,
25
+ type TripMapGeoResponse,
26
+ type TripMapUiTexts,
27
+ } from '@blogmcp/mdx-schema/tripmap'
28
+
29
+ // Re-export the shared helpers under this package's established names so
30
+ // consumers (md-editor, tests) have one import site on the Vue side.
31
+ export { tripMapFormatTime }
32
+ export { parseTripMapHandNodes as parseHandNodes }
33
+ export type { TripMapGeoNode, TripMapGeoResponse }
34
+
35
+ /**
36
+ * Article identity for AUTO mode (map every geotagged media of the article).
37
+ * The host app provides it around its MDX render tree (the Nuxt blog does it
38
+ * in `components/article/MdxContent.vue` for article + preview pages; a bare
39
+ * consumer that doesn't provide it simply has no auto mode — TripMap then
40
+ * needs explicit `slugs` or `nodes`).
41
+ */
42
+ export interface MdxArticleContext {
43
+ filename: string
44
+ language?: string
45
+ }
46
+
47
+ export const MDX_ARTICLE_CONTEXT_KEY: InjectionKey<MdxArticleContext | null> =
48
+ Symbol.for('blogmcp:mdx-article-context')
49
+
50
+ export interface UseTripMapResult {
51
+ slugList: ComputedRef<string[]>
52
+ excludeList: ComputedRef<string[]>
53
+ handNodes: ComputedRef<TripMapHandNode[]>
54
+ /** Deterministic SSR thumbs for explicit-slugs mode (author order). */
55
+ facadeThumbs: ComputedRef<{ slug: string; src: string }[]>
56
+ heightStyle: ComputedRef<{ height: string }>
57
+ articleContext: MdxArticleContext | null
58
+ isAutoMode: ComputedRef<boolean>
59
+ /**
60
+ * Nothing selects media (no slugs, no article context): the geo call only
61
+ * fetches the tenant basemap config, so its failure must not sink a map
62
+ * of hand-made nodes — the wrapper falls back to the core default style.
63
+ */
64
+ isConfigOnly: ComputedRef<boolean>
65
+ /** Always set: slugs / article selection, or the config-only URL. */
66
+ geoUrl: ComputedRef<string>
67
+ labels: ComputedRef<{ play: string; stop: string } | undefined>
68
+ texts: ComputedRef<TripMapUiTexts>
69
+ }
70
+
71
+ export function useTripMap(props: TripMapProps): UseTripMapResult {
72
+ const articleContext = inject(MDX_ARTICLE_CONTEXT_KEY, null)
73
+
74
+ const slugList = computed(() => splitTripMapList(props.slugs))
75
+ const excludeList = computed(() => splitTripMapList(props.exclude))
76
+ const handNodes = computed(() => parseTripMapHandNodes(props.nodes))
77
+ const isAutoMode = computed(() => slugList.value.length === 0)
78
+ const isConfigOnly = computed(() => slugList.value.length === 0 && !articleContext?.filename)
79
+
80
+ const facadeThumbs = computed(() =>
81
+ slugList.value.map((slug) => ({
82
+ slug,
83
+ src: `/api/media/thumb/${encodeURIComponent(slug)}?width=192`,
84
+ })),
85
+ )
86
+
87
+ const heightStyle = computed(() => ({ height: props.height || TRIPMAP_DEFAULT_HEIGHT }))
88
+
89
+ const geoUrl = computed(() => buildTripMapGeoUrl(props, articleContext))
90
+
91
+ const labels = computed(() => tripMapPlaybackLabels(articleContext?.language))
92
+ const texts = computed(() => tripMapUiTexts(articleContext?.language))
93
+
94
+ return {
95
+ slugList,
96
+ excludeList,
97
+ handNodes,
98
+ facadeThumbs,
99
+ heightStyle,
100
+ articleContext,
101
+ isAutoMode,
102
+ isConfigOnly,
103
+ geoUrl,
104
+ labels,
105
+ texts,
106
+ }
107
+ }
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TripMap edit placeholder for the md-editor Tiptap NodeView.
4
+ *
5
+ * Deliberately STATIC — no map canvas, no fetch, no maplibre import. The
6
+ * NodeView re-renders on keystroke-adjacent updates; mounting a WebGL map
7
+ * there would thrash. Authors see a compact summary chip row instead and
8
+ * check the real map through the preview panel / published page.
9
+ */
10
+
11
+ import { computed } from 'vue'
12
+ import type { TripMapProps } from '@blogmcp/mdx-schema'
13
+ import { parseHandNodes } from '../composables/useTripMap'
14
+
15
+ const props = defineProps<TripMapProps>()
16
+
17
+ const slugCount = computed(() => (props.slugs ?? '').split(',').filter((s) => s.trim()).length)
18
+ const nodeCount = computed(() => parseHandNodes(props.nodes).length)
19
+ </script>
20
+
21
+ <template>
22
+ <div class="flex items-center gap-3 rounded-lg border border-blue-500/40 bg-blue-500/5 px-3 py-2" data-mdx-component="TripMap">
23
+ <span aria-hidden="true">🗺</span>
24
+ <span class="font-medium">{{ title || 'TripMap' }}</span>
25
+ <span class="text-sm opacity-70">
26
+ <template v-if="slugCount > 0">{{ slugCount }} photos</template>
27
+ <template v-else>auto (article media)</template>
28
+ <template v-if="nodeCount > 0"> · {{ nodeCount }} waypoints</template>
29
+ </span>
30
+ </div>
31
+ </template>
package/src/edit/index.ts CHANGED
@@ -17,6 +17,7 @@ export { default as AlertEdit } from './AlertEdit.vue'
17
17
  export { default as BadgeEdit } from './BadgeEdit.vue'
18
18
  export { default as IconEdit } from './IconEdit.vue'
19
19
  export { default as TooltipEdit } from './TooltipEdit.vue'
20
+ export { default as TripMapEdit } from './TripMapEdit.vue'
20
21
 
21
22
  // Re-export as generic names for backward compatibility
22
23
  export { default as Callout } from './CalloutEdit.vue'
@@ -30,3 +31,4 @@ export { default as Alert } from './AlertEdit.vue'
30
31
  export { default as Badge } from './BadgeEdit.vue'
31
32
  export { default as Icon } from './IconEdit.vue'
32
33
  export { default as Tooltip } from './TooltipEdit.vue'
34
+ export { default as TripMap } from './TripMapEdit.vue'
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  GalleryEdit,
17
17
  YouTubeEdit,
18
18
  AudioPlayerEdit,
19
+ TripMapEdit,
19
20
  // Aliases for backward compatibility
20
21
  Callout as EditCallout,
21
22
  CodeBlock as EditCodeBlock,
@@ -37,6 +38,8 @@ export {
37
38
  YouTubeDefault,
38
39
  AudioPlayer as RenderAudioPlayer,
39
40
  AudioPlayerDefault,
41
+ TripMap as RenderTripMap,
42
+ TripMapDefault,
40
43
  getRenderComponent,
41
44
  getComponentVariants,
42
45
  } from './render'
package/src/registry.ts CHANGED
@@ -142,6 +142,13 @@ const vueWiring: Record<string, VueWiring> = {
142
142
  },
143
143
  edit: () => import('./edit/TooltipEdit.vue'),
144
144
  },
145
+ TripMap: {
146
+ editor: { icon: 'map', color: '#3b82f6', borderColor: '#3b82f6', bgColor: 'rgba(59, 130, 246, 0.08)' },
147
+ render: {
148
+ default: () => import('./render/tripmap/TripMapDefault.vue'),
149
+ },
150
+ edit: () => import('./edit/TripMapEdit.vue'),
151
+ },
145
152
  }
146
153
 
147
154
  /** Merged registry: schema metadata + Vue wiring, keyed by component name. */
@@ -20,6 +20,7 @@ import { Alert as AlertDefault } from './alert'
20
20
  import { Badge as BadgeDefault } from './badge'
21
21
  import { Icon as IconDefault } from './icon'
22
22
  import { Tooltip as TooltipDefault } from './tooltip'
23
+ import { TripMapDefault } from './tripmap'
23
24
 
24
25
  // Re-export named variants (backward compat)
25
26
  export { CalloutDefault, CalloutMinimal }
@@ -28,6 +29,7 @@ export { GalleryGrid, GalleryLightbox }
28
29
  export { YouTubeDefault }
29
30
  export { AudioPlayerDefault }
30
31
  export { ButtonDefault, CardDefault, AlertDefault, BadgeDefault, IconDefault, TooltipDefault }
32
+ export { TripMapDefault }
31
33
 
32
34
  // Default exports for convenience
33
35
  export { default as Callout } from './callout'
@@ -41,6 +43,7 @@ export { default as Alert } from './alert'
41
43
  export { default as Badge } from './badge'
42
44
  export { default as Icon } from './icon'
43
45
  export { default as Tooltip } from './tooltip'
46
+ export { default as TripMap } from './tripmap'
44
47
 
45
48
  // Async resolver and helpers from central registry (preferred for new code)
46
49
  export {
@@ -63,6 +66,7 @@ const syncRegistry: Record<string, Record<string, unknown>> = {
63
66
  Badge: { default: BadgeDefault },
64
67
  Icon: { default: IconDefault },
65
68
  Tooltip: { default: TooltipDefault },
69
+ TripMap: { default: TripMapDefault },
66
70
  }
67
71
 
68
72
  /** @deprecated Use getRenderComponentAsync from registry instead */
@@ -0,0 +1,176 @@
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
+ })
29
+
30
+ const { slugList, handNodes, facadeThumbs, heightStyle, geoUrl, isConfigOnly, labels, texts } =
31
+ useTripMap(props)
32
+
33
+ const rootEl = ref<HTMLElement | null>(null)
34
+ const mapEl = ref<HTMLElement | null>(null)
35
+ const status = ref<'facade' | 'loading' | 'live' | 'empty'>('facade')
36
+ const skippedCount = ref(0)
37
+
38
+ let destroyMap: (() => void) | null = null
39
+ let observer: IntersectionObserver | null = null
40
+ let started = false
41
+
42
+ async function fetchGeoNodes(): Promise<TripMapGeoResponse> {
43
+ try {
44
+ const res = await fetch(geoUrl.value, { headers: { accept: 'application/json' } })
45
+ if (!res.ok) throw new Error(`media/geo ${res.status}`)
46
+ return (await res.json()) as TripMapGeoResponse
47
+ } catch (err) {
48
+ // Config-only call (hand-made nodes, nothing selected): the map still has
49
+ // its nodes — run on the core default style rather than staying a facade.
50
+ // With a selection the nodes ARE the response, so the failure propagates
51
+ // and the static facade stays (designed degradation).
52
+ if (isConfigOnly.value) return { nodes: [] }
53
+ throw err
54
+ }
55
+ }
56
+
57
+ async function start(): Promise<void> {
58
+ if (started) return
59
+ started = true
60
+ status.value = 'loading'
61
+ try {
62
+ const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeoNodes()])
63
+ const mediaNodes = (geo.nodes ?? [])
64
+ .filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
65
+ .map((n) => ({
66
+ id: n.slug,
67
+ lat: n.latitude,
68
+ lng: n.longitude,
69
+ capturedAt: n.capturedAt ?? undefined,
70
+ thumbnailUrl: n.thumbnailUrl ?? undefined,
71
+ imageUrl: n.url ?? undefined,
72
+ altText: n.altText ?? undefined,
73
+ title: n.placeName ?? undefined,
74
+ }))
75
+ const nodes = core.buildTripNodes(mediaNodes, handNodes.value)
76
+ skippedCount.value = geo.skipped ?? 0
77
+ if (nodes.length === 0 || !mapEl.value) {
78
+ status.value = 'empty'
79
+ return
80
+ }
81
+ const map = new core.TripMap(mapEl.value, {
82
+ styleUrl: props.styleUrl || geo.config?.styleUrl || '',
83
+ darkStyleUrl: geo.config?.darkStyleUrl,
84
+ theme: props.theme,
85
+ cluster: props.cluster,
86
+ showLine: props.line,
87
+ showFilmstrip: props.filmstrip,
88
+ showPlayback: props.playback,
89
+ extraAttribution: geo.config?.attribution,
90
+ labels: labels.value,
91
+ initialView: initialViewFromProps(),
92
+ events: {},
93
+ })
94
+ map.setNodes(nodes)
95
+ await map.init()
96
+ destroyMap = () => map.destroy()
97
+ status.value = 'live'
98
+ } catch {
99
+ // Core unavailable (review-app iframe, offline) or fetch failed —
100
+ // the static facade stays, which is the designed degradation.
101
+ status.value = started && status.value === 'loading' ? 'facade' : status.value
102
+ }
103
+ }
104
+
105
+ function initialViewFromProps(): { center?: [number, number]; zoom?: number } | undefined {
106
+ if (!props.center) return undefined
107
+ const [lat, lng] = props.center.split(',').map((v) => Number.parseFloat(v.trim()))
108
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
109
+ return { center: [lng, lat], zoom: props.zoom }
110
+ }
111
+
112
+ onMounted(() => {
113
+ const el = rootEl.value
114
+ if (!el) return
115
+ if (typeof IntersectionObserver === 'undefined') {
116
+ void start()
117
+ return
118
+ }
119
+ observer = new IntersectionObserver(
120
+ (entries) => {
121
+ if (entries.some((e) => e.isIntersecting)) {
122
+ observer?.disconnect()
123
+ observer = null
124
+ void start()
125
+ }
126
+ },
127
+ { rootMargin: '300px' },
128
+ )
129
+ observer.observe(el)
130
+ })
131
+
132
+ onBeforeUnmount(() => {
133
+ observer?.disconnect()
134
+ destroyMap?.()
135
+ })
136
+ </script>
137
+
138
+ <template>
139
+ <div ref="rootEl" :class="tripMapStyles.container" data-mdx-component="TripMap" :aria-label="title">
140
+ <p v-if="title" :class="tripMapStyles.head">{{ title }}</p>
141
+
142
+ <!-- Facade thumb strip (explicit-slugs mode); replaced by the live filmstrip. -->
143
+ <ol v-if="facadeThumbs.length && status !== 'live'" :class="tripMapStyles.strip">
144
+ <li v-for="thumb in facadeThumbs" :key="thumb.slug" :class="tripMapStyles.stripItem">
145
+ <img :src="thumb.src" alt="" width="80" height="80" loading="lazy" decoding="async" :class="tripMapStyles.stripImg" />
146
+ </li>
147
+ </ol>
148
+
149
+ <!--
150
+ Sized wrapper: the engine host is a static absolutely-positioned child
151
+ that Vue never re-patches (reactive bindings on the engine's container
152
+ would clobber the classes/children the engine adds); the facade box is
153
+ an overlay that simply unmounts when the map goes live.
154
+ -->
155
+ <div :class="tripMapStyles.mapWrap" :style="heightStyle">
156
+ <div ref="mapEl" class="absolute inset-0"></div>
157
+ <div v-if="status !== 'live'" :class="[tripMapStyles.mapOverlay]">
158
+ <div :class="tripMapStyles.mapBox">
159
+ <span :class="tripMapStyles.mapHint">
160
+ {{ status === 'empty' ? texts.empty : texts.hint }}
161
+ </span>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- Waypoint stop list (hand-made nodes) for the static facade. -->
167
+ <ol v-if="handNodes.length && status !== 'live'" :class="tripMapStyles.stopList">
168
+ <li v-for="(node, i) in handNodes" :key="node.id || i">
169
+ <span v-if="node.capturedAt" :class="tripMapStyles.stopTime">{{ tripMapFormatTime(node.capturedAt) }}</span>
170
+ <span>{{ node.title || `${node.lat}, ${node.lng}` }}</span>
171
+ </li>
172
+ </ol>
173
+
174
+ <p v-if="status !== 'live'" :class="tripMapStyles.note">{{ texts.unavailable }}</p>
175
+ </div>
176
+ </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'