@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.30

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.
Files changed (127) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +26 -70
  4. package/amplify/data/resource.ts +27 -70
  5. package/amplify/data/schema.js +26 -58
  6. package/amplify/data/schema.ts +27 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +45 -3
  12. package/amplify/rebuild-trigger-settings.js +22 -0
  13. package/amplify/rebuild-trigger-settings.ts +42 -0
  14. package/amplify/site-backend-config.ts +12 -1
  15. package/amplify/site-backend.js +41 -27
  16. package/amplify/site-backend.ts +51 -34
  17. package/amplify/storage/resource.js +3 -0
  18. package/amplify/storage/resource.ts +3 -0
  19. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  20. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  21. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  22. package/app/api/media/[...path]/route.ts +5 -16
  23. package/app/api/revalidate/route.ts +8 -7
  24. package/app/articles/[slug]/page.tsx +6 -4
  25. package/app/blog-rhythm.css +278 -0
  26. package/app/globals.css +21 -0
  27. package/app/layout.tsx +20 -0
  28. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  29. package/app/newsroom/[section]/page.tsx +4 -3
  30. package/app/newsroom/articles/[id]/page.tsx +15 -0
  31. package/app/newsroom/articles/page.tsx +13 -0
  32. package/app/newsroom/layout.tsx +2 -9
  33. package/app/page.tsx +18 -5
  34. package/app/robots.ts +11 -0
  35. package/bin/papyrus-app.mjs +12 -2
  36. package/bin/papyrus-infra.mjs +74 -18
  37. package/components/archive-shell.tsx +2 -22
  38. package/components/article-video.tsx +247 -3
  39. package/components/blog-page-background.tsx +23 -6
  40. package/components/markus-ir-preview.tsx +300 -0
  41. package/components/newspaper.tsx +6 -2
  42. package/components/newsroom-access-gate.tsx +67 -0
  43. package/components/newsroom-article-editor.tsx +741 -0
  44. package/components/newsroom-articles-view.tsx +129 -0
  45. package/components/newsroom-client-shell.tsx +12 -0
  46. package/components/newsroom-ops-overview.tsx +28 -11
  47. package/components/newsroom-ops-shell.tsx +3 -1
  48. package/components/papyrus-console-shell.tsx +4 -2
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +6 -63
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +28 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +95 -0
  61. package/infra/build-specs.ts +142 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/amplify-outputs-path.ts +5 -1
  68. package/lib/blog-feature-solver.ts +459 -0
  69. package/lib/blog-rhythm.ts +134 -0
  70. package/lib/brand-fonts-watch.ts +43 -0
  71. package/lib/brand-fonts.ts +112 -0
  72. package/lib/cached-content-repository.ts +14 -1
  73. package/lib/category-repository.ts +1 -93
  74. package/lib/content-actions-demo.ts +190 -0
  75. package/lib/content-repository.ts +6 -0
  76. package/lib/content-source-context.ts +26 -0
  77. package/lib/content-types.ts +4 -0
  78. package/lib/edition-routes.ts +31 -29
  79. package/lib/graphql-content-repository.ts +239 -43
  80. package/lib/media-route-response.ts +45 -0
  81. package/lib/newspaper-layout.ts +3 -2
  82. package/lib/newsroom-articles.ts +325 -0
  83. package/lib/newsroom-base-path.ts +12 -3
  84. package/lib/newsroom-category-drilldown.ts +3 -2
  85. package/lib/newsroom-demo-articles.ts +90 -0
  86. package/lib/newsroom-forum-routes.ts +6 -4
  87. package/lib/newsroom-index-filters.ts +14 -10
  88. package/lib/newsroom-media-backend.ts +142 -0
  89. package/lib/newsroom-media.ts +182 -0
  90. package/lib/newsroom-nav.ts +3 -0
  91. package/lib/papyrus-web-locations.ts +7 -6
  92. package/lib/presentation-footer.ts +3 -2
  93. package/lib/pretext-layout.ts +12 -3
  94. package/lib/reader-base-path.ts +24 -0
  95. package/lib/reader-static-params.ts +8 -0
  96. package/lib/renderer.ts +7 -0
  97. package/lib/revalidate-secret.ts +59 -0
  98. package/lib/root-route-redirect.ts +16 -0
  99. package/lib/semantic-graph.ts +5 -0
  100. package/lib/site-brand.ts +51 -0
  101. package/lib/site-env.ts +52 -0
  102. package/lib/staging-gate.ts +37 -0
  103. package/lib/staging-gated-path.ts +23 -0
  104. package/lib/staging-preview-object.ts +114 -0
  105. package/lib/video-mode.ts +42 -0
  106. package/lib/video-script.ts +77 -0
  107. package/middleware.ts +52 -10
  108. package/package.json +41 -1
  109. package/renderers/pretext/article-page.tsx +39 -3
  110. package/renderers/pretext/presentation-shared.tsx +113 -0
  111. package/renderers/pretext/presentation-shell.tsx +15 -102
  112. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  113. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  114. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  115. package/routes.manifest.json +37 -2
  116. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  117. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  118. package/scripts/videoml/bundle-common.mjs +129 -0
  119. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  120. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  121. package/scripts/videoml/preview/preview-timing.ts +42 -0
  122. package/scripts/videoml/preview.html +77 -0
  123. package/scripts/videoml/standard-entry.tsx +46 -0
  124. package/src/sync.mjs +30 -6
  125. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  126. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  127. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,27 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { useEffect, useState } from "react";
