@anthusai/papyrus 1.0.0-next.6 → 1.0.0-next.7
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.
|
@@ -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({
|
|
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
|
-
<
|
|
521
|
-
<
|
|
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
|
-
</
|
|
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
|
|
59
|
+
papyrus: { images, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
|
|
42
60
|
};
|
|
43
61
|
}
|
|
44
62
|
|
|
@@ -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.
|
|
279
|
+
"version": "1.0.0-next.7",
|
|
278
280
|
"dependencies": {
|
|
279
281
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
280
282
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
package/routes.manifest.json
CHANGED