@blogmcp/mdx-render-react 0.3.0 → 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.
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,107 @@
1
+ "use client";
2
+ import { jsxs as T, jsx as r } from "react/jsx-runtime";
3
+ import { useContext as D, useRef as d, useState as V, useMemo as n, useCallback as W, useEffect as G } from "react";
4
+ import { tripMapStyles as a } from "@blogmcp/mdx-schema/styles";
5
+ import { splitTripMapList as q, parseTripMapHandNodes as J, buildTripMapGeoUrl as K, tripMapUiTexts as Q, tripMapPlaybackLabels as X, TRIPMAP_DEFAULT_HEIGHT as Y, tripMapFormatTime as Z } from "@blogmcp/mdx-schema/tripmap";
6
+ import { MdxArticleContext as ee } from "./article-context.js";
7
+ function se(p) {
8
+ const { slugs: x, nodes: M, height: z, line: U, filmstrip: A, playback: _, cluster: I, theme: k, styleUrl: F, center: m, zoom: R, title: h } = p, l = D(ee), L = d(null), f = d(null), g = d(null), P = d(!1), [S, o] = V("facade"), y = n(() => q(x), [x]), c = n(() => J(M), [M]), j = n(() => K(p, l), [p, l]), C = y.length === 0 && !l?.filename, b = n(() => Q(l?.language), [l]), E = n(() => X(l?.language), [l]), N = W(async () => {
9
+ if (!P.current) {
10
+ P.current = !0, o("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 (C) return { nodes: [] };
19
+ throw e;
20
+ }
21
+ }, [i, s] = await Promise.all([import("@blogmcp/tripmap-core"), t()]), v = (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
+ altText: e.altText ?? void 0,
29
+ title: e.placeName ?? void 0
30
+ })), H = i.buildTripNodes(v, c);
31
+ if (H.length === 0 || !f.current) {
32
+ o("empty");
33
+ return;
34
+ }
35
+ let O;
36
+ if (m) {
37
+ const [e, $] = m.split(",").map((B) => Number.parseFloat(B.trim()));
38
+ Number.isFinite(e) && Number.isFinite($) && (O = { center: [$, e], zoom: R });
39
+ }
40
+ const w = new i.TripMap(f.current, {
41
+ styleUrl: F || s.config?.styleUrl || "",
42
+ darkStyleUrl: s.config?.darkStyleUrl,
43
+ theme: k ?? "auto",
44
+ cluster: I ?? !0,
45
+ showLine: U ?? !0,
46
+ showFilmstrip: A ?? !0,
47
+ showPlayback: _ ?? !0,
48
+ extraAttribution: s.config?.attribution,
49
+ labels: E,
50
+ initialView: O,
51
+ events: {}
52
+ });
53
+ w.setNodes(H), await w.init(), g.current = () => w.destroy(), o("live");
54
+ } catch {
55
+ o((t) => t === "loading" ? "facade" : t);
56
+ }
57
+ }
58
+ }, [j, C, c, F, k, I, U, A, E, m, R]);
59
+ G(() => {
60
+ const t = L.current;
61
+ if (!t) return;
62
+ if (typeof IntersectionObserver > "u") {
63
+ N();
64
+ return;
65
+ }
66
+ const i = new IntersectionObserver(
67
+ (s) => {
68
+ s.some((v) => v.isIntersecting) && (i.disconnect(), N());
69
+ },
70
+ { rootMargin: "300px" }
71
+ );
72
+ return i.observe(t), () => i.disconnect();
73
+ }, [N]), G(
74
+ () => () => {
75
+ g.current?.(), g.current = null;
76
+ },
77
+ []
78
+ );
79
+ const u = S !== "live";
80
+ return /* @__PURE__ */ T("div", { ref: L, className: a.container, "data-mdx-component": "TripMap", "aria-label": h, children: [
81
+ h ? /* @__PURE__ */ r("p", { className: a.head, children: h }) : null,
82
+ u && y.length > 0 ? /* @__PURE__ */ r("ol", { className: a.strip, children: y.map((t) => /* @__PURE__ */ r("li", { className: a.stripItem, children: /* @__PURE__ */ r(
83
+ "img",
84
+ {
85
+ src: `/api/media/thumb/${encodeURIComponent(t)}?width=192`,
86
+ alt: "",
87
+ width: 80,
88
+ height: 80,
89
+ loading: "lazy",
90
+ decoding: "async",
91
+ className: a.stripImg
92
+ }
93
+ ) }, t)) }) : null,
94
+ /* @__PURE__ */ T("div", { className: a.mapWrap, style: { height: z || Y }, children: [
95
+ /* @__PURE__ */ r("div", { ref: f, className: "absolute inset-0" }),
96
+ u ? /* @__PURE__ */ r("div", { className: a.mapOverlay, children: /* @__PURE__ */ r("div", { className: a.mapBox, children: /* @__PURE__ */ r("span", { className: a.mapHint, children: S === "empty" ? b.empty : b.hint }) }) }) : null
97
+ ] }),
98
+ u && c.length > 0 ? /* @__PURE__ */ r("ol", { className: a.stopList, children: c.map((t, i) => /* @__PURE__ */ T("li", { children: [
99
+ t.capturedAt ? /* @__PURE__ */ r("span", { className: a.stopTime, children: Z(t.capturedAt) }) : null,
100
+ /* @__PURE__ */ r("span", { children: t.title || `${t.lat}, ${t.lng}` })
101
+ ] }, t.id || i)) }) : null,
102
+ u ? /* @__PURE__ */ r("p", { className: a.note, children: b.unavailable }) : null
103
+ ] });
104
+ }
105
+ export {
106
+ se as default
107
+ };
@@ -0,0 +1,17 @@
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
+ export interface MdxArticleContextValue {
10
+ filename: string;
11
+ language?: string;
12
+ }
13
+ export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
14
+ export declare function MdxArticleProvider({ value, children, }: {
15
+ value: MdxArticleContextValue | null;
16
+ children: ReactNode;
17
+ }): 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.4.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": {
@@ -36,7 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@ark-ui/react": "^5.0.0",
39
- "@blogmcp/mdx-schema": "^0.3.0",
39
+ "@blogmcp/mdx-schema": "^0.4.0",
40
+ "@blogmcp/tripmap-core": "^0.1.0",
40
41
  "@blogmcp/sanitize": "^0.3.0",
