@blogmcp/mdx-render-react 0.4.0 → 0.6.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
|
|
3
|
-
import { useContext as
|
|
4
|
-
import { tripMapStyles as
|
|
5
|
-
import { splitTripMapList as
|
|
6
|
-
import { MdxArticleContext as
|
|
7
|
-
function
|
|
8
|
-
const { slugs:
|
|
9
|
-
if (!
|
|
10
|
-
|
|
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 (
|
|
18
|
+
if (E) return { nodes: [] };
|
|
19
19
|
throw e;
|
|
20
20
|
}
|
|
21
|
-
}, [
|
|
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
|
-
})),
|
|
31
|
-
if (
|
|
32
|
-
|
|
31
|
+
})), C = l.buildTripNodes(N, S);
|
|
32
|
+
if (C.length === 0 || !g.current) {
|
|
33
|
+
u("empty");
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
|
-
let
|
|
36
|
-
if (
|
|
37
|
-
const [e,
|
|
38
|
-
Number.isFinite(e) && Number.isFinite(
|
|
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
|
|
41
|
-
styleUrl:
|
|
41
|
+
const w = new l.TripMap(g.current, {
|
|
42
|
+
styleUrl: I || s.config?.styleUrl || "",
|
|
42
43
|
darkStyleUrl: s.config?.darkStyleUrl,
|
|
43
|
-
theme:
|
|
44
|
-
cluster:
|
|
44
|
+
theme: F ?? "auto",
|
|
45
|
+
cluster: A ?? !0,
|
|
45
46
|
showLine: U ?? !0,
|
|
46
|
-
showFilmstrip:
|
|
47
|
-
showPlayback:
|
|
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:
|
|
50
|
-
initialView:
|
|
53
|
+
labels: O,
|
|
54
|
+
initialView: G,
|
|
51
55
|
events: {}
|
|
52
56
|
});
|
|
53
|
-
w.setNodes(
|
|
57
|
+
w.setNodes(C), await w.init(), y.current = () => w.destroy(), u("live");
|
|
54
58
|
} catch {
|
|
55
|
-
|
|
59
|
+
u((t) => t === "loading" ? "facade" : t);
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
|
-
}, [j,
|
|
59
|
-
|
|
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
|
-
|
|
67
|
+
v();
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
|
-
const
|
|
70
|
+
const l = new IntersectionObserver(
|
|
67
71
|
(s) => {
|
|
68
|
-
s.some((
|
|
72
|
+
s.some((N) => N.isIntersecting) && (l.disconnect(), v());
|
|
69
73
|
},
|
|
70
74
|
{ rootMargin: "300px" }
|
|
71
75
|
);
|
|
72
|
-
return
|
|
73
|
-
}, [
|
|
76
|
+
return l.observe(t), () => l.disconnect();
|
|
77
|
+
}, [v]), _(
|
|
74
78
|
() => () => {
|
|
75
|
-
|
|
79
|
+
y.current?.(), y.current = null;
|
|
76
80
|
},
|
|
77
81
|
[]
|
|
78
82
|
);
|
|
79
|
-
const
|
|
80
|
-
return /* @__PURE__ */
|
|
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(
|
|
83
87
|
"img",
|
|
84
88
|
{
|
|
85
|
-
src:
|
|
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:
|
|
95
|
+
className: r.stripImg
|
|
92
96
|
}
|
|
93
|
-
) }, t)) }) : null,
|
|
94
|
-
/* @__PURE__ */
|
|
95
|
-
/* @__PURE__ */
|
|
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
|
|
97
101
|
] }),
|
|
98
|
-
|
|
99
|
-
t.capturedAt ? /* @__PURE__ */
|
|
100
|
-
/* @__PURE__ */
|
|
101
|
-
] }, t.
|
|
102
|
-
|
|
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
|
-
|
|
110
|
+
de as default
|
|
107
111
|
};
|
|
@@ -6,9 +6,23 @@
|
|
|
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
|
+
* Set ONLY by a draft preview: the draft-preview token of the page (`/preview/<token>`).
|
|
15
|
+
* AUTO mode then asks kb-api for the article's WORKING COPY photo set; without it a draft
|
|
16
|
+
* maps nothing (a reader's request for a draft is a 404) and an article with unpublished
|
|
17
|
+
* edits maps its published photos. Never set it on a published page.
|
|
18
|
+
*/
|
|
19
|
+
previewToken?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The article's geotagged media (`contentImagesGeo`), when the host has
|
|
22
|
+
* them: the SERVER-RENDERED facade then carries real thumbnails with alt
|
|
23
|
+
* text and named stops in AUTO mode — what crawlers read. Optional.
|
|
24
|
+
*/
|
|
25
|
+
media?: readonly TripMapFacadeMedia[] | null;
|
|
12
26
|
}
|
|
13
27
|
export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
|
|
14
28
|
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.
|
|
3
|
+
"version": "0.6.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.
|
|
40
|
-
"@blogmcp/tripmap-core": "^0.1.
|
|
41
|
-
"@blogmcp/sanitize": "^0.
|
|
42
|
-
"@blogmcp/sdk": "^0.
|
|
41
|
+
"@blogmcp/mdx-schema": "^0.6.0",
|
|
42
|
+
"@blogmcp/tripmap-core": "^0.1.2",
|
|
43
|
+
"@blogmcp/sanitize": "^0.6.0",
|
|
44
|
+
"@blogmcp/sdk": "^0.6.0",
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
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 &&
|
|
193
|
+
{showFacade && facade.stops.length > 0 ? (
|
|
185
194
|
<ol className={tripMapStyles.stopList}>
|
|
186
|
-
{
|
|
187
|
-
<li key={
|
|
188
|
-
{
|
|
189
|
-
<span className={tripMapStyles.stopTime}>{tripMapFormatTime(
|
|
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>{
|
|
200
|
+
<span>{stop.title}</span>
|
|
192
201
|
</li>
|
|
193
202
|
))}
|
|
194
203
|
</ol>
|
|
@@ -9,10 +9,24 @@
|
|
|
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
|
+
* Set ONLY by a draft preview: the draft-preview token of the page (`/preview/<token>`).
|
|
19
|
+
* AUTO mode then asks kb-api for the article's WORKING COPY photo set; without it a draft
|
|
20
|
+
* maps nothing (a reader's request for a draft is a 404) and an article with unpublished
|
|
21
|
+
* edits maps its published photos. Never set it on a published page.
|
|
22
|
+
*/
|
|
23
|
+
previewToken?: string
|
|
24
|
+
/**
|
|
25
|
+
* The article's geotagged media (`contentImagesGeo`), when the host has
|
|
26
|
+
* them: the SERVER-RENDERED facade then carries real thumbnails with alt
|
|
27
|
+
* text and named stops in AUTO mode — what crawlers read. Optional.
|
|
28
|
+
*/
|
|
29
|
+
media?: readonly TripMapFacadeMedia[] | null
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)
|