@avocadostudio-ai/orchestrator-core 0.3.3 → 0.4.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/dist/chat/chat-pipeline.js +16 -1
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/checks/field-walk.d.ts +18 -1
- package/dist/checks/field-walk.js +46 -0
- package/dist/checks/rules-draft.js +79 -15
- package/dist/checks/run-checks.d.ts +11 -1
- package/dist/checks/run-checks.js +11 -4
- package/dist/checks/session-runner.js +4 -0
- package/dist/checks/types.d.ts +44 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/handler/create-orchestrator.js +220 -32
- package/dist/http/history-actions.d.ts +43 -0
- package/dist/http/history-actions.js +122 -0
- package/dist/http/publish-actions.d.ts +11 -0
- package/dist/http/publish-actions.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +12 -26
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -67,14 +67,33 @@ export type CmsPublishResult = void | {
|
|
|
67
67
|
export interface CmsMediaItem {
|
|
68
68
|
/** Stable id in the source store. Used as the picker's selection key. */
|
|
69
69
|
id: string;
|
|
70
|
+
/**
|
|
71
|
+
* What this asset is.
|
|
72
|
+
*
|
|
73
|
+
* Absent means `"image"`, because every adapter written before documents
|
|
74
|
+
* existed returns images and must keep working unchanged. `"file"` is a
|
|
75
|
+
* document — a menu PDF, a price list, a consent form — which the editor
|
|
76
|
+
* offers into a `file` field or a prose link rather than an `<img>`.
|
|
77
|
+
*/
|
|
78
|
+
kind?: "image" | "file";
|
|
70
79
|
/** Human-facing filename or title, if the store has one. */
|
|
71
80
|
name?: string;
|
|
72
81
|
/** Full-size URL to insert into the page. Omitted only if the store has none. */
|
|
73
82
|
imageUrl?: string;
|
|
74
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Grid thumbnail. May be the same URL as `imageUrl` when no derivative
|
|
85
|
+
* exists. A `file` has nothing to show, so this is `""` for one and the
|
|
86
|
+
* picker draws an icon from `contentType` instead.
|
|
87
|
+
*/
|
|
75
88
|
thumbUrl: string;
|
|
76
89
|
/** Alt text the store already holds, so the editor does not invent one. */
|
|
77
90
|
alt?: string;
|
|
91
|
+
/** For `kind: "file"`: the URL to link to. */
|
|
92
|
+
url?: string;
|
|
93
|
+
/** MIME type, when the store knows it. Drives the picker's icon and filter. */
|
|
94
|
+
contentType?: string;
|
|
95
|
+
/** Bytes, when the store knows it. Shown to a person; nothing decides on it. */
|
|
96
|
+
size?: number;
|
|
78
97
|
}
|
|
79
98
|
/** One page of a media-library search. */
|
|
80
99
|
export interface CmsMediaQuery {
|
|
@@ -84,6 +103,16 @@ export interface CmsMediaQuery {
|
|
|
84
103
|
page: number;
|
|
85
104
|
/** Items per page. */
|
|
86
105
|
limit: number;
|
|
106
|
+
/**
|
|
107
|
+
* Which kind the caller wants.
|
|
108
|
+
*
|
|
109
|
+
* Absent means "images", which is what every existing caller meant and every
|
|
110
|
+
* existing adapter answers. An adapter that does not understand this field
|
|
111
|
+
* returns images to a request for documents; the route filters the response
|
|
112
|
+
* by `kind` as well, so an old adapter degrades to an empty document tab
|
|
113
|
+
* rather than to a grid of images offered as PDFs.
|
|
114
|
+
*/
|
|
115
|
+
kind?: "image" | "file";
|
|
87
116
|
}
|
|
88
117
|
/** What `getMedia` returns: the page, and how many there are in total. */
|
|
89
118
|
export interface CmsMediaPage {
|
|
@@ -200,6 +229,43 @@ export interface CmsAdapter {
|
|
|
200
229
|
* requirement, and nothing in the route path knows it exists.
|
|
201
230
|
*/
|
|
202
231
|
getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
|
|
232
|
+
/**
|
|
233
|
+
* Optional media *write*, the other half of `getMedia`.
|
|
234
|
+
*
|
|
235
|
+
* Somebody has to be able to add a document, and until this existed the only
|
|
236
|
+
* way was to put the file on disk yourself and commit it — which is not a
|
|
237
|
+
* path a non-technical editor has, and is the whole reason the editor could
|
|
238
|
+
* list a site's PDFs but never gain one.
|
|
239
|
+
*
|
|
240
|
+
* The adapter decides where a file goes, for the same reason it decides where
|
|
241
|
+
* pages come from: we do not know. A static site writes into `public/`; a
|
|
242
|
+
* Sanity site uploads an asset and gets a CDN URL; a site on a read-only
|
|
243
|
+
* filesystem implements nothing and the editor offers no upload button.
|
|
244
|
+
* Owning storage here instead would mean owning it *badly* — the existing
|
|
245
|
+
* `POST /image/upload` writes to the orchestrator's local disk, which on an
|
|
246
|
+
* ephemeral container loses every upload on redeploy, and building the
|
|
247
|
+
* document path on that foundation would spread the defect rather than fix
|
|
248
|
+
* it.
|
|
249
|
+
*
|
|
250
|
+
* Implement it and the editor offers upload. Leave it off and it does not —
|
|
251
|
+
* silence means no, as everywhere else on this interface.
|
|
252
|
+
*
|
|
253
|
+
* Throwing rejects the upload with the thrown message shown to the editor, so
|
|
254
|
+
* a refusal ("we only accept PDFs", "that name is taken") is expressible
|
|
255
|
+
* without a second return shape.
|
|
256
|
+
*/
|
|
257
|
+
uploadMedia?(input: CmsMediaUpload): Promise<CmsMediaItem>;
|
|
258
|
+
}
|
|
259
|
+
/** One file on its way in. */
|
|
260
|
+
export interface CmsMediaUpload {
|
|
261
|
+
/** Original filename as the browser reported it. Treat as untrusted. */
|
|
262
|
+
filename: string;
|
|
263
|
+
/** MIME type the browser reported, or "" — also untrusted. */
|
|
264
|
+
contentType: string;
|
|
265
|
+
/** The bytes. */
|
|
266
|
+
data: Uint8Array;
|
|
267
|
+
/** What the caller says this is. The adapter may disagree and throw. */
|
|
268
|
+
kind: "image" | "file";
|
|
203
269
|
}
|
|
204
270
|
/**
|
|
205
271
|
* Per-operation capability declaration. Every field is tri-state, and the
|
|
@@ -266,6 +332,13 @@ export interface ResolvedCapabilities {
|
|
|
266
332
|
* opens onto a method nobody implemented is a worse answer than no tab.
|
|
267
333
|
*/
|
|
268
334
|
readsMedia: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* Whether this site can take a new file — derived from `uploadMedia`, the
|
|
337
|
+
* same way `readsMedia` is derived from `getMedia`. The editor offers an
|
|
338
|
+
* upload control only where there is something behind it; a button that
|
|
339
|
+
* always fails is worse than no button.
|
|
340
|
+
*/
|
|
341
|
+
writesMedia: boolean;
|
|
269
342
|
/**
|
|
270
343
|
* The subset the adapter actually declared. A caller that needs to
|
|
271
344
|
* distinguish "this site says no" from "nobody said" reads this; everything
|
package/dist/cms/adapter.js
CHANGED
|
@@ -15,6 +15,7 @@ export function resolveCapabilities(adapter, override) {
|
|
|
15
15
|
// `=== true`, not truthiness: an adapter that says nothing has not said yes.
|
|
16
16
|
readsDraftPerspective: adapter?.perspectives === true,
|
|
17
17
|
readsMedia: typeof adapter?.getMedia === "function",
|
|
18
|
+
writesMedia: typeof adapter?.uploadMedia === "function",
|
|
18
19
|
declared
|
|
19
20
|
};
|
|
20
21
|
}
|
package/dist/cms/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { resolveCapabilities } from "./adapter.ts";
|
|
|
3
3
|
export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
|
|
4
4
|
export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
|
|
5
5
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
|
|
6
|
-
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
|
|
6
|
+
export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./media-sources.ts";
|
package/dist/cms/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
|
|
|
2
2
|
export { jsonFileAdapter } from "./json-file-adapter.js";
|
|
3
3
|
export { editorApiAdapter } from "./editor-api-adapter.js";
|
|
4
4
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
|
|
5
|
-
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
5
|
+
export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
|
|
1
|
+
import type { CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload } from "./adapter.ts";
|
|
2
2
|
/** Connection details for one of the built-in media readers. */
|
|
3
3
|
export type CmsMediaSourceConfig = {
|
|
4
4
|
provider: "contentful";
|
|
@@ -17,6 +17,8 @@ export type CmsMediaSourceConfig = {
|
|
|
17
17
|
};
|
|
18
18
|
/** A media reader: the exact shape of `CmsAdapter.getMedia`. */
|
|
19
19
|
export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
|
|
20
|
+
/** A media writer: the exact shape of `CmsAdapter.uploadMedia`. */
|
|
21
|
+
export type CmsMediaUploader = (input: CmsMediaUpload) => Promise<CmsMediaItem>;
|
|
20
22
|
/** Display name for a provider id, for the picker's tab. */
|
|
21
23
|
export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
|
|
22
24
|
/**
|
|
@@ -37,6 +39,32 @@ export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]
|
|
|
37
39
|
* promise inside a modal is not.
|
|
38
40
|
*/
|
|
39
41
|
export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
|
|
42
|
+
/**
|
|
43
|
+
* Build a media writer from the same connection details as the reader, or
|
|
44
|
+
* `null` when this provider (or this configuration) cannot take an upload.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
|
|
48
|
+
* return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Null rather than a stub that throws, and spread rather than assigned,
|
|
52
|
+
* because `writesMedia` is derived from whether the method *exists*
|
|
53
|
+
* (`resolveCapabilities`) and the editor shows its upload control on the
|
|
54
|
+
* strength of that. A method that is always present and always fails is a
|
|
55
|
+
* button that is always there and never works.
|
|
56
|
+
*
|
|
57
|
+
* So a project with a read-only token gets no upload control at all, which is
|
|
58
|
+
* the truth about it: Sanity's query API answers a viewer token and its asset
|
|
59
|
+
* API does not, and that difference is invisible until somebody picks a file.
|
|
60
|
+
*
|
|
61
|
+
* **Sanity only, for now.** Uploading to Contentful is a three-step
|
|
62
|
+
* asynchronous dance — create the asset, ask for processing, poll, publish —
|
|
63
|
+
* and Strapi's is a multipart POST to its upload plugin; neither is written
|
|
64
|
+
* yet, and both answer `null` so that an adapter which spreads the result
|
|
65
|
+
* simply offers no upload rather than offering a broken one.
|
|
66
|
+
*/
|
|
67
|
+
export declare function cmsMediaUploader(config: CmsMediaSourceConfig): CmsMediaUploader | null;
|
|
40
68
|
/**
|
|
41
69
|
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
42
70
|
* three shapes.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Sanity's dated API: one version for both the query and the asset endpoints. */
|
|
2
|
+
const SANITY_API_VERSION = "v2024-01-01";
|
|
1
3
|
const LABELS = {
|
|
2
4
|
contentful: "Contentful",
|
|
3
5
|
sanity: "Sanity",
|
|
@@ -69,20 +71,33 @@ async function contentfulMedia(config, { query, page, limit }) {
|
|
|
69
71
|
// ---------------------------------------------------------------------------
|
|
70
72
|
// Sanity — GROQ, slice paging, a second query for the count.
|
|
71
73
|
// ---------------------------------------------------------------------------
|
|
72
|
-
async function sanityMedia(config, { query, page, limit }) {
|
|
74
|
+
async function sanityMedia(config, { query, page, limit, kind }) {
|
|
73
75
|
const label = LABELS.sanity;
|
|
74
76
|
const dataset = config.dataset ?? "production";
|
|
75
77
|
const offset = (page - 1) * limit;
|
|
76
78
|
const end = offset + limit - 1;
|
|
79
|
+
const wantsFiles = kind === "file";
|
|
80
|
+
/*
|
|
81
|
+
* Sanity keeps documents in a second asset type. A PDF uploaded through the
|
|
82
|
+
* Studio — or through `cmsMediaUploader` below — is a `sanity.fileAsset`,
|
|
83
|
+
* and asking for `sanity.imageAsset` finds none of them however many the
|
|
84
|
+
* project holds. Before this branch existed, a request for documents was
|
|
85
|
+
* answered with the image list, and the route's own kind filter then dropped
|
|
86
|
+
* every item: an empty Documents tab on a project full of menus.
|
|
87
|
+
*/
|
|
88
|
+
const assetType = wantsFiles ? "sanity.fileAsset" : "sanity.imageAsset";
|
|
77
89
|
// GROQ is interpolated, so the filter term is stripped of the two characters
|
|
78
90
|
// that could close the string literal it lands inside.
|
|
79
91
|
const safeQuery = (query ?? "").replace(/["\\]/g, "");
|
|
80
92
|
const filter = safeQuery
|
|
81
|
-
? `_type == "
|
|
82
|
-
: `_type == "
|
|
83
|
-
const
|
|
93
|
+
? `_type == "${assetType}" && originalFilename match "*${safeQuery}*"`
|
|
94
|
+
: `_type == "${assetType}"`;
|
|
95
|
+
const projection = wantsFiles
|
|
96
|
+
? `{ _id, url, originalFilename, mimeType, size }`
|
|
97
|
+
: `{ _id, url, originalFilename, metadata { dimensions } }`;
|
|
98
|
+
const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] ${projection}`;
|
|
84
99
|
const countGroq = `count(*[${filter}])`;
|
|
85
|
-
const base = `https://${config.projectId}.api.sanity.io/
|
|
100
|
+
const base = `https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/data/query/${dataset}`;
|
|
86
101
|
const headers = {};
|
|
87
102
|
if (config.token)
|
|
88
103
|
headers.authorization = `Bearer ${config.token}`;
|
|
@@ -95,17 +110,37 @@ async function sanityMedia(config, { query, page, limit }) {
|
|
|
95
110
|
const assets = (await assetsRes.json());
|
|
96
111
|
const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
|
|
97
112
|
return {
|
|
98
|
-
items: (assets.result ?? []).map((asset) => ({
|
|
113
|
+
items: (assets.result ?? []).map((asset) => wantsFiles ? sanityFileItem(asset) : {
|
|
99
114
|
id: asset._id,
|
|
100
115
|
name: asset.originalFilename,
|
|
101
116
|
alt: asset.originalFilename,
|
|
102
117
|
imageUrl: asset.url,
|
|
103
118
|
thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
|
|
104
|
-
})
|
|
119
|
+
}),
|
|
105
120
|
totalPages: Math.ceil(count / limit),
|
|
106
121
|
label
|
|
107
122
|
};
|
|
108
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* One `sanity.fileAsset` as the picker's document tab renders it.
|
|
126
|
+
*
|
|
127
|
+
* `url` rather than `imageUrl`, because this is a link target and not something
|
|
128
|
+
* to draw, and `thumbUrl: ""` for the same reason — the picker draws an icon
|
|
129
|
+
* from `contentType` when there is nothing to show. The URL is Sanity's own
|
|
130
|
+
* `cdn.sanity.io/files/…` address: durable, CDN-served, and unchanged by any
|
|
131
|
+
* redeploy of the site that links to it.
|
|
132
|
+
*/
|
|
133
|
+
function sanityFileItem(asset) {
|
|
134
|
+
return {
|
|
135
|
+
id: asset._id,
|
|
136
|
+
kind: "file",
|
|
137
|
+
name: asset.originalFilename,
|
|
138
|
+
url: asset.url,
|
|
139
|
+
thumbUrl: "",
|
|
140
|
+
contentType: asset.mimeType ?? "application/octet-stream",
|
|
141
|
+
...(typeof asset.size === "number" ? { size: asset.size } : {})
|
|
142
|
+
};
|
|
143
|
+
}
|
|
109
144
|
// ---------------------------------------------------------------------------
|
|
110
145
|
// Strapi — upload plugin, page/pageSize paging, count in a response header.
|
|
111
146
|
// ---------------------------------------------------------------------------
|
|
@@ -142,6 +177,152 @@ async function strapiMedia(config, { query, page, limit }) {
|
|
|
142
177
|
label
|
|
143
178
|
};
|
|
144
179
|
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Adding one — the write half, for the CMSes whose asset API is a single call.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Build a media writer from the same connection details as the reader, or
|
|
185
|
+
* `null` when this provider (or this configuration) cannot take an upload.
|
|
186
|
+
*
|
|
187
|
+
* ```ts
|
|
188
|
+
* const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
|
|
189
|
+
* return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* Null rather than a stub that throws, and spread rather than assigned,
|
|
193
|
+
* because `writesMedia` is derived from whether the method *exists*
|
|
194
|
+
* (`resolveCapabilities`) and the editor shows its upload control on the
|
|
195
|
+
* strength of that. A method that is always present and always fails is a
|
|
196
|
+
* button that is always there and never works.
|
|
197
|
+
*
|
|
198
|
+
* So a project with a read-only token gets no upload control at all, which is
|
|
199
|
+
* the truth about it: Sanity's query API answers a viewer token and its asset
|
|
200
|
+
* API does not, and that difference is invisible until somebody picks a file.
|
|
201
|
+
*
|
|
202
|
+
* **Sanity only, for now.** Uploading to Contentful is a three-step
|
|
203
|
+
* asynchronous dance — create the asset, ask for processing, poll, publish —
|
|
204
|
+
* and Strapi's is a multipart POST to its upload plugin; neither is written
|
|
205
|
+
* yet, and both answer `null` so that an adapter which spreads the result
|
|
206
|
+
* simply offers no upload rather than offering a broken one.
|
|
207
|
+
*/
|
|
208
|
+
export function cmsMediaUploader(config) {
|
|
209
|
+
if (config.provider !== "sanity")
|
|
210
|
+
return null;
|
|
211
|
+
const token = config.token?.trim();
|
|
212
|
+
if (!token)
|
|
213
|
+
return null;
|
|
214
|
+
return (input) => sanityUpload({ ...config, token }, input);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* MIME types for the document extensions a site is likely to be handed, used
|
|
218
|
+
* only when the browser reported none.
|
|
219
|
+
*
|
|
220
|
+
* Sanity sniffs the bytes and would usually get there on its own; sending
|
|
221
|
+
* `application/octet-stream` for a PDF, though, is how an asset ends up in the
|
|
222
|
+
* library with a type the picker cannot draw an icon for.
|
|
223
|
+
*/
|
|
224
|
+
const UPLOAD_MIME_BY_EXT = {
|
|
225
|
+
pdf: "application/pdf",
|
|
226
|
+
doc: "application/msword",
|
|
227
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
228
|
+
xls: "application/vnd.ms-excel",
|
|
229
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
230
|
+
csv: "text/csv",
|
|
231
|
+
txt: "text/plain",
|
|
232
|
+
png: "image/png",
|
|
233
|
+
jpg: "image/jpeg",
|
|
234
|
+
jpeg: "image/jpeg",
|
|
235
|
+
webp: "image/webp",
|
|
236
|
+
gif: "image/gif",
|
|
237
|
+
svg: "image/svg+xml"
|
|
238
|
+
};
|
|
239
|
+
function uploadContentType(input) {
|
|
240
|
+
const declared = input.contentType.trim();
|
|
241
|
+
if (declared)
|
|
242
|
+
return declared;
|
|
243
|
+
const ext = input.filename.toLowerCase().split(".").pop() ?? "";
|
|
244
|
+
return UPLOAD_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The filename Sanity records as `originalFilename`.
|
|
248
|
+
*
|
|
249
|
+
* It is not a path — the asset's own URL is derived from a content hash — so
|
|
250
|
+
* this strips directory segments and control characters and otherwise leaves
|
|
251
|
+
* the name a person chose intact, accents and spaces included. The picker
|
|
252
|
+
* shows this string, and "Menükarte Winter 2026.pdf" is what makes a document
|
|
253
|
+
* recognisable in a list of twenty.
|
|
254
|
+
*/
|
|
255
|
+
function uploadFilename(raw) {
|
|
256
|
+
const base = raw.split(/[\\/]/).pop() ?? "";
|
|
257
|
+
// Control characters, written as escapes: a literal one in the source is
|
|
258
|
+
// invisible to every reader and turns the file binary to `grep`.
|
|
259
|
+
return base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 120);
|
|
260
|
+
}
|
|
261
|
+
async function sanityUpload(config, input) {
|
|
262
|
+
const dataset = config.dataset ?? "production";
|
|
263
|
+
/*
|
|
264
|
+
* Two buckets, and picking the wrong one is not a detail: `images` runs the
|
|
265
|
+
* bytes through image processing and refuses anything that is not a picture,
|
|
266
|
+
* which is exactly the refusal a PDF sent there produces. `kind` is what the
|
|
267
|
+
* editor said it was uploading, so it decides.
|
|
268
|
+
*/
|
|
269
|
+
const bucket = input.kind === "file" ? "files" : "images";
|
|
270
|
+
const filename = uploadFilename(input.filename);
|
|
271
|
+
const params = new URLSearchParams();
|
|
272
|
+
if (filename)
|
|
273
|
+
params.set("filename", filename);
|
|
274
|
+
const res = await fetch(`https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/assets/${bucket}/${dataset}?${params}`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${config.token}`,
|
|
278
|
+
"content-type": uploadContentType(input)
|
|
279
|
+
},
|
|
280
|
+
body: new Uint8Array(input.data)
|
|
281
|
+
});
|
|
282
|
+
/*
|
|
283
|
+
* Throwing is the contract's way of refusing an upload, and the message
|
|
284
|
+
* reaches the person who picked the file — so it has to say what Sanity
|
|
285
|
+
* said, not that something went wrong.
|
|
286
|
+
*/
|
|
287
|
+
if (!res.ok)
|
|
288
|
+
throw new Error(await sanityUploadError(res));
|
|
289
|
+
const body = (await res.json());
|
|
290
|
+
const doc = body.document;
|
|
291
|
+
if (!doc?._id || !doc.url) {
|
|
292
|
+
throw new Error("Sanity accepted the upload but returned no asset.");
|
|
293
|
+
}
|
|
294
|
+
if (input.kind === "file") {
|
|
295
|
+
return sanityFileItem({ _id: doc._id, url: doc.url, originalFilename: doc.originalFilename, mimeType: doc.mimeType, size: doc.size });
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
id: doc._id,
|
|
299
|
+
name: doc.originalFilename,
|
|
300
|
+
alt: doc.originalFilename,
|
|
301
|
+
imageUrl: doc.url,
|
|
302
|
+
thumbUrl: `${doc.url}?w=200&h=200&fit=crop`
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function sanityUploadError(res) {
|
|
306
|
+
/*
|
|
307
|
+
* 401/403 has one overwhelmingly likely cause and it is not obvious from
|
|
308
|
+
* Sanity's own wording: the token reads fine — the picker's grid is full of
|
|
309
|
+
* images, which is what makes it confusing — and simply has no write grant.
|
|
310
|
+
*/
|
|
311
|
+
const suffix = res.status === 401 || res.status === 403
|
|
312
|
+
? " The API token needs write access to this dataset; a read token fills the picker but cannot upload."
|
|
313
|
+
: "";
|
|
314
|
+
const fallback = `Sanity refused the upload (${res.status}).${suffix}`;
|
|
315
|
+
try {
|
|
316
|
+
const body = (await res.json());
|
|
317
|
+
const described = typeof body.error === "string"
|
|
318
|
+
? body.error
|
|
319
|
+
: body.error?.description ?? body.error?.message ?? body.message;
|
|
320
|
+
return described ? `Sanity refused the upload: ${described}${suffix}` : fallback;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return fallback;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
145
326
|
/**
|
|
146
327
|
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
147
328
|
* three shapes.
|