@anthusai/papyrus 1.0.0-next.6 → 1.0.0-next.8

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.
@@ -130,7 +130,7 @@ def request_staging_build_action() -> dict:
130
130
 
131
131
 
132
132
  def handler(event, context):
133
- field = event["info"]["fieldName"]
133
+ field = event["fieldName"]
134
134
  arguments = parse_input(event["arguments"]["input"])
135
135
  actor = actor_from_event(event)
136
136
  try:
@@ -20,6 +20,7 @@ type PreviewSidecar = {
20
20
  type PreviewContext = {
21
21
  sidecar: PreviewSidecar;
22
22
  citationNumbers: Map<string, number>;
23
+ imageUrls: Record<string, string>;
23
24
  };
24
25
 
25
26
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -55,6 +56,19 @@ function imagePlaceholder(token: string, context: PreviewContext): ReactNode {
55
56
  const entry = context.sidecar.images[token];
56
57
  const src = typeof entry?.src === "string" ? entry.src : "";
57
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
+ }
58
72
  return (
59
73
  <div
60
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"
@@ -258,7 +272,13 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
258
272
  );
259
273
  }
260
274
 
261
- export function MarkusIrPreview({ bodyIr }: { bodyIr: unknown }) {
275
+ export function MarkusIrPreview({
276
+ bodyIr,
277
+ imageUrls = {},
278
+ }: {
279
+ bodyIr: unknown;
280
+ imageUrls?: Record<string, string>;
281
+ }) {
262
282
  try {
263
283
  const envelope = typeof bodyIr === "string" ? JSON.parse(bodyIr) : bodyIr;
264
284
  if (!isRecord(envelope)) throw new Error("bodyIr must be a JSON object");
@@ -267,6 +287,7 @@ export function MarkusIrPreview({ bodyIr }: { bodyIr: unknown }) {
267
287
  const context: PreviewContext = {
268
288
  sidecar,
269
289
  citationNumbers: numberCitationsByFirstAppearance(envelope.document, sidecar),
290
+ imageUrls,
270
291
  };
271
292
  return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
272
293
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react";
3
+ import { ArrowLeftIcon, ExternalLinkIcon, ImagePlusIcon } from "lucide-react";
4
4
  import Link from "next/link";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { Badge } from "./ui/badge";
@@ -34,6 +34,12 @@ import {
34
34
  type ErrorLocation,
35
35
  type NewsroomArticleEditorState,
36
36
  } from "../lib/newsroom-articles";
37
+ import { resolveMediaBackend } from "../lib/newsroom-media-backend";
38
+ import {
39
+ insertDirectiveAtSelection,
40
+ readImageDimensions,
41
+ uploadArticleImage,
42
+ } from "../lib/newsroom-media";
37
43
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
38
44
  import { SITE_BRAND } from "../lib/site-brand";
39
45
  import { cn } from "../lib/utils";
@@ -105,9 +111,14 @@ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string;
105
111
  const [mobileTab, setMobileTab] = useState<MobileTab>("edit");
106
112
  const [sections, setSections] = useState<string[]>([]);
107
113
  const [reloadToken, setReloadToken] = useState(0);
114
+ const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
115
+ const [imageUploading, setImageUploading] = useState(false);
116
+ const [imageError, setImageError] = useState<string | null>(null);
108
117
 
109
118
  const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
110
119
  const bodyRef = useRef<HTMLTextAreaElement | null>(null);
120
+ const imageInputRef = useRef<HTMLInputElement | null>(null);
121
+ const mediaBackend = useMemo(() => resolveMediaBackend(demo), [demo]);
111
122
  const validationSequence = useRef(0);
112
123
 
113
124
  useEffect(() => {
@@ -147,6 +158,33 @@ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string;
147
158
  };
148
159
  }, [backend, blocked]);
149
160
 
161
+ const savedItemId = editor?.id ?? null;
162
+ useEffect(() => {
163
+ if (blocked || savedItemId === null) return;
164
+ let cancelled = false;
165
+ mediaBackend
166
+ .listMediaAssets(savedItemId)
167
+ .then(async (assets) => {
168
+ const entries = await Promise.all(
169
+ assets.map(async (asset) => {
170
+ if (!asset.srcPath || !asset.storagePath) return null;
171
+ try {
172
+ return [asset.srcPath, await mediaBackend.imageUrl(asset.storagePath)] as const;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }),
177
+ );
178
+ if (cancelled) return;
179
+ const loaded = Object.fromEntries(entries.filter((entry) => entry !== null));
180
+ setImageUrls((current) => ({ ...loaded, ...current }));
181
+ })
182
+ .catch(() => undefined);
183
+ return () => {
184
+ cancelled = true;
185
+ };
186
+ }, [blocked, mediaBackend, savedItemId]);
187
+
150
188
  const frontMatterYaml = editor?.frontMatterYaml ?? null;
151
189
  const bodyMarkus = editor?.bodyMarkus ?? null;
152
190
 
@@ -207,6 +245,35 @@ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string;
207
245
  }, 0);
208
246
  };
209
247
 
248
+ const insertImage = async (file: File) => {
249
+ if (!editor || editor.id === null) return;
250
+ setImageUploading(true);
251
+ setImageError(null);
252
+ try {
253
+ const uploaded = await uploadArticleImage(mediaBackend, readImageDimensions, {
254
+ itemId: editor.id,
255
+ slug: editor.slug,
256
+ file,
257
+ });
258
+ const textarea = bodyRef.current;
259
+ const start = textarea?.selectionStart ?? editor.bodyMarkus.length;
260
+ const end = textarea?.selectionEnd ?? editor.bodyMarkus.length;
261
+ const inserted = insertDirectiveAtSelection(editor.bodyMarkus, start, end, uploaded.directive);
262
+ setImageUrls((current) => ({ ...current, [uploaded.srcPath]: uploaded.imageUrl }));
263
+ patchEditor({ bodyMarkus: inserted.body });
264
+ window.setTimeout(() => {
265
+ const target = bodyRef.current;
266
+ if (!target) return;
267
+ target.focus();
268
+ target.setSelectionRange(inserted.altSelectionStart, inserted.altSelectionStart);
269
+ }, 0);
270
+ } catch (error) {
271
+ setImageError(failureMessage(error));
272
+ } finally {
273
+ setImageUploading(false);
274
+ }
275
+ };
276
+
210
277
  const saveDraft = useCallback(async (): Promise<string | null> => {
211
278
  if (!editor) return null;
212
279
  setBusy("save");
@@ -517,18 +584,55 @@ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string;
517
584
  />
518
585
  </label>
519
586
 
520
- <label className="block space-y-1 text-sm font-medium">
521
- <span>Body (Markus)</span>
587
+ <div className="space-y-1 text-sm font-medium">
588
+ <div className="flex flex-wrap items-center justify-between gap-2">
589
+ <label htmlFor="newsroom-article-body">Body (Markus)</label>
590
+ <Button
591
+ data-newsroom-article-insert-image
592
+ disabled={editor.id === null || imageUploading || busy !== null}
593
+ onClick={() => imageInputRef.current?.click()}
594
+ size="xs"
595
+ type="button"
596
+ variant="outline"
597
+ >
598
+ {imageUploading ? <Spinner /> : <ImagePlusIcon aria-hidden="true" />}
599
+ Insert image
600
+ </Button>
601
+ <input
602
+ accept="image/*"
603
+ aria-label="Image file"
604
+ className="hidden"
605
+ data-newsroom-article-image-input
606
+ onChange={(event) => {
607
+ const file = event.target.files?.[0];
608
+ event.target.value = "";
609
+ if (file) void insertImage(file);
610
+ }}
611
+ ref={imageInputRef}
612
+ type="file"
613
+ />
614
+ </div>
615
+ {editor.id === null ? (
616
+ <p className="text-xs font-normal text-muted-foreground" data-newsroom-article-image-hint>
617
+ Save the draft once to enable image upload.
618
+ </p>
619
+ ) : null}
620
+ {imageError ? (
621
+ <p className="text-xs font-normal text-destructive" data-newsroom-article-image-error role="alert">
622
+ {imageError}
623
+ </p>
624
+ ) : null}
522
625
  <Textarea
523
626
  className={TEXTAREA_CLASS}
524
627
  data-newsroom-article-body
628
+ id="newsroom-article-body"
525
629
  onChange={(event) => patchEditor({ bodyMarkus: event.target.value })}
526
630
  ref={bodyRef}
527
631
  rows={28}
528
632
  spellCheck={false}
529
633
  value={editor.bodyMarkus}
530
634
  />
531
- </label>
635
+ </div>
532
636
 
533
637
  {validation.status === "unavailable" ? (
534
638
  <NewsroomOpsStatusBanner tone="error">{validation.message}</NewsroomOpsStatusBanner>
@@ -574,7 +678,7 @@ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string;
574
678
  <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
575
679
  ) : null}
576
680
  {bodyIr ? (
577
- <MarkusIrPreview bodyIr={bodyIr} />
681
+ <MarkusIrPreview bodyIr={bodyIr} imageUrls={imageUrls} />
578
682
  ) : (
579
683
  <p className="text-sm text-muted-foreground">
580
684
  {invalid ? "Fix the errors to see a preview." : "The preview appears once the markup is valid."}
@@ -22,7 +22,15 @@ function titleFromYaml(frontMatterYaml: string | null, fallback: string): string
22
22
  return fallback;
23
23
  }
24
24
 
25
+ const DEMO_IMAGE_DIRECTIVE = /^::image\{([^}]*)\}$/;
26
+
27
+ function demoAttribute(attributes: string, name: string): string {
28
+ const match = new RegExp(`${name}="([^"]*)"`).exec(attributes);
29
+ return match ? match[1] : "";
30
+ }
31
+
25
32
  function demoEnvelope(bodyMarkus: string) {
33
+ const images: Record<string, Record<string, unknown>> = {};
26
34
  const children = bodyMarkus
27
35
  .split(/\n{2,}/)
28
36
  .map((block) => block.trim())
@@ -32,13 +40,23 @@ function demoEnvelope(bodyMarkus: string) {
32
40
  if (heading) {
33
41
  return { type: "heading", level: heading[1].length, inline: [{ type: "text", text: heading[2] }], line: null };
34
42
  }
43
+ const image = DEMO_IMAGE_DIRECTIVE.exec(block);
44
+ if (image) {
45
+ const token = `PAPYRUSMARKUP${String(Object.keys(images).length + 1).padStart(5, "0")}END`;
46
+ images[token] = {
47
+ src: demoAttribute(image[1], "src"),
48
+ alt: demoAttribute(image[1], "alt"),
49
+ layout: demoAttribute(image[1], "layout") || null,
50
+ };
51
+ return { type: "paragraph", inline: [{ type: "text", text: token }], line: null };
52
+ }
35
53
  return { type: "paragraph", inline: [{ type: "text", text: block }], line: null };
36
54
  });
37
55
  return {
38
56
  schemaVersion: 1,
39
57
  markus: { version: "demo", irSchemaVersion: 1 },
40
58
  document: { type: "document", schema_version: 1, front_matter: {}, children },
41
- papyrus: { images: {}, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
59
+ papyrus: { images, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
42
60
  };
43
61
  }
44
62
 
@@ -3,12 +3,22 @@ import { getContentSource, type ContentSource } from "./site-env";
3
3
 
4
4
  export type ContentSourceContext = {
5
5
  source: ContentSource;
6
+ /** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
7
+ client?: unknown;
6
8
  };
7
9
 
8
10
  const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
9
11
 
10
- export function runWithContentSource<T>(source: ContentSource, operation: () => Promise<T>): Promise<T> {
11
- return contentSourceStorage.run({ source }, operation);
12
+ export function runWithContentSource<T>(
13
+ source: ContentSource,
14
+ operation: () => Promise<T>,
15
+ client?: unknown,
16
+ ): Promise<T> {
17
+ return contentSourceStorage.run({ source, client }, operation);
18
+ }
19
+
20
+ export function currentRequestClient<T>(): T | undefined {
21
+ return contentSourceStorage.getStore()?.client as T | undefined;
12
22
  }
13
23
 
14
24
  export function currentContentSource(): ContentSource {
@@ -1,3 +1,4 @@
1
+ import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
1
2
  import { generateClient } from "aws-amplify/data";
2
3
  import type { Schema } from "../amplify/data/resource";
3
4
  import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
@@ -17,7 +18,7 @@ import {
17
18
  } from "./publication-items";
18
19
  import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
19
20
  import { SITE_BRAND } from "./site-brand";
20
- import { currentContentSource, runWithContentSource } from "./content-source-context";
21
+ import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
21
22
  import { getContentSource } from "./site-env";
22
23
 
23
24
  const DEFAULT_EDITION_SLUG = "current";
@@ -143,12 +144,18 @@ function isVisibleItemStatus(status: string): boolean {
143
144
  }
144
145
 
145
146
  async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
146
- const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
147
+ const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
147
148
  const source = getContentSource();
148
- const nextServerContext = source === "drafts" ? { cookies: (await import("next/headers")).cookies } : null;
149
+ if (source === "drafts") {
150
+ // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
151
+ // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
152
+ const { cookies } = await import("next/headers");
153
+ const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
154
+ return runWithContentSource(source, operation, client);
155
+ }
149
156
  return runWithContentSource(source, () =>
150
157
  runWithAmplifyServerContext({
151
- nextServerContext,
158
+ nextServerContext: null,
152
159
  operation: async () => {
153
160
  readerContextClient = generateClient<Schema>({ authMode: authMode() });
154
161
  try {
@@ -241,6 +248,8 @@ export const graphqlContentRepository: ContentRepository = {
241
248
  };
242
249
 
243
250
  function getClient(): DataClient {
251
+ const requestClient = currentRequestClient<DataClient>();
252
+ if (requestClient) return requestClient;
244
253
  if (readerContextClient) return readerContextClient;
245
254
 
246
255
  if (!buildTimeClient) {
@@ -0,0 +1,142 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import { getUrl, uploadData } from "aws-amplify/storage";
3
+ import type { Schema } from "../amplify/data/resource";
4
+ import type { MediaAssetFields, MediaAssetRecord, NewsroomMediaBackend } from "./newsroom-media";
5
+
6
+ type MediaListPage = {
7
+ data?: (Record<string, unknown> | null)[] | null;
8
+ nextToken?: string | null;
9
+ errors?: { message?: string }[] | null;
10
+ };
11
+
12
+ export type MediaDataClient = {
13
+ models: {
14
+ MediaAsset: {
15
+ create: (
16
+ input: MediaAssetFields,
17
+ options: { authMode: "userPool" },
18
+ ) => Promise<{ errors?: { message?: string }[] | null }>;
19
+ listMediaAssetsByItemAndSortKey: (
20
+ input: { itemId: string },
21
+ options: { authMode: "userPool"; nextToken?: string | null; limit?: number },
22
+ ) => Promise<MediaListPage>;
23
+ };
24
+ };
25
+ };
26
+
27
+ export type MediaStorageApi = {
28
+ upload: (input: { path: string; data: Blob; contentType: string }) => Promise<void>;
29
+ signedUrl: (path: string) => Promise<string>;
30
+ };
31
+
32
+ function failOnErrors(errors: { message?: string }[] | null | undefined): void {
33
+ if (errors && errors.length > 0) {
34
+ throw new Error(errors.map((error) => error.message ?? "GraphQL error").join("; "));
35
+ }
36
+ }
37
+
38
+ function srcPathFromMetadata(metadata: unknown): string | null {
39
+ let parsed = metadata;
40
+ if (typeof metadata === "string") {
41
+ try {
42
+ parsed = JSON.parse(metadata);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ if (typeof parsed !== "object" || parsed === null) return null;
48
+ const srcPath = (parsed as Record<string, unknown>).srcPath;
49
+ return typeof srcPath === "string" ? srcPath : null;
50
+ }
51
+
52
+ export function createMediaBackend(client: () => MediaDataClient, storage: MediaStorageApi): NewsroomMediaBackend {
53
+ return {
54
+ uploadImage: ({ storagePath, data, contentType }) =>
55
+ storage.upload({ path: storagePath, data, contentType }),
56
+ async createMediaAsset(fields) {
57
+ const response = await client().models.MediaAsset.create(fields, { authMode: "userPool" });
58
+ failOnErrors(response.errors);
59
+ },
60
+ async listMediaAssets(itemId) {
61
+ const records: MediaAssetRecord[] = [];
62
+ let nextToken: string | null | undefined = null;
63
+ do {
64
+ const page: MediaListPage = await client().models.MediaAsset.listMediaAssetsByItemAndSortKey(
65
+ { itemId },
66
+ { authMode: "userPool", nextToken, limit: 500 },
67
+ );
68
+ failOnErrors(page.errors);
69
+ for (const row of page.data ?? []) {
70
+ if (!row) continue;
71
+ records.push({
72
+ id: String(row.id),
73
+ sortKey: String(row.sortKey),
74
+ storagePath: typeof row.storagePath === "string" ? row.storagePath : null,
75
+ alt: typeof row.alt === "string" ? row.alt : "",
76
+ srcPath: srcPathFromMetadata(row.metadata),
77
+ });
78
+ }
79
+ nextToken = page.nextToken;
80
+ } while (nextToken);
81
+ return records;
82
+ },
83
+ imageUrl: (storagePath) => storage.signedUrl(storagePath),
84
+ };
85
+ }
86
+
87
+ const amplifyStorage: MediaStorageApi = {
88
+ async upload({ path, data, contentType }) {
89
+ await uploadData({ path, data, options: { contentType } }).result;
90
+ },
91
+ async signedUrl(path) {
92
+ const { url } = await getUrl({ path });
93
+ return url.toString();
94
+ },
95
+ };
96
+
97
+ export function createLiveMediaBackend(): NewsroomMediaBackend {
98
+ let client: MediaDataClient | null = null;
99
+ return createMediaBackend(() => {
100
+ if (!client) client = generateClient<Schema>() as unknown as MediaDataClient;
101
+ return client;
102
+ }, amplifyStorage);
103
+ }
104
+
105
+ export function createDemoMediaBackend(): NewsroomMediaBackend {
106
+ const objects = new Map<string, Blob>();
107
+ const rows = new Map<string, MediaAssetFields[]>();
108
+ return {
109
+ async uploadImage({ storagePath, data }) {
110
+ objects.set(storagePath, data);
111
+ },
112
+ async createMediaAsset(fields) {
113
+ rows.set(fields.itemId, [...(rows.get(fields.itemId) ?? []), fields]);
114
+ },
115
+ async listMediaAssets(itemId) {
116
+ return (rows.get(itemId) ?? []).map((fields) => ({
117
+ id: fields.id,
118
+ sortKey: fields.sortKey,
119
+ storagePath: fields.storagePath,
120
+ alt: fields.alt,
121
+ srcPath: srcPathFromMetadata(fields.metadata),
122
+ }));
123
+ },
124
+ async imageUrl(storagePath) {
125
+ const object = objects.get(storagePath);
126
+ if (!object) throw new Error(`No demo object at ${storagePath}.`);
127
+ return URL.createObjectURL(object);
128
+ },
129
+ };
130
+ }
131
+
132
+ let liveBackend: NewsroomMediaBackend | null = null;
133
+ let demoBackend: NewsroomMediaBackend | null = null;
134
+
135
+ export function resolveMediaBackend(demo: boolean): NewsroomMediaBackend {
136
+ if (demo) {
137
+ demoBackend ??= createDemoMediaBackend();
138
+ return demoBackend;
139
+ }
140
+ liveBackend ??= createLiveMediaBackend();
141
+ return liveBackend;
142
+ }
@@ -0,0 +1,182 @@
1
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
2
+
3
+ export const IMAGE_CONTENT_TYPES: Record<string, string> = {
4
+ png: "image/png",
5
+ jpg: "image/jpeg",
6
+ jpeg: "image/jpeg",
7
+ gif: "image/gif",
8
+ webp: "image/webp",
9
+ avif: "image/avif",
10
+ svg: "image/svg+xml",
11
+ };
12
+
13
+ export type ImageFileLike = { name: string; size: number };
14
+
15
+ function extensionOf(filename: string): string {
16
+ const dot = filename.lastIndexOf(".");
17
+ return dot === -1 ? "" : filename.slice(dot + 1).toLowerCase();
18
+ }
19
+
20
+ export function contentTypeForFilename(filename: string): string | null {
21
+ return IMAGE_CONTENT_TYPES[extensionOf(filename)] ?? null;
22
+ }
23
+
24
+ export function isSvgFilename(filename: string): boolean {
25
+ return extensionOf(filename) === "svg";
26
+ }
27
+
28
+ function sanitizeSegment(value: string): string {
29
+ return value
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9._-]+/g, "-")
32
+ .replace(/-{2,}/g, "-")
33
+ .replace(/^[-.]+|-+$/g, "");
34
+ }
35
+
36
+ export function srcPathFor(slug: string, filename: string, existingSrcPaths: Iterable<string> = []): string {
37
+ const taken = new Set(existingSrcPaths);
38
+ const extension = sanitizeSegment(extensionOf(filename));
39
+ const dot = filename.lastIndexOf(".");
40
+ const rawStem = dot === -1 ? filename : filename.slice(0, dot);
41
+ const stem = sanitizeSegment(rawStem) || "image";
42
+ const directory = sanitizeSegment(slug) || "article";
43
+ const suffix = extension ? `.${extension}` : "";
44
+ let candidate = `assets/${directory}/${stem}${suffix}`;
45
+ for (let counter = 2; taken.has(candidate); counter += 1) {
46
+ candidate = `assets/${directory}/${stem}-${counter}${suffix}`;
47
+ }
48
+ return candidate;
49
+ }
50
+
51
+ export async function sha256Hex(data: string | ArrayBuffer): Promise<string> {
52
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
53
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
54
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
55
+ }
56
+
57
+ export async function mediaIdFor(itemId: string, srcPath: string): Promise<string> {
58
+ return `media-${itemId}-${(await sha256Hex(srcPath)).slice(0, 10)}`;
59
+ }
60
+
61
+ export function validateImageFile(file: ImageFileLike): string | null {
62
+ if (contentTypeForFilename(file.name) === null) {
63
+ return "Choose a PNG, JPEG, GIF, WebP, AVIF or SVG image.";
64
+ }
65
+ if (file.size <= 0) return "That file is empty.";
66
+ if (file.size > MAX_IMAGE_BYTES) return "Images can be at most 10 MB.";
67
+ return null;
68
+ }
69
+
70
+ function altAttributeValue(alt: string): string {
71
+ return alt.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/[{}]/g, "");
72
+ }
73
+
74
+ export function imageDirective(srcPath: string, alt: string): string {
75
+ return `::image{src="${srcPath}" alt="${altAttributeValue(alt)}" layout="inline"}`;
76
+ }
77
+
78
+ export type MediaAssetFields = {
79
+ id: string;
80
+ itemId: string;
81
+ type: "image";
82
+ role: "body";
83
+ sortKey: string;
84
+ storagePath: string;
85
+ alt: string;
86
+ width?: number;
87
+ height?: number;
88
+ metadata: string;
89
+ };
90
+
91
+ export function mediaSortKey(position: number, srcPath: string): string {
92
+ return `${String(position).padStart(3, "0")}#${srcPath}`;
93
+ }
94
+
95
+ export type SelectionInsertion = { body: string; altSelectionStart: number };
96
+
97
+ function countEdgeNewlines(text: string, fromEnd: boolean): number {
98
+ let count = 0;
99
+ while (count < 2 && (fromEnd ? text[text.length - 1 - count] : text[count]) === "\n") count += 1;
100
+ return count;
101
+ }
102
+
103
+ export function insertDirectiveAtSelection(
104
+ body: string,
105
+ selectionStart: number,
106
+ selectionEnd: number,
107
+ directive: string,
108
+ ): SelectionInsertion {
109
+ const before = body.slice(0, selectionStart);
110
+ const after = body.slice(selectionEnd);
111
+ const prefix = before === "" ? "" : "\n".repeat(2 - countEdgeNewlines(before, true));
112
+ const suffix = after === "" ? "\n" : "\n".repeat(2 - countEdgeNewlines(after, false));
113
+ const directiveStart = before.length + prefix.length;
114
+ return {
115
+ body: `${before}${prefix}${directive}${suffix}${after}`,
116
+ altSelectionStart: directiveStart + directive.indexOf('alt="') + 'alt="'.length,
117
+ };
118
+ }
119
+
120
+ export type MediaAssetRecord = {
121
+ id: string;
122
+ sortKey: string;
123
+ storagePath: string | null;
124
+ alt: string;
125
+ srcPath: string | null;
126
+ };
127
+
128
+ export type NewsroomMediaBackend = {
129
+ uploadImage: (input: { storagePath: string; data: Blob; contentType: string }) => Promise<void>;
130
+ createMediaAsset: (fields: MediaAssetFields) => Promise<void>;
131
+ listMediaAssets: (itemId: string) => Promise<MediaAssetRecord[]>;
132
+ imageUrl: (storagePath: string) => Promise<string>;
133
+ };
134
+
135
+ export type ImageDimensionReader = (file: Blob, filename: string) => Promise<{ width: number; height: number } | null>;
136
+
137
+ export type UploadedImage = { srcPath: string; directive: string; imageUrl: string };
138
+
139
+ export async function uploadArticleImage(
140
+ backend: NewsroomMediaBackend,
141
+ readDimensions: ImageDimensionReader,
142
+ input: { itemId: string; slug: string; file: File },
143
+ ): Promise<UploadedImage> {
144
+ const problem = validateImageFile(input.file);
145
+ if (problem) throw new Error(problem);
146
+ const contentType = contentTypeForFilename(input.file.name) as string;
147
+ const existing = await backend.listMediaAssets(input.itemId);
148
+ const srcPath = srcPathFor(
149
+ input.slug,
150
+ input.file.name,
151
+ existing.map((asset) => asset.srcPath).filter((path): path is string => path !== null),
152
+ );
153
+ const bytes = await input.file.arrayBuffer();
154
+ const sha256 = await sha256Hex(bytes);
155
+ const dimensions = await readDimensions(input.file, input.file.name);
156
+ const storagePath = `media/${srcPath}`;
157
+ await backend.uploadImage({ storagePath, data: input.file, contentType });
158
+ await backend.createMediaAsset({
159
+ id: await mediaIdFor(input.itemId, srcPath),
160
+ itemId: input.itemId,
161
+ type: "image",
162
+ role: "body",
163
+ sortKey: mediaSortKey(existing.length + 1, srcPath),
164
+ storagePath,
165
+ alt: input.file.name,
166
+ ...(dimensions ?? {}),
167
+ metadata: JSON.stringify({ srcPath, sha256, bytes: bytes.byteLength, contentType }),
168
+ });
169
+ return { srcPath, directive: imageDirective(srcPath, ""), imageUrl: await backend.imageUrl(storagePath) };
170
+ }
171
+
172
+ export async function readImageDimensions(file: Blob, filename: string): Promise<{ width: number; height: number } | null> {
173
+ if (isSvgFilename(filename)) return null;
174
+ try {
175
+ const bitmap = await createImageBitmap(file);
176
+ const size = { width: bitmap.width, height: bitmap.height };
177
+ bitmap.close();
178
+ return size;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
package/package.json CHANGED
@@ -167,6 +167,8 @@
167
167
  "./lib/newsroom-index-filters": "./lib/newsroom-index-filters.ts",
168
168
  "./lib/newsroom-knowledge-query-request": "./lib/newsroom-knowledge-query-request.ts",
169
169
  "./lib/newsroom-list-selection": "./lib/newsroom-list-selection.ts",
170
+ "./lib/newsroom-media-backend": "./lib/newsroom-media-backend.ts",
171
+ "./lib/newsroom-media": "./lib/newsroom-media.ts",
170
172
  "./lib/newsroom-nav": "./lib/newsroom-nav.ts",
171
173
  "./lib/newsroom-references": "./lib/newsroom-references.ts",
172
174
  "./lib/newsroom-sections": "./lib/newsroom-sections.ts",
@@ -274,7 +276,7 @@
274
276
  "optional": true
275
277
  }
276
278
  },
277
- "version": "1.0.0-next.6",
279
+ "version": "1.0.0-next.8",
278
280
  "dependencies": {
279
281
  "@aws-amplify/adapter-nextjs": "^1.7.3",
280
282
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.6",
2
+ "version": "1.0.0-next.8",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",