41
42
  "@blogmcp/sdk": "^0.3.0",
42
43
  "@iconify/react": "^6.0.0",
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,200 @@
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
+ } from '@blogmcp/mdx-schema/tripmap'
27
+
28
+ import { MdxArticleContext } from './article-context'
29
+
30
+ type Status = 'facade' | 'loading' | 'live' | 'empty'
31
+
32
+ export default function TripMap(props: TripMapProps) {
33
+ const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title } = props
34
+ const article = useContext(MdxArticleContext)
35
+
36
+ const rootRef = useRef<HTMLDivElement | null>(null)
37
+ const mapRef = useRef<HTMLDivElement | null>(null)
38
+ const destroyRef = useRef<(() => void) | null>(null)
39
+ const startedRef = useRef(false)
40
+ const [status, setStatus] = useState<Status>('facade')
41
+
42
+ const slugList = useMemo(() => splitTripMapList(slugs), [slugs])
43
+ const handNodes = useMemo(() => parseTripMapHandNodes(nodes), [nodes])
44
+ const geoUrl = useMemo(() => buildTripMapGeoUrl(props, article), [props, article])
45
+ // Nothing selects media: the geo call only carries the tenant basemap config.
46
+ const configOnly = slugList.length === 0 && !article?.filename
47
+ const texts = useMemo(() => tripMapUiTexts(article?.language), [article])
48
+ const labels = useMemo(() => tripMapPlaybackLabels(article?.language), [article])
49
+
50
+ const start = useCallback(async () => {
51
+ if (startedRef.current) return
52
+ startedRef.current = true
53
+ setStatus('loading')
54
+ try {
55
+ const fetchGeo = async (): Promise<TripMapGeoResponse> => {
56
+ try {
57
+ const res = await fetch(geoUrl, { headers: { accept: 'application/json' } })
58
+ if (!res.ok) throw new Error(`media/geo ${res.status}`)
59
+ return (await res.json()) as TripMapGeoResponse
60
+ } catch (err) {
61
+ // Config-only (hand-made nodes only): keep the nodes, run on the
62
+ // core default style. With a selection the nodes ARE the response,
63
+ // so the failure propagates and the static facade stays.
64
+ if (configOnly) return { nodes: [] }
65
+ throw err
66
+ }
67
+ }
68
+ const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeo()])
69
+ const mediaNodes = (geo.nodes ?? [])
70
+ .filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
71
+ .map((n) => ({
72
+ id: n.slug,
73
+ lat: n.latitude,
74
+ lng: n.longitude,
75
+ capturedAt: n.capturedAt ?? undefined,
76
+ thumbnailUrl: n.thumbnailUrl ?? undefined,
77
+ imageUrl: n.url ?? undefined,
78
+ altText: n.altText ?? undefined,
79
+ title: n.placeName ?? undefined,
80
+ }))
81
+ const tripNodes = core.buildTripNodes(mediaNodes, handNodes)
82
+ if (tripNodes.length === 0 || !mapRef.current) {
83
+ setStatus('empty')
84
+ return
85
+ }
86
+ let initialView: { center?: [number, number]; zoom?: number } | undefined
87
+ if (center) {
88
+ const [lat, lng] = center.split(',').map((v) => Number.parseFloat(v.trim()))
89
+ if (Number.isFinite(lat) && Number.isFinite(lng)) initialView = { center: [lng, lat], zoom }
90
+ }
91
+ const map = new core.TripMap(mapRef.current, {
92
+ styleUrl: styleUrl || geo.config?.styleUrl || '',
93
+ darkStyleUrl: geo.config?.darkStyleUrl,
94
+ theme: theme ?? 'auto',
95
+ cluster: cluster ?? true,
96
+ showLine: line ?? true,
97
+ showFilmstrip: filmstrip ?? true,
98
+ showPlayback: playback ?? true,
99
+ extraAttribution: geo.config?.attribution,
100
+ labels,
101
+ initialView,
102
+ events: {},
103
+ })
104
+ map.setNodes(tripNodes)
105
+ await map.init()
106
+ destroyRef.current = () => map.destroy()
107
+ setStatus('live')
108
+ } catch {
109
+ // Core unavailable (review-app iframe, offline) or fetch failed —
110
+ // the static facade stays, which is the designed degradation.
111
+ setStatus((s) => (s === 'loading' ? 'facade' : s))
112
+ }
113
+ }, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom])
114
+
115
+ useEffect(() => {
116
+ const el = rootRef.current
117
+ if (!el) return
118
+ if (typeof IntersectionObserver === 'undefined') {
119
+ void start()
120
+ return
121
+ }
122
+ const observer = new IntersectionObserver(
123
+ (entries) => {
124
+ if (entries.some((e) => e.isIntersecting)) {
125
+ observer.disconnect()
126
+ void start()
127
+ }
128
+ },
129
+ { rootMargin: '300px' },
130
+ )
131
+ observer.observe(el)
132
+ return () => observer.disconnect()
133
+ }, [start])
134
+
135
+ useEffect(
136
+ () => () => {
137
+ destroyRef.current?.()
138
+ destroyRef.current = null
139
+ },
140
+ [],
141
+ )
142
+
143
+ const showFacade = status !== 'live'
144
+
145
+ return (
146
+ <div ref={rootRef} className={tripMapStyles.container} data-mdx-component="TripMap" aria-label={title}>
147
+ {title ? <p className={tripMapStyles.head}>{title}</p> : null}
148
+
149
+ {showFacade && slugList.length > 0 ? (
150
+ <ol className={tripMapStyles.strip}>
151
+ {slugList.map((slug) => (
152
+ <li key={slug} className={tripMapStyles.stripItem}>
153
+ <img
154
+ src={`/api/media/thumb/${encodeURIComponent(slug)}?width=192`}
155
+ alt=""
156
+ width={80}
157
+ height={80}
158
+ loading="lazy"
159
+ decoding="async"
160
+ className={tripMapStyles.stripImg}
161
+ />
162
+ </li>
163
+ ))}
164
+ </ol>
165
+ ) : null}
166
+
167
+ {/*
168
+ Sized wrapper: the engine host is a static absolutely-positioned child
169
+ that React never re-patches (reactive props on the engine's container
170
+ would clobber the classes/children the engine adds); the facade box is
171
+ an overlay that simply unmounts when the map goes live.
172
+ */}
173
+ <div className={tripMapStyles.mapWrap} style={{ height: height || TRIPMAP_DEFAULT_HEIGHT }}>
174
+ <div ref={mapRef} className="absolute inset-0" />
175
+ {showFacade ? (
176
+ <div className={tripMapStyles.mapOverlay}>
177
+ <div className={tripMapStyles.mapBox}>
178
+ <span className={tripMapStyles.mapHint}>{status === 'empty' ? texts.empty : texts.hint}</span>
179
+ </div>
180
+ </div>
181
+ ) : null}
182
+ </div>
183
+
184
+ {showFacade && handNodes.length > 0 ? (
185
+ <ol className={tripMapStyles.stopList}>
186
+ {handNodes.map((node, i) => (
187
+ <li key={node.id || i}>
188
+ {node.capturedAt ? (
189
+ <span className={tripMapStyles.stopTime}>{tripMapFormatTime(node.capturedAt)}</span>
190
+ ) : null}
191
+ <span>{node.title || `${node.lat}, ${node.lng}`}</span>
192
+ </li>
193
+ ))}
194
+ </ol>
195
+ ) : null}
196
+
197
+ {showFacade ? <p className={tripMapStyles.note}>{texts.unavailable}</p> : null}
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,28 @@
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
+
13
+ export interface MdxArticleContextValue {
14
+ filename: string
15
+ language?: string
16
+ }
17
+
18
+ export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)
19
+
20
+ export function MdxArticleProvider({
21
+ value,
22
+ children,
23
+ }: {
24
+ value: MdxArticleContextValue | null
25
+ children: ReactNode
26
+ }) {
27
+ return createElement(MdxArticleContext.Provider, { value }, children)
28
+ }
@@ -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
  /**