@blogmcp/mdx-render-react 0.4.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.
@@ -1,13 +1,13 @@
1
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");
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
11
  try {
12
12
  const t = async () => {
13
13
  try {
@@ -15,93 +15,97 @@ function se(p) {
15
15
  if (!e.ok) throw new Error(`media/geo ${e.status}`);
16
16
  return await e.json();
17
17
  } catch (e) {
18
- if (C) return { nodes: [] };
18
+ if (E) return { nodes: [] };
19
19
  throw e;
20
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) => ({
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
22
  id: e.slug,
23
23
  lat: e.latitude,
24
24
  lng: e.longitude,
25
25
  capturedAt: e.capturedAt ?? void 0,
26
26
  thumbnailUrl: e.thumbnailUrl ?? void 0,
27
27
  imageUrl: e.url ?? void 0,
28
+ largeImageUrl: e.largeUrl ?? void 0,
28
29
  altText: e.altText ?? void 0,
29
30
  title: e.placeName ?? void 0
30
- })), H = i.buildTripNodes(v, c);
31
- if (H.length === 0 || !f.current) {
32
- o("empty");
31
+ })), C = l.buildTripNodes(N, S);
32
+ if (C.length === 0 || !g.current) {
33
+ u("empty");
33
34
  return;
34
35
  }
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 });
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 });
39
40
  }
40
- const w = new i.TripMap(f.current, {
41
- styleUrl: F || s.config?.styleUrl || "",
41
+ const w = new l.TripMap(g.current, {
42
+ styleUrl: I || s.config?.styleUrl || "",
42
43
  darkStyleUrl: s.config?.darkStyleUrl,
43
- theme: k ?? "auto",
44
- cluster: I ?? !0,
44
+ theme: F ?? "auto",
45
+ cluster: A ?? !0,
45
46
  showLine: U ?? !0,
46
- showFilmstrip: A ?? !0,
47
- showPlayback: _ ?? !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,
48
52
  extraAttribution: s.config?.attribution,
49
- labels: E,
50
- initialView: O,
53
+ labels: O,
54
+ initialView: G,
51
55
  events: {}
52
56
  });
53
- w.setNodes(H), await w.init(), g.current = () => w.destroy(), o("live");
57
+ w.setNodes(C), await w.init(), y.current = () => w.destroy(), u("live");
54
58
  } catch {
55
- o((t) => t === "loading" ? "facade" : t);
59
+ u((t) => t === "loading" ? "facade" : t);
56
60
  }
57
61
  }
58
- }, [j, C, c, F, k, I, U, A, E, m, R]);
59
- G(() => {
62
+ }, [j, E, S, I, F, A, U, k, O, f, R, c, h]);
63
+ _(() => {
60
64
  const t = L.current;
61
65
  if (!t) return;
62
66
  if (typeof IntersectionObserver > "u") {
63
- N();
67
+ v();
64
68
  return;
65
69
  }
66
- const i = new IntersectionObserver(
70
+ const l = new IntersectionObserver(
67
71
  (s) => {
68
- s.some((v) => v.isIntersecting) && (i.disconnect(), N());
72
+ s.some((N) => N.isIntersecting) && (l.disconnect(), v());
69
73
  },
70
74
  { rootMargin: "300px" }
71
75
  );
72
- return i.observe(t), () => i.disconnect();
73
- }, [N]), G(
76
+ return l.observe(t), () => l.disconnect();
77
+ }, [v]), _(
74
78
  () => () => {
75
- g.current?.(), g.current = null;
79
+ y.current?.(), y.current = null;
76
80
  },
77
81
  []
78
82
  );
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
+ 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(
83
87
  "img",
84
88
  {
85
- src: `/api/media/thumb/${encodeURIComponent(t)}?width=192`,
86
- alt: "",
89
+ src: t.src,
90
+ alt: t.alt,
87
91
  width: 80,
88
92
  height: 80,
89
93
  loading: "lazy",
90
94
  decoding: "async",
91
- className: a.stripImg
95
+ className: r.stripImg
92
96
  }
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
+ ) }, 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
97
101
  ] }),
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
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
103
107
  ] });
104
108
  }
105
109
  export {
106
- se as default
110
+ de as default
107
111
  };
@@ -6,9 +6,16 @@
6
6
  * mode and TripMap needs explicit `slugs` or `nodes`.
7
7
  */
8
8
  import { type ReactNode } from 'react';
9
+ import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap';
9
10
  export interface MdxArticleContextValue {
10
11
  filename: string;
11
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;
12
19
  }
13
20
  export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
14
21
  export declare function MdxArticleProvider({ value, children, }: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogmcp/mdx-render-react",
3
- "version": "0.4.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,14 +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.4.0",
40
- "@blogmcp/tripmap-core": "^0.1.0",
41
+ "@blogmcp/mdx-schema": "^0.5.0",
42
+ "@blogmcp/tripmap-core": "^0.1.2",
41
43
  "@blogmcp/sanitize": "^0.3.0",
42
- "@blogmcp/sdk": "^0.3.0",
44
+ "@blogmcp/sdk": "^0.3.2",
43
45
  "@iconify/react": "^6.0.0",
44
46
  "marked": "^15.0.7"
45
47
  },