4
+ import { useRhythmOverlay } from "./use-rhythm-overlay";
5
5
 
6
6
  type ArchiveShellProps = {
7
7
  children: ReactNode;
8
8
  };
9
9
 
10
10
  export function ArchiveShell({ children }: ArchiveShellProps) {
11
- const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
12
-
13
- useEffect(() => {
14
- const handleKeyDown = (event: KeyboardEvent) => {
15
- if (isEditableEventTarget(event.target)) return;
16
- if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
17
- event.preventDefault();
18
- setShowRhythmOverlay((current) => !current);
19
- }
20
- };
21
-
22
- window.addEventListener("keydown", handleKeyDown);
23
- return () => window.removeEventListener("keydown", handleKeyDown);
24
- }, []);
11
+ const showRhythmOverlay = useRhythmOverlay();
25
12
 
26
13
  return (
27
14
  <main
@@ -32,10 +19,3 @@ export function ArchiveShell({ children }: ArchiveShellProps) {
32
19
  </main>
33
20
  );
34
21
  }
35
-
36
- function isEditableEventTarget(target: EventTarget | null): boolean {
37
- if (!(target instanceof HTMLElement)) return false;
38
- const tagName = target.tagName.toLowerCase();
39
- if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
40
- return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
41
- }
@@ -1,31 +1,232 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { Pause, Play } from "lucide-react";
4
5
  import type { ArticleVideoAsset } from "../lib/articles";
5
6
  import { resolveThemedVideoSrc } from "../lib/themed-image";
6
7
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
8
+ import { getSiteBrand } from "../lib/site-brand";
9
+ import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
10
+ import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
7
11
 
8
12
  type ArticleVideoFigureProps = {
9
13
  video: ArticleVideoAsset;
10
14
  slug: string;
11
15
  figureClassName?: string;
12
16
  priority?: boolean;
17
+ videoScript?: VideoScriptRef | null;
13
18
  };
14
19
 
