@blogmcp/mdx-render-react 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukas Zubal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -30,3 +30,12 @@ Tailwind classes verbatim.
30
30
 
31
31
  When the first React consumer appears (blog-react or editor-react). Until then, keep the package
32
32
  empty so the schema contract proves framework-neutral by construction.
33
+
34
+ ## Real-browser tests
35
+
36
+ `npm run test:e2e` — Playwright against a Vite harness (`test/e2e/harness`, port 5097; JSX via
37
+ esbuild's automatic runtime, no plugin-react) mounting the real `TripMap` renderer: hand-made nodes,
38
+ the tenant-config path, AUTO mode through `MdxArticleProvider`, exclude, explicit slugs, an unknown
39
+ article, clusters, the detail panel, export, the viewer — on a desktop and a phone project. The
40
+ `/api/media/geo` mock is shared with the Vue harness (`packages/mdx-schema/test/harness/geo-mock.ts`).
41
+ `npm run harness` serves the pages for manual work.
package/dist/index.js CHANGED
@@ -1,43 +1,48 @@
1
1
  import { default as o } from "./MdxContent.js";
2
- import { serverRenderMap as r } from "./server-render-map.js";
3
- import { getComponentVariants as l, getRenderComponentAsync as m, reactRegistry as d } from "./registry.js";
4
- import { createMarked as u, marked as n } from "./marked.js";
2
+ import { serverRenderMap as a } from "./server-render-map.js";
3
+ import { getComponentVariants as l, getRenderComponentAsync as d, reactRegistry as m } from "./registry.js";
4
+ import { createMarked as u, marked as x } from "./marked.js";
5
5
  import { default as s } from "./render/callout/CalloutDefault.js";
6
- import { default as g } from "./render/callout/CalloutMinimal.js";
6
+ import { default as C } from "./render/callout/CalloutMinimal.js";
7
7
  import { default as c } from "./render/youtube/YouTubeDefault.js";
8
- import { default as y } from "./render/gallery/GalleryGrid.js";
9
- import { default as k } from "./render/codeblock/CodeBlockDefault.js";
8
+ import { default as A } from "./render/gallery/GalleryGrid.js";
9
+ import { default as D } from "./render/codeblock/CodeBlockDefault.js";
10
10
  import { default as B } from "./render/audioplayer/AudioPlayerDefault.js";
11
- import { default as G } from "./render/button/Button.js";
12
- import { default as T } from "./render/card/Card.js";
13
- import { default as v } from "./render/alert/Alert.js";
14
- import { default as N } from "./render/badge/Badge.js";
11
+ import { default as T } from "./render/button/Button.js";
12
+ import { default as G } from "./render/card/Card.js";
13
+ import { default as P } from "./render/alert/Alert.js";
14
+ import { default as H } from "./render/badge/Badge.js";
15
15
  import { default as V } from "./render/icon/Icon.js";
16
16
  import { default as h } from "./render/tooltip/Tooltip.js";
17
- import { componentMetadata as q, getComponentMetadata as w, getComponentNames as z } from "@blogmcp/mdx-schema/metadata";
18
- import { defaultImageHtml as F } from "@blogmcp/sdk";
17
+ import { default as q } from "./render/tripmap/TripMap.js";
18
+ import { MdxArticleContext as z, MdxArticleProvider as E } from "./render/tripmap/article-context.js";
19
+ import { componentMetadata as J, getComponentMetadata as K, getComponentNames as L } from "@blogmcp/mdx-schema/metadata";
20
+ import { defaultImageHtml as Q } from "@blogmcp/sdk";
19
21
  export {
20
- v as Alert,
22
+ P as Alert,
21
23
  B as AudioPlayerDefault,
22
- N as Badge,
23
- G as Button,
24
+ H as Badge,
25
+ T as Button,
24
26
  s as CalloutDefault,
25
- g as CalloutMinimal,
26
- T as Card,
27
- k as CodeBlockDefault,
28
- y as GalleryGrid,
27
+ C as CalloutMinimal,
28
+ G as Card,
29
+ D as CodeBlockDefault,
30
+ A as GalleryGrid,
29
31
  V as Icon,
32
+ z as MdxArticleContext,
33
+ E as MdxArticleProvider,
30
34
  o as MdxContent,
31
35
  h as Tooltip,
36
+ q as TripMap,
32
37
  c as YouTubeDefault,
33
- q as componentMetadata,
38
+ J as componentMetadata,
34
39
  u as createMarked,
35
- F as defaultImageHtml,
36
- w as getComponentMetadata,
37
- z as getComponentNames,
40
+ Q as defaultImageHtml,
41
+ K as getComponentMetadata,
42
+ L as getComponentNames,
38
43
  l as getComponentVariants,
39
- m as getRenderComponentAsync,
40
- n as marked,
41
- d as reactRegistry,
42
- r as serverRenderMap
44
+ d as getRenderComponentAsync,
45
+ x as marked,
46
+ m as reactRegistry,
47
+ a as serverRenderMap
43
48
  };
package/dist/registry.js CHANGED
@@ -57,6 +57,11 @@ const i = {
57
57
  render: {
58
58
  default: () => import("./render/tooltip/Tooltip.js")
59
59
  }
60
+ },
61
+ TripMap: {
62
+ render: {
63
+ default: () => import("./render/tripmap/TripMap.js")
64
+ }
60
65
  }
61
66
  }, o = Object.fromEntries(
62
67
  d.map((e) => {
@@ -68,11 +73,11 @@ const i = {
68
73
  return [e.name, { metadata: e, ...r }];
69
74
  })
70
75
  );
71
- function u(e) {
76
+ function p(e) {
72
77
  const r = o[e];
73
78
  return r ? Object.keys(r.render) : [];
74
79
  }
75
- async function p(e, r = "default") {
80
+ async function u(e, r = "default") {
76
81
  const t = o[e];
77
82
  if (!t) return;
78
83
  const n = t.render[r] || t.render.default;
@@ -82,7 +87,7 @@ export {
82
87
  f as componentMetadata,
83
88
  s as getComponentMetadata,
84
89
  g as getComponentNames,
85
- u as getComponentVariants,
86
- p as getRenderComponentAsync,
90
+ p as getComponentVariants,
91
+ u as getRenderComponentAsync,
87
92
  o as reactRegistry
88
93
  };
@@ -16,6 +16,9 @@ export { default as Alert } from './alert/Alert';
16
16
  export { default as Badge } from './badge/Badge';
17
17
  export { default as Icon } from './icon/Icon';
18
18
  export { default as Tooltip } from './tooltip/Tooltip';
19
+ export { default as TripMap } from './tripmap/TripMap';
20
+ export { MdxArticleContext, MdxArticleProvider } from './tripmap/article-context';
21
+ export type { MdxArticleContextValue } from './tripmap/article-context';
19
22
  export type { ButtonProps } from './button/Button';
20
23
  export type { BadgeProps } from './badge/Badge';
21
24
  export type { AlertProps } from './alert/Alert';
@@ -2,25 +2,30 @@ import { default as o } from "./callout/CalloutDefault.js";
2
2
  import { default as r } from "./callout/CalloutMinimal.js";
3
3
  import { default as l } from "./youtube/YouTubeDefault.js";
4
4
  import { default as d } from "./gallery/GalleryGrid.js";
5
- import { default as p } from "./codeblock/CodeBlockDefault.js";
6
- import { default as x } from "./audioplayer/AudioPlayerDefault.js";
5
+ import { default as x } from "./codeblock/CodeBlockDefault.js";
6
+ import { default as s } from "./audioplayer/AudioPlayerDefault.js";
7
7
  import { default as C } from "./button/Button.js";
8
8
  import { default as n } from "./card/Card.js";
9
- import { default as c } from "./alert/Alert.js";
10
- import { default as A } from "./badge/Badge.js";
11
- import { default as T } from "./icon/Icon.js";
12
- import { default as g } from "./tooltip/Tooltip.js";
9
+ import { default as D } from "./alert/Alert.js";
10
+ import { default as B } from "./badge/Badge.js";
11
+ import { default as y } from "./icon/Icon.js";
12
+ import { default as P } from "./tooltip/Tooltip.js";
13
+ import { default as g } from "./tripmap/TripMap.js";
14
+ import { MdxArticleContext as v, MdxArticleProvider as I } from "./tripmap/article-context.js";
13
15
  export {
14
- c as Alert,
15
- x as AudioPlayerDefault,
16
- A as Badge,
16
+ D as Alert,
17
+ s as AudioPlayerDefault,
18
+ B as Badge,
17
19
  C as Button,
18
20
  o as CalloutDefault,
19
21
  r as CalloutMinimal,
20
22
  n as Card,
21
- p as CodeBlockDefault,
23
+ x as CodeBlockDefault,
22
24
  d as GalleryGrid,
23
- T as Icon,
24
- g as Tooltip,
25
+ y as Icon,
26
+ v as MdxArticleContext,
27
+ I as MdxArticleProvider,
28
+ P as Tooltip,
29
+ g as TripMap,
25
30
  l as YouTubeDefault
26
31
  };
@@ -0,0 +1,2 @@
1
+ import type { TripMapProps } from '@blogmcp/mdx-schema';
2
+ export default function TripMap(props: TripMapProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,111 @@
1
+ "use client";
2
+ import { jsxs as M, jsx as a } from "react/jsx-runtime";
3
+ import { useContext as q, useRef as d, useState as J, useMemo as n, useCallback as K, useEffect as _ } from "react";
4
+ import { tripMapStyles as r } from "@blogmcp/mdx-schema/styles";
5
+ import { splitTripMapList as Q, parseTripMapHandNodes as X, buildTripMapGeoUrl as Y, tripMapUiTexts as Z, tripMapPlaybackLabels as ee, tripMapFacade as te, TRIPMAP_DEFAULT_HEIGHT as ae, tripMapFormatTime as re } from "@blogmcp/mdx-schema/tripmap";
6
+ import { MdxArticleContext as ie } from "./article-context.js";
7
+ function de(o) {
8
+ const { slugs: T, nodes: x, height: B, line: U, filmstrip: k, playback: D, cluster: A, theme: F, styleUrl: I, center: f, zoom: R, title: c, detail: h } = o, i = q(ie), L = d(null), g = d(null), V = d(null), y = d(null), H = d(!1), [P, u] = J("facade"), W = n(() => Q(T), [T]), S = n(() => X(x), [x]), j = n(() => Y(o, i), [o, i]), E = W.length === 0 && !i?.filename, b = n(() => Z(i?.language), [i]), O = n(() => ee(i?.language), [i]), p = n(() => te(o, i), [o, i]), v = K(async () => {
9
+ if (!H.current) {
10
+ H.current = !0, u("loading");
11
+ try {
12
+ const t = async () => {
13
+ try {
14
+ const e = await fetch(j, { headers: { accept: "application/json" } });
15
+ if (!e.ok) throw new Error(`media/geo ${e.status}`);
16
+ return await e.json();
17
+ } catch (e) {
18
+ if (E) return { nodes: [] };
19
+ throw e;
20
+ }
21
+ }, [l, s] = await Promise.all([import("@blogmcp/tripmap-core"), t()]), N = (s.nodes ?? []).filter((e) => typeof e.latitude == "number" && typeof e.longitude == "number").map((e) => ({
22
+ id: e.slug,
23
+ lat: e.latitude,
24
+ lng: e.longitude,
25
+ capturedAt: e.capturedAt ?? void 0,
26
+ thumbnailUrl: e.thumbnailUrl ?? void 0,
27
+ imageUrl: e.url ?? void 0,
28
+ largeImageUrl: e.largeUrl ?? void 0,
29
+ altText: e.altText ?? void 0,
30
+ title: e.placeName ?? void 0
31
+ })), C = l.buildTripNodes(N, S);
32
+ if (C.length === 0 || !g.current) {
33
+ u("empty");
34
+ return;
35
+ }
36
+ let G;
37
+ if (f) {
38
+ const [e, z] = f.split(",").map(($) => Number.parseFloat($.trim()));
39
+ Number.isFinite(e) && Number.isFinite(z) && (G = { center: [z, e], zoom: R });
40
+ }
41
+ const w = new l.TripMap(g.current, {
42
+ styleUrl: I || s.config?.styleUrl || "",
43
+ darkStyleUrl: s.config?.darkStyleUrl,
44
+ theme: F ?? "auto",
45
+ cluster: A ?? !0,
46
+ showLine: U ?? !0,
47
+ showFilmstrip: k ?? !0,
48
+ showPlayback: D ?? !0,
49
+ title: c,
50
+ detail: h ?? "popup",
51
+ detailHost: h === "panel" ? V.current ?? void 0 : void 0,
52
+ extraAttribution: s.config?.attribution,
53
+ labels: O,
54
+ initialView: G,
55
+ events: {}
56
+ });
57
+ w.setNodes(C), await w.init(), y.current = () => w.destroy(), u("live");
58
+ } catch {
59
+ u((t) => t === "loading" ? "facade" : t);
60
+ }
61
+ }
62
+ }, [j, E, S, I, F, A, U, k, O, f, R, c, h]);
63
+ _(() => {
64
+ const t = L.current;
65
+ if (!t) return;
66
+ if (typeof IntersectionObserver > "u") {
67
+ v();
68
+ return;
69
+ }
70
+ const l = new IntersectionObserver(
71
+ (s) => {
72
+ s.some((N) => N.isIntersecting) && (l.disconnect(), v());
73
+ },
74
+ { rootMargin: "300px" }
75
+ );
76
+ return l.observe(t), () => l.disconnect();
77
+ }, [v]), _(
78
+ () => () => {
79
+ y.current?.(), y.current = null;
80
+ },
81
+ []
82
+ );
83
+ const m = P !== "live";
84
+ return /* @__PURE__ */ M("div", { ref: L, className: r.container, "data-mdx-component": "TripMap", "aria-label": c, children: [
85
+ c ? /* @__PURE__ */ a("p", { className: r.head, children: c }) : null,
86
+ m && p.thumbs.length > 0 ? /* @__PURE__ */ a("ol", { className: r.strip, children: p.thumbs.map((t) => /* @__PURE__ */ a("li", { className: r.stripItem, children: /* @__PURE__ */ a(
87
+ "img",
88
+ {
89
+ src: t.src,
90
+ alt: t.alt,
91
+ width: 80,
92
+ height: 80,
93
+ loading: "lazy",
94
+ decoding: "async",
95
+ className: r.stripImg
96
+ }
97
+ ) }, t.slug)) }) : null,
98
+ /* @__PURE__ */ M("div", { className: r.mapWrap, style: { height: B || ae }, children: [
99
+ /* @__PURE__ */ a("div", { ref: g, className: "absolute inset-0" }),
100
+ m ? /* @__PURE__ */ a("div", { className: r.mapOverlay, children: /* @__PURE__ */ a("div", { className: r.mapBox, children: /* @__PURE__ */ a("span", { className: r.mapHint, children: P === "empty" ? b.empty : b.hint }) }) }) : null
101
+ ] }),
102
+ m && p.stops.length > 0 ? /* @__PURE__ */ a("ol", { className: r.stopList, children: p.stops.map((t) => /* @__PURE__ */ M("li", { children: [
103
+ t.capturedAt ? /* @__PURE__ */ a("span", { className: r.stopTime, children: re(t.capturedAt) }) : null,
104
+ /* @__PURE__ */ a("span", { children: t.title })
105
+ ] }, t.key)) }) : null,
106
+ m ? /* @__PURE__ */ a("p", { className: r.note, children: b.unavailable }) : null
107
+ ] });
108
+ }
109
+ export {
110
+ de as default
111
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Article identity for TripMap AUTO mode (map every geotagged media of the
3
+ * article). The host app wraps its rendered article body in the provider —
4
+ * the Next blog does in `app/articles/[slug]/page.tsx` AND the draft preview
5
+ * `app/preview/[token]/page.tsx`; a consumer that doesn't simply has no auto
6
+ * mode and TripMap needs explicit `slugs` or `nodes`.
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap';
10
+ export interface MdxArticleContextValue {
11
+ filename: string;
12
+ language?: string;
13
+ /**
14
+ * The article's geotagged media (`contentImagesGeo`), when the host has
15
+ * them: the SERVER-RENDERED facade then carries real thumbnails with alt
16
+ * text and named stops in AUTO mode — what crawlers read. Optional.
17
+ */
18
+ media?: readonly TripMapFacadeMedia[] | null;
19
+ }
20
+ export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
21
+ export declare function MdxArticleProvider({ value, children, }: {
22
+ value: MdxArticleContextValue | null;
23
+ children: ReactNode;
24
+ }): import("react").FunctionComponentElement<import("react").ProviderProps<MdxArticleContextValue | null>>;
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { createContext as r, createElement as o } from "react";
3
+ const n = r(null);
4
+ function i({
5
+ value: e,
6
+ children: t
7
+ }) {
8
+ return o(n.Provider, { value: e }, t);
9
+ }
10
+ export {
11
+ n as MdxArticleContext,
12
+ i as MdxArticleProvider
13
+ };
@@ -11,7 +11,8 @@ import P from "./render/alert/Alert.js";
11
11
  import x from "./render/badge/Badge.js";
12
12
  import y from "./render/icon/Icon.js";
13
13
  import w from "./render/tooltip/Tooltip.js";
14
- const E = {
14
+ import E from "./render/tripmap/TripMap.js";
15
+ const R = {
15
16
  Callout: c,
16
17
  YouTube: f,
17
18
  Gallery: l,
@@ -22,35 +23,36 @@ const E = {
22
23
  Alert: P,
23
24
  Badge: x,
24
25
  Icon: y,
25
- Tooltip: w
26
- }, R = "__mdxPreparePropsAppliedFor";
27
- function A(r, e) {
26
+ Tooltip: w,
27
+ TripMap: E
28
+ }, A = "__mdxPreparePropsAppliedFor";
29
+ function C(r, e) {
28
30
  const o = function(t) {
29
- const { children: a, ...n } = t, m = i(n, e);
30
- return p(r, m, a);
31
+ const { children: a, ...m } = t, n = i(m, e);
32
+ return p(r, n, a);
31
33
  };
32
- return o[R] = e.name, o.displayName = `MdxPrepared(${e.name})`, o;
34
+ return o[A] = e.name, o.displayName = `MdxPrepared(${e.name})`, o;
33
35
  }
34
- const C = new Map(
36
+ const D = new Map(
35
37
  d.map((r) => [r.name, r])
36
38
  );
37
- function D(r) {
38
- const e = C.get(r);
39
+ function T(r) {
40
+ const e = D.get(r);
39
41
  if (!e)
40
42
  throw new Error(
41
43
  `[mdx-render-react] No componentMetadata for "${r}" — the React render map has a component the schema does not know about. Add an entry in packages/mdx-schema/src/metadata/index.ts or remove the entry from serverRenderMap.`
42
44
  );
43
45
  return e;
44
46
  }
45
- const Y = Object.fromEntries(
46
- Object.entries(E).map(([r, e]) => [
47
+ const z = Object.fromEntries(
48
+ Object.entries(R).map(([r, e]) => [
47
49
  r,
48
- A(e, D(r))
50
+ C(e, T(r))
49
51
  ])
50
52
  );
51
53
  export {
52
- R as MDX_PREPARED_SENTINEL,
53
- E as serverRenderMap,
54
- A as wrapReactComponentForMdx,
55
- Y as wrappedServerRenderMap
54
+ A as MDX_PREPARED_SENTINEL,
55
+ R as serverRenderMap,
56
+ C as wrapReactComponentForMdx,
57
+ z as wrappedServerRenderMap
56
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogmcp/mdx-render-react",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "React renderer for MDX components — implements the @blogmcp/mdx-schema contract alongside @blogmcp/mdx-render-vue. Server-Component-first with two client islands (Tooltip, AudioPlayer).",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,13 +32,16 @@
32
32
  "dev": "vite build --watch",
33
33
  "test": "vitest run",
34
34
  "test:watch": "vitest",
35
- "typecheck": "tsc --noEmit"
35
+ "typecheck": "tsc --noEmit",
36
+ "test:e2e": "playwright test --config test/e2e/playwright.config.ts",
37
+ "harness": "vite --config test/e2e/harness/vite.config.ts --port 5097"
36
38
  },
37
39
  "dependencies": {
38
40
  "@ark-ui/react": "^5.0.0",
39
- "@blogmcp/mdx-schema": "^0.3.0",
41
+ "@blogmcp/mdx-schema": "^0.5.0",
42
+ "@blogmcp/tripmap-core": "^0.1.2",
40
43
  "@blogmcp/sanitize": "^0.3.0",
41
- "@blogmcp/sdk": "^0.3.0",
44
+ "@blogmcp/sdk": "^0.3.2",
42
45
  "@iconify/react": "^6.0.0",
43
46
  "marked": "^15.0.7"
44
47
  },
package/src/registry.ts CHANGED
@@ -95,6 +95,11 @@ const reactWiring: Record<string, ReactWiring> = {
95
95
  default: () => import('./render/tooltip/Tooltip'),
96
96
  },
97
97
  },
98
+ TripMap: {
99
+ render: {
100
+ default: () => import('./render/tripmap/TripMap'),
101
+ },
102
+ },
98
103
  }
99
104
 
100
105
  /** Merged registry: schema metadata + React wiring, keyed by component name. */
@@ -16,6 +16,9 @@ export { default as Alert } from './alert/Alert'
16
16
  export { default as Badge } from './badge/Badge'
17
17
  export { default as Icon } from './icon/Icon'
18
18
  export { default as Tooltip } from './tooltip/Tooltip'
19
+ export { default as TripMap } from './tripmap/TripMap'
20
+ export { MdxArticleContext, MdxArticleProvider } from './tripmap/article-context'
21
+ export type { MdxArticleContextValue } from './tripmap/article-context'
19
22
 
20
23
  export type { ButtonProps } from './button/Button'
21
24
  export type { BadgeProps } from './badge/Badge'
@@ -0,0 +1,209 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * TripMap — client island (mirrors Vue TripMapDefault.vue).
5
+ *
6
+ * SSR-safe facade (thumb strip + waypoint list + placeholder box) that
7
+ * upgrades to the MapLibre map when scrolled near. The map engine is
8
+ * reached ONLY via dynamic `import('@blogmcp/tripmap-core')` — never a
9
+ * static import — so bundlers emit maplibre as a lazy chunk and the
10
+ * single-file article-review bundle (which externalizes the core) simply
11
+ * keeps the facade when the import rejects.
12
+ */
13
+
14
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
15
+ import type { TripMapProps } from '@blogmcp/mdx-schema'
16
+ import { tripMapStyles } from '@blogmcp/mdx-schema/styles'
17
+ import {
18
+ buildTripMapGeoUrl,
19
+ parseTripMapHandNodes,
20
+ splitTripMapList,
21
+ TRIPMAP_DEFAULT_HEIGHT,
22
+ tripMapFormatTime,
23
+ tripMapPlaybackLabels,
24
+ tripMapUiTexts,
25
+ type TripMapGeoResponse,
26
+ tripMapFacade,
27
+ } from '@blogmcp/mdx-schema/tripmap'
28
+
29
+ import { MdxArticleContext } from './article-context'
30
+
31
+ type Status = 'facade' | 'loading' | 'live' | 'empty'
32
+
33
+ export default function TripMap(props: TripMapProps) {
34
+ const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title, detail } = props
35
+ const article = useContext(MdxArticleContext)
36
+
37
+ const rootRef = useRef<HTMLDivElement | null>(null)
38
+ const mapRef = useRef<HTMLDivElement | null>(null)
39
+ // detail="panel": the engine mounts the photo panel here, below the sized map box.
40
+ const detailRef = useRef<HTMLDivElement | null>(null)
41
+ const destroyRef = useRef<(() => void) | null>(null)
42
+ const startedRef = useRef(false)
43
+ const [status, setStatus] = useState<Status>('facade')
44
+
45
+ const slugList = useMemo(() => splitTripMapList(slugs), [slugs])
46
+ const handNodes = useMemo(() => parseTripMapHandNodes(nodes), [nodes])
47
+ const geoUrl = useMemo(() => buildTripMapGeoUrl(props, article), [props, article])
48
+ // Nothing selects media: the geo call only carries the tenant basemap config.
49
+ const configOnly = slugList.length === 0 && !article?.filename
50
+ const texts = useMemo(() => tripMapUiTexts(article?.language), [article])
51
+ const labels = useMemo(() => tripMapPlaybackLabels(article?.language), [article])
52
+ const facade = useMemo(() => tripMapFacade(props, article), [props, article])
53
+
54
+ const start = useCallback(async () => {
55
+ if (startedRef.current) return
56
+ startedRef.current = true
57
+ setStatus('loading')
58
+ try {
59
+ const fetchGeo = async (): Promise<TripMapGeoResponse> => {
60
+ try {
61
+ const res = await fetch(geoUrl, { headers: { accept: 'application/json' } })
62
+ if (!res.ok) throw new Error(`media/geo ${res.status}`)
63
+ return (await res.json()) as TripMapGeoResponse
64
+ } catch (err) {
65
+ // Config-only (hand-made nodes only): keep the nodes, run on the
66
+ // core default style. With a selection the nodes ARE the response,
67
+ // so the failure propagates and the static facade stays.
68
+ if (configOnly) return { nodes: [] }
69
+ throw err
70
+ }
71
+ }
72
+ const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeo()])
73
+ const mediaNodes = (geo.nodes ?? [])
74
+ .filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
75
+ .map((n) => ({
76
+ id: n.slug,
77
+ lat: n.latitude,
78
+ lng: n.longitude,
79
+ capturedAt: n.capturedAt ?? undefined,
80
+ thumbnailUrl: n.thumbnailUrl ?? undefined,
81
+ imageUrl: n.url ?? undefined,
82
+ largeImageUrl: n.largeUrl ?? undefined,
83
+ altText: n.altText ?? undefined,
84
+ title: n.placeName ?? undefined,
85
+ }))
86
+ const tripNodes = core.buildTripNodes(mediaNodes, handNodes)
87
+ if (tripNodes.length === 0 || !mapRef.current) {
88
+ setStatus('empty')
89
+ return
90
+ }
91
+ let initialView: { center?: [number, number]; zoom?: number } | undefined
92
+ if (center) {
93
+ const [lat, lng] = center.split(',').map((v) => Number.parseFloat(v.trim()))
94
+ if (Number.isFinite(lat) && Number.isFinite(lng)) initialView = { center: [lng, lat], zoom }
95
+ }
96
+ const map = new core.TripMap(mapRef.current, {
97
+ styleUrl: styleUrl || geo.config?.styleUrl || '',
98
+ darkStyleUrl: geo.config?.darkStyleUrl,
99
+ theme: theme ?? 'auto',
100
+ cluster: cluster ?? true,
101
+ showLine: line ?? true,
102
+ showFilmstrip: filmstrip ?? true,
103
+ showPlayback: playback ?? true,
104
+ title,
105
+ detail: detail ?? 'popup',
106
+ detailHost: detail === 'panel' ? (detailRef.current ?? undefined) : undefined,
107
+ extraAttribution: geo.config?.attribution,
108
+ labels,
109
+ initialView,
110
+ events: {},
111
+ })
112
+ map.setNodes(tripNodes)
113
+ await map.init()
114
+ destroyRef.current = () => map.destroy()
115
+ setStatus('live')
116
+ } catch {
117
+ // Core unavailable (review-app iframe, offline) or fetch failed —
118
+ // the static facade stays, which is the designed degradation.
119
+ setStatus((s) => (s === 'loading' ? 'facade' : s))
120
+ }
121
+ }, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom, title, detail])
122
+
123
+ useEffect(() => {
124
+ const el = rootRef.current
125
+ if (!el) return
126
+ if (typeof IntersectionObserver === 'undefined') {
127
+ void start()
128
+ return
129
+ }
130
+ const observer = new IntersectionObserver(
131
+ (entries) => {
132
+ if (entries.some((e) => e.isIntersecting)) {
133
+ observer.disconnect()
134
+ void start()
135
+ }
136
+ },
137
+ { rootMargin: '300px' },
138
+ )
139
+ observer.observe(el)
140
+ return () => observer.disconnect()
141
+ }, [start])
142
+
143
+ useEffect(
144
+ () => () => {
145
+ destroyRef.current?.()
146
+ destroyRef.current = null
147
+ },
148
+ [],
149
+ )
150
+
151
+ const showFacade = status !== 'live'
152
+
153
+ return (
154
+ <div ref={rootRef} className={tripMapStyles.container} data-mdx-component="TripMap" aria-label={title}>
155
+ {title ? <p className={tripMapStyles.head}>{title}</p> : null}
156
+
157
+ {/* Server-rendered strip with alt text — what crawlers read; replaced by the live filmstrip. */}
158
+ {showFacade && facade.thumbs.length > 0 ? (
159
+ <ol className={tripMapStyles.strip}>
160
+ {facade.thumbs.map((thumb) => (
161
+ <li key={thumb.slug} className={tripMapStyles.stripItem}>
162
+ <img
163
+ src={thumb.src}
164
+ alt={thumb.alt}
165
+ width={80}
166
+ height={80}
167
+ loading="lazy"
168
+ decoding="async"
169
+ className={tripMapStyles.stripImg}
170
+ />
171
+ </li>
172
+ ))}
173
+ </ol>
174
+ ) : null}
175
+
176
+ {/*
177
+ Sized wrapper: the engine host is a static absolutely-positioned child
178
+ that React never re-patches (reactive props on the engine's container
179
+ would clobber the classes/children the engine adds); the facade box is
180
+ an overlay that simply unmounts when the map goes live.
181
+ */}
182
+ <div className={tripMapStyles.mapWrap} style={{ height: height || TRIPMAP_DEFAULT_HEIGHT }}>
183
+ <div ref={mapRef} className="absolute inset-0" />
184
+ {showFacade ? (
185
+ <div className={tripMapStyles.mapOverlay}>
186
+ <div className={tripMapStyles.mapBox}>
187
+ <span className={tripMapStyles.mapHint}>{status === 'empty' ? texts.empty : texts.hint}</span>
188
+ </div>
189
+ </div>
190
+ ) : null}
191
+ </div>
192
+
193
+ {showFacade && facade.stops.length > 0 ? (
194
+ <ol className={tripMapStyles.stopList}>
195
+ {facade.stops.map((stop) => (
196
+ <li key={stop.key}>
197
+ {stop.capturedAt ? (
198
+ <span className={tripMapStyles.stopTime}>{tripMapFormatTime(stop.capturedAt)}</span>
199
+ ) : null}
200
+ <span>{stop.title}</span>
201
+ </li>
202
+ ))}
203
+ </ol>
204
+ ) : null}
205
+
206
+ {showFacade ? <p className={tripMapStyles.note}>{texts.unavailable}</p> : null}
207
+ </div>
208
+ )
209
+ }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Article identity for TripMap AUTO mode (map every geotagged media of the
5
+ * article). The host app wraps its rendered article body in the provider —
6
+ * the Next blog does in `app/articles/[slug]/page.tsx` AND the draft preview
7
+ * `app/preview/[token]/page.tsx`; a consumer that doesn't simply has no auto
8
+ * mode and TripMap needs explicit `slugs` or `nodes`.
9
+ */
10
+
11
+ import { createContext, createElement, type ReactNode } from 'react'
12
+ import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap'
13
+
14
+ export interface MdxArticleContextValue {
15
+ filename: string
16
+ language?: string
17
+ /**
18
+ * The article's geotagged media (`contentImagesGeo`), when the host has
19
+ * them: the SERVER-RENDERED facade then carries real thumbnails with alt
20
+ * text and named stops in AUTO mode — what crawlers read. Optional.
21
+ */
22
+ media?: readonly TripMapFacadeMedia[] | null
23
+ }
24
+
25
+ export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)
26
+
27
+ export function MdxArticleProvider({
28
+ value,
29
+ children,
30
+ }: {
31
+ value: MdxArticleContextValue | null
32
+ children: ReactNode
33
+ }) {
34
+ return createElement(MdxArticleContext.Provider, { value }, children)
35
+ }
@@ -41,6 +41,7 @@ import Alert from './render/alert/Alert'
41
41
  import Badge from './render/badge/Badge'
42
42
  import Icon from './render/icon/Icon'
43
43
  import Tooltip from './render/tooltip/Tooltip'
44
+ import TripMap from './render/tripmap/TripMap'
44
45
 
45
46
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
47
  export const serverRenderMap: Record<string, ComponentType<any>> = {
@@ -55,6 +56,7 @@ export const serverRenderMap: Record<string, ComponentType<any>> = {
55
56
  Badge,
56
57
  Icon,
57
58
  Tooltip,
59
+ TripMap,
58
60
  }
59
61
 
60
62
  /**