@@ -23,6 +23,7 @@ import {
23
23
  tripMapPlaybackLabels,
24
24
  tripMapUiTexts,
25
25
  type TripMapGeoResponse,
26
+ tripMapFacade,
26
27
  } from '@blogmcp/mdx-schema/tripmap'
27
28
 
28
29
  import { MdxArticleContext } from './article-context'
@@ -30,11 +31,13 @@ import { MdxArticleContext } from './article-context'
30
31
  type Status = 'facade' | 'loading' | 'live' | 'empty'
31
32
 
32
33
  export default function TripMap(props: TripMapProps) {
33
- const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title } = props
34
+ const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title, detail } = props
34
35
  const article = useContext(MdxArticleContext)
35
36
 
36
37
  const rootRef = useRef<HTMLDivElement | null>(null)
37
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)
38
41
  const destroyRef = useRef<(() => void) | null>(null)
39
42
  const startedRef = useRef(false)
40
43
  const [status, setStatus] = useState<Status>('facade')
@@ -46,6 +49,7 @@ export default function TripMap(props: TripMapProps) {
46
49
  const configOnly = slugList.length === 0 && !article?.filename
47
50
  const texts = useMemo(() => tripMapUiTexts(article?.language), [article])
48
51
  const labels = useMemo(() => tripMapPlaybackLabels(article?.language), [article])
52
+ const facade = useMemo(() => tripMapFacade(props, article), [props, article])
49
53
 
50
54
  const start = useCallback(async () => {
51
55
  if (startedRef.current) return
@@ -75,6 +79,7 @@ export default function TripMap(props: TripMapProps) {
75
79
  capturedAt: n.capturedAt ?? undefined,
76
80
  thumbnailUrl: n.thumbnailUrl ?? undefined,
77
81
  imageUrl: n.url ?? undefined,
82
+ largeImageUrl: n.largeUrl ?? undefined,
78
83
  altText: n.altText ?? undefined,
79
84
  title: n.placeName ?? undefined,
80
85
  }))
@@ -96,6 +101,9 @@ export default function TripMap(props: TripMapProps) {
96
101
  showLine: line ?? true,
97
102
  showFilmstrip: filmstrip ?? true,
98
103
  showPlayback: playback ?? true,
104
+ title,
105
+ detail: detail ?? 'popup',
106
+ detailHost: detail === 'panel' ? (detailRef.current ?? undefined) : undefined,
99
107
  extraAttribution: geo.config?.attribution,
100
108
  labels,
101
109
  initialView,
@@ -110,7 +118,7 @@ export default function TripMap(props: TripMapProps) {
110
118
  // the static facade stays, which is the designed degradation.
111
119
  setStatus((s) => (s === 'loading' ? 'facade' : s))
112
120
  }
113
- }, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom])
121
+ }, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom, title, detail])
114
122
 
115
123
  useEffect(() => {
116
124
  const el = rootRef.current
@@ -146,13 +154,14 @@ export default function TripMap(props: TripMapProps) {
146
154
  <div ref={rootRef} className={tripMapStyles.container} data-mdx-component="TripMap" aria-label={title}>
147
155
  {title ? <p className={tripMapStyles.head}>{title}</p> : null}
148
156
 
149
- {showFacade && slugList.length > 0 ? (
157
+ {/* Server-rendered strip with alt text — what crawlers read; replaced by the live filmstrip. */}
158
+ {showFacade && facade.thumbs.length > 0 ? (
150
159
  <ol className={tripMapStyles.strip}>
151
- {slugList.map((slug) => (
152
- <li key={slug} className={tripMapStyles.stripItem}>
160
+ {facade.thumbs.map((thumb) => (
161
+ <li key={thumb.slug} className={tripMapStyles.stripItem}>
153
162
  <img
154
- src={`/api/media/thumb/${encodeURIComponent(slug)}?width=192`}
155
- alt=""
163
+ src={thumb.src}
164
+ alt={thumb.alt}
156
165
  width={80}
157
166
  height={80}
158
167
  loading="lazy"
@@ -181,14 +190,14 @@ export default function TripMap(props: TripMapProps) {
181
190
  ) : null}
182
191
  </div>
183
192
 
184
- {showFacade && handNodes.length > 0 ? (
193
+ {showFacade && facade.stops.length > 0 ? (
185
194
  <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>
195
+ {facade.stops.map((stop) => (
196
+ <li key={stop.key}>
197
+ {stop.capturedAt ? (
198
+ <span className={tripMapStyles.stopTime}>{tripMapFormatTime(stop.capturedAt)}</span>
190
199
  ) : null}
191
- <span>{node.title || `${node.lat}, ${node.lng}`}</span>
200
+ <span>{stop.title}</span>
192
201
  </li>
193
202
  ))}
194
203
  </ol>
@@ -9,10 +9,17 @@
9
9
  */
10
10
 
11
11
  import { createContext, createElement, type ReactNode } from 'react'
12
+ import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap'
12
13
 
13
14
  export interface MdxArticleContextValue {
14
15
  filename: string
15
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
16
23
  }
17
24
 
18
25
  export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)