15
- export function ArticleVideoFigure({ video, slug, figureClassName = "article-photo article-video" }: ArticleVideoFigureProps) {
20
+ const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
21
+
22
+ export function ArticleVideoFigure({
23
+ video,
24
+ slug,
25
+ figureClassName = "article-photo article-video",
26
+ videoScript = null,
27
+ }: ArticleVideoFigureProps) {
16
28
  const theme = useResolvedPapyrusTheme();
17
29
  const [hasHydrated, setHasHydrated] = useState(false);
30
+ const [videoMode, setVideoMode] = useState<VideoMode>(() => resolveVideoModeFromEnv());
31
+ const [isPlaying, setIsPlaying] = useState(false);
32
+ const [currentTime, setCurrentTime] = useState(0);
33
+ const [duration, setDuration] = useState(0);
34
+ const [previewReady, setPreviewReady] = useState(false);
35
+ const iframeRef = useRef<HTMLIFrameElement>(null);
36
+ const videoRef = useRef<HTMLVideoElement>(null);
37
+ const isFramed = getSiteBrand().videoPlayer === "framed";
18
38
 
19
39
  useEffect(() => {
20
40
  setHasHydrated(true);
41
+ setVideoMode(resolveVideoMode());
21
42
  }, []);
22
43
 
23
44
  // Match SSR to the default dark MP4 in `video.src` until after hydration.
24
45
  const resolvedTheme = hasHydrated ? theme : "dark";
25
46
  const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
47
+ const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
48
+ const previewDsl = videoScript?.dsl
49
+ ? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
50
+ : null;
51
+ const previewSrc = `/videoml/preview.html?target=${encodeURIComponent(slug)}&theme=${resolvedTheme}${
52
+ src ? `&audio=${encodeURIComponent(src)}` : ""
53
+ }`;
54
+
55
+ if (usePreview && previewDsl && typeof window !== "undefined") {
56
+ sessionStorage.setItem(`${PREVIEW_STORAGE_PREFIX}${slug}`, previewDsl);
57
+ }
58
+
59
+ useEffect(() => {
60
+ setPreviewReady(false);
61
+ setIsPlaying(false);
62
+ setCurrentTime(0);
63
+ setDuration(0);
64
+ }, [previewSrc, usePreview]);
65
+
66
+ useEffect(() => {
67
+ if (!usePreview || !previewReady) return;
68
+ iframeRef.current?.contentWindow?.postMessage(
69
+ { kind: "papyrus-video-refresh", target: slug, theme: resolvedTheme, audio: src || undefined },
70
+ "*",
71
+ );
72
+ }, [previewReady, resolvedTheme, slug, src, usePreview]);
73
+
74
+ useEffect(() => {
75
+ if (!usePreview) return;
76
+ const onMessage = (event: MessageEvent) => {
77
+ const payload = event.data;
78
+ if (!payload || typeof payload !== "object" || payload.kind !== "papyrus-video-time") return;
79
+ if (typeof payload.currentTime === "number") setCurrentTime(payload.currentTime);
80
+ if (typeof payload.duration === "number") setDuration(payload.duration);
81
+ };
82
+ window.addEventListener("message", onMessage);
83
+ return () => window.removeEventListener("message", onMessage);
84
+ }, [usePreview]);
85
+
86
+ const togglePreviewPlayback = useCallback(() => {
87
+ iframeRef.current?.contentWindow?.postMessage({ kind: isPlaying ? "papyrus-video-pause" : "papyrus-video-play" }, "*");
88
+ setIsPlaying(!isPlaying);
89
+ }, [isPlaying]);
90
+
91
+ const seekPreview = useCallback((nextTime: number) => {
92
+ const clamped = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
93
+ iframeRef.current?.contentWindow?.postMessage({ kind: "papyrus-video-seek", time: clamped }, "*");
94
+ setCurrentTime(clamped);
95
+ }, []);
96
+
97
+ const toggleNativePlayback = useCallback(() => {
98
+ const element = videoRef.current;
99
+ if (!element) return;
100
+ if (element.paused) void element.play().catch(() => setIsPlaying(false));
101
+ else element.pause();
102
+ }, []);
103
+
104
+ const seekNative = useCallback((nextTime: number) => {
105
+ const element = videoRef.current;
106
+ if (!element) return;
107
+ element.currentTime = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
108
+ setCurrentTime(element.currentTime);
109
+ }, []);
110
+
111
+ const framedControls = (
112
+ <FramedVideoControls
113
+ currentTime={currentTime}
114
+ duration={duration}
115
+ isPlaying={isPlaying}
116
+ onSeek={usePreview ? seekPreview : seekNative}
117
+ onToggle={usePreview ? togglePreviewPlayback : toggleNativePlayback}
118
+ />
119
+ );
120
+
121
+ if (isFramed && usePreview) {
122
+ return (
123
+ <figure
124
+ className={`${figureClassName} article-video--framed article-video--preview`}
125
+ data-media-type="videoml-preview"
126
+ data-video-theme={resolvedTheme}
127
+ data-video-mode={videoMode}
128
+ >
129
+ <div className="article-video__media">
130
+ {hasHydrated ? (
131
+ <iframe
132
+ key={`${slug}-${resolvedTheme}-${src}`}
133
+ ref={iframeRef}
134
+ className="article-video__preview-frame"
135
+ src={previewSrc}
136
+ title={video.alt}
137
+ onLoad={() => setPreviewReady(true)}
138
+ />
139
+ ) : null}
140
+ </div>
141
+ {framedControls}
142
+ <span className="sr-only" data-video-slug={slug}>
143
+ {video.alt}
144
+ </span>
145
+ </figure>
146
+ );
147
+ }
148
+
149
+ if (usePreview) {
150
+ return (
151
+ <figure
152
+ className={`${figureClassName} article-video--preview`}
153
+ data-media-type="videoml-preview"
154
+ data-video-theme={resolvedTheme}
155
+ data-video-mode={videoMode}
156
+ >
157
+ {hasHydrated ? (
158
+ <iframe
159
+ key={`${slug}-${resolvedTheme}-${src}`}
160
+ ref={iframeRef}
161
+ className="article-video__preview-frame"
162
+ src={previewSrc}
163
+ title={video.alt}
164
+ onLoad={() => setPreviewReady(true)}
165
+ />
166
+ ) : null}
167
+ <div className="article-video__preview-controls">
168
+ <button type="button" onClick={togglePreviewPlayback} aria-pressed={isPlaying}>
169
+ {isPlaying ? "Pause video" : "Play video"}
170
+ </button>
171
+ <input
172
+ type="range"
173
+ min={0}
174
+ max={duration || 0}
175
+ step={0.1}
176
+ value={Math.min(currentTime, duration || 0)}
177
+ onChange={(event) => seekPreview(Number(event.target.value))}
178
+ aria-label="Seek video"
179
+ disabled={!duration}
180
+ />
181
+ </div>
182
+ {video.caption ? <figcaption>{video.caption}</figcaption> : null}
183
+ {video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
184
+ <span className="sr-only" data-video-slug={slug}>
185
+ {video.alt}
186
+ </span>
187
+ </figure>
188
+ );
189
+ }
190
+
191
+ if (isFramed) {
192
+ return (
193
+ <figure
194
+ className={`${figureClassName} article-video--framed`}
195
+ data-media-type="video"
196
+ data-video-theme={resolvedTheme}
197
+ data-video-mode={videoMode}
198
+ >
199
+ <div className="article-video__media">
200
+ <video
201
+ ref={videoRef}
202
+ playsInline
203
+ preload="metadata"
204
+ poster={video.posterSrc}
205
+ aria-label={video.alt}
206
+ className="article-video__player"
207
+ key={hasHydrated ? src : "ssr"}
208
+ onPlay={() => setIsPlaying(true)}
209
+ onPause={() => setIsPlaying(false)}
210
+ onEnded={() => setIsPlaying(false)}
211
+ onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
212
+ onDurationChange={(event) => {
213
+ const nextDuration = event.currentTarget.duration;
214
+ if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
215
+ }}
216
+ >
217
+ <source src={src} type="video/mp4" />
218
+ </video>
219
+ </div>
220
+ {framedControls}
221
+ <span className="sr-only" data-video-slug={slug}>
222
+ {video.alt}
223
+ </span>
224
+ </figure>
225
+ );
226
+ }
26
227
 
27
228
  return (
28
- <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme}>
229
+ <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
29
230
  <video
30
231
  controls
31
232
  playsInline
@@ -45,3 +246,46 @@ export function ArticleVideoFigure({ video, slug, figureClassName = "article-pho
45
246
  </figure>
46
247
  );
47
248
  }
249
+
250
+ function FramedVideoControls({
251
+ currentTime,
252
+ duration,
253
+ isPlaying,
254
+ onSeek,
255
+ onToggle,
256
+ }: {
257
+ currentTime: number;
258
+ duration: number;
259
+ isPlaying: boolean;
260
+ onSeek: (nextTime: number) => void;
261
+ onToggle: () => void;
262
+ }) {
263
+ const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
264
+ return (
265
+ <div className="article-video__cta" data-playing={isPlaying ? "true" : "false"}>
266
+ <button type="button" className="article-video__cta-toggle" onClick={onToggle} aria-pressed={isPlaying}>
267
+ <span className="article-video__cta-label">
268
+ <span className="article-video__cta-label-sizer" aria-hidden="true">
269
+ Pause Video
270
+ </span>
271
+ <span className="article-video__cta-label-text">{isPlaying ? "Pause Video" : "Play Video"}</span>
272
+ </span>
273
+ <span className="article-video__cta-icon" aria-hidden="true">
274
+ {isPlaying ? <Pause /> : <Play />}
275
+ </span>
276
+ </button>
277
+ <input
278
+ className="article-video__cta-seek"
279
+ type="range"
280
+ min={0}
281
+ max={duration || 0}
282
+ step={0.1}
283
+ value={Math.min(currentTime, duration || 0)}
284
+ onChange={(event) => onSeek(Number(event.target.value))}
285
+ aria-label="Seek video"
286
+ disabled={!duration}
287
+ style={{ "--seek-progress": `${seekProgress}%` } as CSSProperties}
288
+ />
289
+ </div>
290
+ );
291
+ }
@@ -2,18 +2,35 @@
2
2
 
3
3
  import type { RefObject } from "react";
4
4
 
5
+ /** A rectangle, in the background layer's own coordinates, that header text occupies. */
6
+ export type BlogHeaderObstacle = {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+
13
+ /** Vertical rhythm grid the rhythm blog layout is built on. */
14
+ export type BlogBackgroundRhythm = {
15
+ rowHeight: number;
16
+ paintBuffer: number;
17
+ paintHeight: number;
18
+ };
19
+
5
20
  export type BlogPageBackgroundProps = {
6
21
  pageRef: RefObject<HTMLElement | null>;
22
+ /** Rhythm blog layout only: header text boxes the artwork should keep clear of. */
23
+ headerObstacles?: BlogHeaderObstacle[];
24
+ /** Rhythm blog layout only: the rhythm grid, so artwork can align to it. */
25
+ rhythm?: BlogBackgroundRhythm;
7
26
  };
8
27
 
9
28
  /**
10
29
  * Default (no-op) blog page background for publications without a bespoke
11
- * backdrop. Threat Intelligence supplies its own animated defense-graph
12
- * background (publications/threat_intelligence/blog-defense/page-background.tsx);
13
- * every other publication gets no decorative background layer. This is what
14
- * `renderers/pretext` falls back to for any publication that isn't Threat
15
- * Intelligence, so the blog layout loads cleanly without depending on TI's
16
- * package.
30
+ * backdrop. A publication supplies header artwork through the
31
+ * `components.BlogPageBackground` brand slot; with the rhythm blog layout the
32
+ * slot also receives `headerObstacles` and `rhythm`. This is what
33
+ * `renderers/pretext` falls back to when no slot component is registered.
17
34
  */
18
35
  export function BlogPageBackground(_props: BlogPageBackgroundProps) {
19
36
  return null;
@@ -0,0 +1,300 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ parseMarkusDocument,
4
+ type MarkusBlock,
5
+ type MarkusDirective,
6
+ type MarkusInline,
7
+ type MarkusNode,
8
+ } from "../lib/markus-ir";
9
+
10
+ const TOKEN_PATTERN = /PAPYRUSMARKUP\d{5}END/g;
11
+ const WHOLE_TOKEN_PATTERN = /^PAPYRUSMARKUP\d{5}END$/;
12
+
13
+ type PreviewSidecar = {
14
+ images: Record<string, Record<string, unknown>>;
15
+ citations: Record<string, string[]>;
16
+ citationLists: Record<string, unknown>;
17
+ bibliography: string[];
18
+ };
19
+
20
+ type PreviewContext = {
21
+ sidecar: PreviewSidecar;
22
+ citationNumbers: Map<string, number>;
23
+ imageUrls: Record<string, string>;
24
+ };
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function readSidecar(envelope: Record<string, unknown>): PreviewSidecar {
31
+ const sidecar = envelope.papyrus;
32
+ if (!isRecord(sidecar)) throw new Error("bodyIr.papyrus is missing");
33
+ const { images, citations, citationLists, bibliography } = sidecar;
34
+ if (!isRecord(images) || !isRecord(citations) || !isRecord(citationLists) || !Array.isArray(bibliography)) {
35
+ throw new Error("bodyIr.papyrus must contain images, citations, citationLists and bibliography");
36
+ }
37
+ return {
38
+ images: images as PreviewSidecar["images"],
39
+ citations: citations as PreviewSidecar["citations"],
40
+ citationLists,
41
+ bibliography: bibliography.map(String),
42
+ };
43
+ }
44
+
45
+ function numberCitationsByFirstAppearance(document: unknown, sidecar: PreviewSidecar): Map<string, number> {
46
+ const numbers = new Map<string, number>();
47
+ for (const token of JSON.stringify(document).match(TOKEN_PATTERN) ?? []) {
48
+ for (const key of sidecar.citations[token] ?? []) {
49
+ if (!numbers.has(key)) numbers.set(key, numbers.size + 1);
50
+ }
51
+ }
52
+ return numbers;
53
+ }
54
+
55
+ function imagePlaceholder(token: string, context: PreviewContext): ReactNode {
56
+ const entry = context.sidecar.images[token];
57
+ const src = typeof entry?.src === "string" ? entry.src : "";
58
+ const alt = typeof entry?.alt === "string" ? entry.alt : "";
59
+ const url = context.imageUrls[src];
60
+ if (url) {
61
+ return (
62
+ // eslint-disable-next-line @next/next/no-img-element
63
+ <img
64
+ alt={alt}
65
+ className="my-3 h-auto max-w-full rounded-lg"
66
+ data-markus-preview-image-loaded
67
+ loading="lazy"
68
+ src={url}
69
+ />
70
+ );
71
+ }
72
+ return (
73
+ <div
74
+ className="my-3 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-6 text-center text-xs text-muted-foreground"
75
+ data-markus-preview-image
76
+ >
77
+ {`Image: ${src} (${alt})`}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function renderText(text: string, context: PreviewContext): ReactNode {
83
+ const parts: ReactNode[] = [];
84
+ let cursor = 0;
85
+ for (const match of text.matchAll(TOKEN_PATTERN)) {
86
+ const token = match[0];
87
+ const index = match.index ?? 0;
88
+ const keys = context.sidecar.citations[token];
89
+ if (!keys) continue;
90
+ if (index > cursor) parts.push(text.slice(cursor, index));
91
+ parts.push(
92
+ <sup className="text-primary" key={`${index}-${token}`}>
93
+ {keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
94
+ </sup>,
95
+ );
96
+ cursor = index + token.length;
97
+ }
98
+ if (cursor < text.length) parts.push(text.slice(cursor));
99
+ return parts;
100
+ }
101
+
102
+ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactNode[] {
103
+ return inlines.map((node, index) => {
104
+ switch (node.type) {
105
+ case "text":
106
+ return <span key={index}>{renderText(node.text, context)}</span>;
107
+ case "emphasis":
108
+ return <em key={index}>{renderInlines(node.children, context)}</em>;
109
+ case "strong":
110
+ return <strong key={index}>{renderInlines(node.children, context)}</strong>;
111
+ case "strikethrough":
112
+ return <s key={index}>{renderInlines(node.children, context)}</s>;
113
+ case "code_span":
114
+ return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{node.code}</code>;
115
+ case "link":
116
+ return node.href ? (
117
+ <a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">
118
+ {renderInlines(node.children, context)}
119
+ </a>
120
+ ) : (
121
+ <span key={index}>{renderInlines(node.children, context)}</span>
122
+ );
123
+ case "image":
124
+ return (
125
+ <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
126
+ {`Image: ${node.src ?? ""} (${node.alt})`}
127
+ </span>
128
+ );
129
+ case "soft_break":
130
+ return <span key={index}>{" "}</span>;
131
+ case "hard_break":
132
+ return <br key={index} />;
133
+ case "html_inline":
134
+ return <code className="font-mono text-xs" key={index}>{node.value}</code>;
135
+ }
136
+ });
137
+ }
138
+
139
+ function soleToken(inlines: MarkusInline[]): string | null {
140
+ if (inlines.length !== 1 || inlines[0].type !== "text") return null;
141
+ const text = inlines[0].text.trim();
142
+ return WHOLE_TOKEN_PATTERN.test(text) ? text : null;
143
+ }
144
+
145
+ const HEADING_CLASSES = [
146
+ "mt-5 mb-2 text-2xl font-semibold tracking-tight",
147
+ "mt-5 mb-2 text-xl font-semibold tracking-tight",
148
+ "mt-4 mb-2 text-lg font-semibold",
149
+ "mt-4 mb-1 text-base font-semibold",
150
+ "mt-3 mb-1 text-sm font-semibold",
151
+ "mt-3 mb-1 text-sm font-medium",
152
+ ];
153
+
154
+ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext): ReactNode {
155
+ switch (block.type) {
156
+ case "paragraph": {
157
+ const token = soleToken(block.inline);
158
+ if (token && token in context.sidecar.images) return <div key={index}>{imagePlaceholder(token, context)}</div>;
159
+ if (token && token in context.sidecar.citationLists) {
160
+ return (
161
+ <ol className="my-3 list-decimal space-y-1 pl-6 text-sm" data-markus-preview-bibliography key={index}>
162
+ {context.sidecar.bibliography.map((entry, entryIndex) => (
163
+ <li key={entryIndex}>{entry}</li>
164
+ ))}
165
+ </ol>
166
+ );
167
+ }
168
+ return <p className="my-3 leading-relaxed" key={index}>{renderInlines(block.inline, context)}</p>;
169
+ }
170
+ case "heading": {
171
+ const level = Math.min(Math.max(block.level, 1), 6);
172
+ const Tag = `h${level}` as "h1";
173
+ return <Tag className={HEADING_CLASSES[level - 1]} key={index}>{renderInlines(block.inline, context)}</Tag>;
174
+ }
175
+ case "list": {
176
+ const items = block.items.map((item, itemIndex) => (
177
+ <li key={itemIndex}>{renderBlocks(item.children, context)}</li>
178
+ ));
179
+ return block.ordered ? (
180
+ <ol className="my-3 list-decimal space-y-1 pl-6" key={index} start={block.start ?? undefined}>{items}</ol>
181
+ ) : (
182
+ <ul className="my-3 list-disc space-y-1 pl-6" key={index}>{items}</ul>
183
+ );
184
+ }
185
+ case "blockquote":
186
+ return (
187
+ <blockquote className="my-3 border-l-4 border-border pl-4 italic text-muted-foreground" key={index}>
188
+ {renderBlocks(block.children, context)}
189
+ </blockquote>
190
+ );
191
+ case "code":
192
+ return (
193
+ <pre className="my-3 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs" key={index}>
194
+ <code>{block.value}</code>
195
+ </pre>
196
+ );
197
+ case "thematic_break":
198
+ return <hr className="my-4 border-border" key={index} />;
199
+ case "table":
200
+ return (
201
+ <div className="my-3 overflow-x-auto" key={index}>
202
+ <table className="w-full border-collapse text-sm">
203
+ <thead>
204
+ <tr>
205
+ {block.header.map((cell, cellIndex) => (
206
+ <th className="border border-border bg-muted/40 px-2 py-1 text-left" key={cellIndex}>
207
+ {renderInlines(cell, context)}
208
+ </th>
209
+ ))}
210
+ </tr>
211
+ </thead>
212
+ <tbody>
213
+ {block.rows.map((row, rowIndex) => (
214
+ <tr key={rowIndex}>
215
+ {row.map((cell, cellIndex) => (
216
+ <td className="border border-border px-2 py-1" key={cellIndex}>{renderInlines(cell, context)}</td>
217
+ ))}
218
+ </tr>
219
+ ))}
220
+ </tbody>
221
+ </table>
222
+ </div>
223
+ );
224
+ case "html":
225
+ return (
226
+ <pre className="my-3 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs" key={index}>{block.value}</pre>
227
+ );
228
+ }
229
+ }
230
+
231
+ function renderDirective(directive: MarkusDirective, index: number, context: PreviewContext): ReactNode {
232
+ if (directive.name === "pull-quote") {
233
+ const attribution = directive.attributes.attribution;
234
+ return (
235
+ <blockquote
236
+ className="my-4 border-y border-border px-4 py-3 text-lg font-medium italic"
237
+ data-markus-preview-directive="pull-quote"
238
+ key={index}
239
+ >
240
+ {renderBlocks(directive.children, context)}
241
+ {typeof attribution === "string" && attribution ? (
242
+ <footer className="mt-1 text-sm not-italic text-muted-foreground">{`— ${attribution}`}</footer>
243
+ ) : null}
244
+ </blockquote>
245
+ );
246
+ }
247
+ const attributeEntries = Object.entries(directive.attributes).filter(([, value]) => value !== null && value !== "");
248
+ return (
249
+ <section
250
+ className="my-3 rounded-lg border border-border"
251
+ data-markus-preview-directive={directive.name}
252
+ key={index}
253
+ >
254
+ <header className="border-b border-border bg-muted/40 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
255
+ {directive.name}
256
+ </header>
257
+ <div className="px-3 py-1">
258
+ {directive.leaf && attributeEntries.length > 0 ? (
259
+ <p className="my-2 font-mono text-xs text-muted-foreground">
260
+ {attributeEntries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}
261
+ </p>
262
+ ) : null}
263
+ {renderBlocks(directive.children, context)}
264
+ </div>
265
+ </section>
266
+ );
267
+ }
268
+
269
+ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[] {
270
+ return nodes.map((node, index) =>
271
+ node.type === "directive" ? renderDirective(node, index, context) : renderBlock(node, index, context),
272
+ );
273
+ }
274
+
275
+ export function MarkusIrPreview({
276
+ bodyIr,
277
+ imageUrls = {},
278
+ }: {
279
+ bodyIr: unknown;
280
+ imageUrls?: Record<string, string>;
281
+ }) {
282
+ try {
283
+ const envelope = typeof bodyIr === "string" ? JSON.parse(bodyIr) : bodyIr;
284
+ if (!isRecord(envelope)) throw new Error("bodyIr must be a JSON object");
285
+ const document = parseMarkusDocument(envelope.document);
286
+ const sidecar = readSidecar(envelope);
287
+ const context: PreviewContext = {
288
+ sidecar,
289
+ citationNumbers: numberCitationsByFirstAppearance(envelope.document, sidecar),
290
+ imageUrls,
291
+ };
292
+ return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
293
+ } catch (error) {
294
+ return (
295
+ <p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">
296
+ {`The preview could not be rendered: ${error instanceof Error ? error.message : "unknown error"}`}
297
+ </p>
298
+ );
299
+ }
300
+ }