@vespercms/admin 0.0.0-stage → 0.1.0-next.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.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,302 @@
1
+ import { extensionOf, isImagePath, type MediaItem } from '@vespercms/core';
2
+ import { Button, Dialog, Icon, TextField } from '@vespercms/ui';
3
+ import { useMemo, useRef, useState } from 'react';
4
+ import { FileImage } from '../app/file-url.tsx';
5
+ import { useStudio } from '../app/context.tsx';
6
+ import { useLoad } from '../app/load.ts';
7
+ import { fileSize, megabytes } from './hygiene.ts';
8
+ import { fileIcon } from './file-icon.ts';
9
+ import { checkUpload, sendUpload, type UploadStep } from './upload.ts';
10
+
11
+ export type MediaPickerProps = {
12
+ isOpen: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ kind: 'image' | 'file';
15
+ /** File extensions a file field accepts, lower case without dots. Empty for any. */
16
+ extensions?: readonly string[];
17
+ /** Where a new upload goes, site-relative. */
18
+ folder: string;
19
+ /** Called with the chosen file's site-relative path, and what hygiene did to a new upload. */
20
+ onChoose: (path: string, notes?: readonly string[]) => void;
21
+ };
22
+
23
+ /**
24
+ * Choose a file: upload a new one (after hygiene and a duplicate check) or
25
+ * pick one already in the site. One dialog, two plain sections.
26
+ */
27
+ export function MediaPicker({
28
+ isOpen,
29
+ onOpenChange,
30
+ kind,
31
+ extensions = [],
32
+ folder,
33
+ onChoose,
34
+ }: MediaPickerProps) {
35
+ return (
36
+ <Dialog
37
+ title={kind === 'image' ? 'Choose an image' : 'Choose a file'}
38
+ isOpen={isOpen}
39
+ onOpenChange={onOpenChange}
40
+ >
41
+ {isOpen && (
42
+ <PickerBody
43
+ kind={kind}
44
+ extensions={extensions}
45
+ folder={folder}
46
+ onChoose={(path, notes) => {
47
+ onChoose(path, notes);
48
+ onOpenChange(false);
49
+ }}
50
+ />
51
+ )}
52
+ </Dialog>
53
+ );
54
+ }
55
+
56
+ function PickerBody({
57
+ kind,
58
+ extensions = [],
59
+ folder,
60
+ onChoose,
61
+ }: Pick<MediaPickerProps, 'kind' | 'extensions' | 'folder' | 'onChoose'>) {
62
+ const { api, siteName, manifest } = useStudio();
63
+ const maxEdge = manifest.site.images?.maxUploadEdge;
64
+ const [media, reloadMedia] = useLoad(() => api.listMedia(), 'media');
65
+ const [step, setStep] = useState<UploadStep | { step: 'working'; text: string } | null>(null);
66
+ const [query, setQuery] = useState('');
67
+ const input = useRef<HTMLInputElement>(null);
68
+
69
+ const fits = (path: string) =>
70
+ kind === 'image'
71
+ ? isImagePath(path)
72
+ : extensions.length === 0 || extensions.includes(extensionOf(path));
73
+ const items = useMemo(() => {
74
+ const needle = query.trim().toLowerCase();
75
+ return (media.state === 'ready' ? media.value : [])
76
+ .filter((item) => fits(item.path))
77
+ .filter((item) => needle === '' || item.path.toLowerCase().includes(needle));
78
+ // `fits` only reads props that key this body.
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [media, query]);
81
+
82
+ const upload = async (ready: Extract<UploadStep, { upload: unknown }>) => {
83
+ setStep({ step: 'working', text: 'Uploading…' });
84
+ // Chosen for a field: in GitHub mode it goes out with the entry's next publish.
85
+ const result = await sendUpload(api, ready.upload, folder, { withNextSave: true });
86
+ if (result.ok) {
87
+ reloadMedia();
88
+ onChoose(result.path, ready.upload.notes);
89
+ } else {
90
+ setStep({ step: 'refused', message: result.message });
91
+ }
92
+ };
93
+
94
+ const accept =
95
+ kind === 'image'
96
+ ? 'image/*,.heic,.heif'
97
+ : extensions.length > 0
98
+ ? extensions.map((e) => `.${e}`).join(',')
99
+ : undefined;
100
+
101
+ return (
102
+ <div className="flex flex-col gap-6">
103
+ <section aria-labelledby="picker-upload" className="flex flex-col gap-3">
104
+ <h3 id="picker-upload" className="text-heading-3">
105
+ Upload from your computer
106
+ </h3>
107
+ <p className="text-small text-content-muted">
108
+ {kind === 'image'
109
+ ? `Large photos are scaled down, turned upright, and stripped of location data before they’re added to ${siteName}.`
110
+ : `The file is added to ${folder}.`}
111
+ </p>
112
+ <input
113
+ ref={input}
114
+ type="file"
115
+ accept={accept}
116
+ tabIndex={-1}
117
+ aria-hidden="true"
118
+ className="sr-only"
119
+ onChange={(event) => {
120
+ const file = event.target.files?.[0];
121
+ event.target.value = '';
122
+ if (!file) return;
123
+ setStep({ step: 'working', text: `Preparing ${file.name}…` });
124
+ void checkUpload(file, media.state === 'ready' ? media.value : [], {
125
+ accept: kind === 'image' ? 'image' : 'any',
126
+ ...(maxEdge ? { maxEdge } : {}),
127
+ }).then((next) => {
128
+ if (next.step === 'ready') void upload(next);
129
+ else setStep(next);
130
+ });
131
+ }}
132
+ />
133
+ <div>
134
+ <Button
135
+ icon="upload"
136
+ onPress={() => {
137
+ input.current?.click();
138
+ }}
139
+ >
140
+ {kind === 'image' ? 'Upload an image' : 'Upload a file'}
141
+ </Button>
142
+ </div>
143
+ <UploadStatus
144
+ step={step}
145
+ onUpload={(ready) => void upload(ready)}
146
+ onReuse={onChoose}
147
+ onCancel={() => {
148
+ setStep(null);
149
+ }}
150
+ />
151
+ </section>
152
+
153
+ <section aria-labelledby="picker-existing" className="flex flex-col gap-3">
154
+ <h3 id="picker-existing" className="text-heading-3">
155
+ Or choose one already in the site
156
+ </h3>
157
+ <TextField
158
+ label="Search by file name"
159
+ type="search"
160
+ icon="search"
161
+ value={query}
162
+ onChange={setQuery}
163
+ />
164
+ {media.state === 'loading' && (
165
+ <p className="text-small text-content-muted">Loading files…</p>
166
+ )}
167
+ {media.state === 'failed' && (
168
+ <p role="alert" className="text-danger">
169
+ Couldn’t list files: {media.message}
170
+ </p>
171
+ )}
172
+ {media.state === 'ready' && items.length === 0 && (
173
+ <p className="text-content-muted">No {kind === 'image' ? 'images' : 'files'} found.</p>
174
+ )}
175
+ <ul className="grid max-h-80 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2">
176
+ {items.map((item) => (
177
+ <li key={item.path}>
178
+ <MediaChoice item={item} onChoose={onChoose} />
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ </section>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function MediaChoice({ item, onChoose }: { item: MediaItem; onChoose: (path: string) => void }) {
188
+ const name = item.path.slice(item.path.lastIndexOf('/') + 1);
189
+ return (
190
+ <button
191
+ type="button"
192
+ onClick={() => {
193
+ onChoose(item.path);
194
+ }}
195
+ className="flex w-full items-center gap-3 rounded-md border border-border p-2 text-left hover:bg-surface-sunken focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
196
+ >
197
+ {isImagePath(item.path) ? (
198
+ <FileImage
199
+ path={item.path}
200
+ loading="lazy"
201
+ className="size-12 shrink-0 rounded-md object-cover"
202
+ />
203
+ ) : (
204
+ <span
205
+ aria-hidden="true"
206
+ className="flex size-12 shrink-0 items-center justify-center rounded-md bg-surface-sunken text-content-muted"
207
+ >
208
+ <Icon name={fileIcon(extensionOf(item.path))} size={24} />
209
+ </span>
210
+ )}
211
+ <span className="min-w-0">
212
+ <span className="block truncate font-semibold">{name}</span>
213
+ <span className="block font-mono text-caption break-all text-content-muted">
214
+ {item.path.slice(0, item.path.lastIndexOf('/'))} · {fileSize(item.size)}
215
+ </span>
216
+ </span>
217
+ </button>
218
+ );
219
+ }
220
+
221
+ export function UploadStatus({
222
+ step,
223
+ onUpload,
224
+ onReuse,
225
+ onCancel,
226
+ }: {
227
+ step: UploadStep | { step: 'working'; text: string } | null;
228
+ onUpload: (ready: Extract<UploadStep, { upload: unknown }>) => void;
229
+ onReuse: (path: string) => void;
230
+ onCancel: () => void;
231
+ }) {
232
+ if (!step) return null;
233
+ if (step.step === 'working') return <p role="status">{step.text}</p>;
234
+ if (step.step === 'refused') {
235
+ return (
236
+ <p role="alert" className="text-danger">
237
+ {step.message}
238
+ </p>
239
+ );
240
+ }
241
+ const notes = step.upload.notes.length > 0 && (
242
+ <ul className="list-disc pl-6 text-small text-content-muted">
243
+ {step.upload.notes.map((note) => (
244
+ <li key={note}>{note}</li>
245
+ ))}
246
+ </ul>
247
+ );
248
+ if (step.step === 'duplicate') {
249
+ return (
250
+ <div
251
+ role="alert"
252
+ className="flex flex-col gap-3 rounded-md border border-warning bg-surface-raised p-4"
253
+ >
254
+ <p>This file is already in the site as {step.existing}.</p>
255
+ <div className="flex flex-wrap gap-3">
256
+ <Button
257
+ onPress={() => {
258
+ onReuse(step.existing);
259
+ }}
260
+ >
261
+ Use the existing file
262
+ </Button>
263
+ <Button
264
+ variant="quiet"
265
+ onPress={() => {
266
+ onUpload(step);
267
+ }}
268
+ >
269
+ Upload a copy anyway
270
+ </Button>
271
+ </div>
272
+ </div>
273
+ );
274
+ }
275
+ if (step.step === 'confirm-large') {
276
+ return (
277
+ <div
278
+ role="alert"
279
+ className="flex flex-col gap-3 rounded-md border border-warning bg-surface-raised p-4"
280
+ >
281
+ <p>
282
+ {step.upload.name} is {megabytes(step.upload.bytes.length)}. Large files make the site’s
283
+ repository and pages heavier. Upload it anyway?
284
+ </p>
285
+ {notes}
286
+ <div className="flex flex-wrap gap-3">
287
+ <Button
288
+ onPress={() => {
289
+ onUpload(step);
290
+ }}
291
+ >
292
+ Upload it
293
+ </Button>
294
+ <Button variant="quiet" onPress={onCancel}>
295
+ Cancel
296
+ </Button>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
301
+ return null;
302
+ }
@@ -0,0 +1,17 @@
1
+ import type { IconName } from '@vespercms/ui';
2
+
3
+ const KINDS: [IconName, string[]][] = [
4
+ [
5
+ 'file-text',
6
+ ['pdf', 'doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'mdx', 'mdoc', 'ppt', 'pptx', 'key'],
7
+ ],
8
+ ['file-sheet', ['csv', 'tsv', 'xls', 'xlsx', 'ods', 'numbers']],
9
+ ['file-archive', ['zip', 'gz', 'tgz', 'tar', '7z', 'rar']],
10
+ ['file-video', ['mp4', 'm4v', 'mov', 'webm', 'ogv']],
11
+ ['file-audio', ['mp3', 'm4a', 'wav', 'ogg', 'oga', 'flac', 'aac']],
12
+ ];
13
+
14
+ /** The icon for a file that isn't an image, by its extension; a plain page when unknown. */
15
+ export function fileIcon(extension: string): IconName {
16
+ return KINDS.find(([, extensions]) => extensions.includes(extension))?.[0] ?? 'file';
17
+ }
@@ -0,0 +1,262 @@
1
+ /*
2
+ * Just enough of each image format to make upload hygiene decisions without
3
+ * decoding pixels (spec §6.6.6): what a file really is, its size, its EXIF
4
+ * orientation, whether it carries location data, and how to drop metadata
5
+ * without re-encoding. Pure functions over bytes, so they're unit-tested.
6
+ */
7
+
8
+ export type DetectedType =
9
+ 'jpeg' | 'png' | 'gif' | 'webp' | 'avif' | 'heic' | 'svg' | 'pdf' | 'other';
10
+
11
+ const ascii = (bytes: Uint8Array, start: number, length: number) =>
12
+ String.fromCharCode(...bytes.subarray(start, start + length));
13
+
14
+ /** What a file is, from its first bytes rather than its name. */
15
+ export function detectType(bytes: Uint8Array): DetectedType {
16
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
17
+ if (ascii(bytes, 0, 8) === '\x89PNG\r\n\x1a\n') return 'png';
18
+ if (ascii(bytes, 0, 4) === 'GIF8') return 'gif';
19
+ if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP') return 'webp';
20
+ if (ascii(bytes, 0, 5) === '%PDF-') return 'pdf';
21
+ if (ascii(bytes, 4, 4) === 'ftyp') {
22
+ const size = view(bytes).getUint32(0);
23
+ const brands = [ascii(bytes, 8, 4)];
24
+ for (let at = 16; at + 4 <= Math.min(size, bytes.length); at += 4)
25
+ brands.push(ascii(bytes, at, 4));
26
+ if (brands.some((b) => b === 'avif' || b === 'avis')) return 'avif';
27
+ if (
28
+ brands.some((b) =>
29
+ ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'].includes(b),
30
+ )
31
+ ) {
32
+ return 'heic';
33
+ }
34
+ }
35
+ const head = new TextDecoder()
36
+ .decode(bytes.subarray(0, 1024))
37
+ .replace(/^\uFEFF/, '')
38
+ .trimStart();
39
+ if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(head))
40
+ return 'svg';
41
+ return 'other';
42
+ }
43
+
44
+ function view(bytes: Uint8Array): DataView {
45
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
46
+ }
47
+
48
+ export type ImageFacts = {
49
+ width?: number;
50
+ height?: number;
51
+ /** EXIF orientation, 1–8. 1 (or absent) means the pixels are already upright. */
52
+ orientation?: number;
53
+ /** EXIF GPS data is present. */
54
+ hasLocation: boolean;
55
+ /** Any metadata a lossless strip would remove. */
56
+ hasMetadata: boolean;
57
+ };
58
+
59
+ // --- JPEG -----------------------------------------------------------------
60
+
61
+ type Segment = { marker: number; start: number; end: number };
62
+
63
+ /** Segments before the image data (SOS), with their byte ranges. */
64
+ function jpegSegments(bytes: Uint8Array): { segments: Segment[]; dataStart: number } {
65
+ const segments: Segment[] = [];
66
+ let at = 2;
67
+ while (at + 4 <= bytes.length && bytes[at] === 0xff) {
68
+ const marker = bytes[at + 1] ?? 0;
69
+ if (marker === 0xff) {
70
+ at += 1; // fill byte
71
+ continue;
72
+ }
73
+ const length = view(bytes).getUint16(at + 2);
74
+ const end = at + 2 + length;
75
+ if (marker === 0xda) return { segments, dataStart: at };
76
+ segments.push({ marker, start: at, end });
77
+ at = end;
78
+ }
79
+ return { segments, dataStart: at };
80
+ }
81
+
82
+ /** Orientation and GPS presence from an EXIF (TIFF) block. */
83
+ function readExif(tiff: Uint8Array): { orientation?: number; hasLocation: boolean } {
84
+ if (tiff.length < 8) return { hasLocation: false };
85
+ const little = ascii(tiff, 0, 2) === 'II';
86
+ const data = view(tiff);
87
+ const u16 = (o: number) => data.getUint16(o, little);
88
+ const u32 = (o: number) => data.getUint32(o, little);
89
+ const ifd = u32(4);
90
+ if (ifd + 2 > tiff.length) return { hasLocation: false };
91
+ let orientation: number | undefined;
92
+ let hasLocation = false;
93
+ const count = u16(ifd);
94
+ for (let i = 0; i < count; i++) {
95
+ const entry = ifd + 2 + i * 12;
96
+ if (entry + 12 > tiff.length) break;
97
+ const tag = u16(entry);
98
+ if (tag === 0x0112) orientation = u16(entry + 8);
99
+ if (tag === 0x8825) hasLocation = true;
100
+ }
101
+ return { ...(orientation !== undefined ? { orientation } : {}), hasLocation };
102
+ }
103
+
104
+ const KEEP_APP = new Set([0xe0, 0xe2, 0xee]); // JFIF, ICC colour profile, Adobe colour transform
105
+
106
+ export function jpegFacts(bytes: Uint8Array): ImageFacts {
107
+ const facts: ImageFacts = { hasLocation: false, hasMetadata: false };
108
+ for (const s of jpegSegments(bytes).segments) {
109
+ const body = bytes.subarray(s.start + 4, s.end);
110
+ if (s.marker === 0xe1 && ascii(body, 0, 6) === 'Exif\0\0') {
111
+ Object.assign(facts, readExif(body.subarray(6)));
112
+ }
113
+ if ((s.marker >= 0xe1 && s.marker <= 0xef && !KEEP_APP.has(s.marker)) || s.marker === 0xfe) {
114
+ facts.hasMetadata = true;
115
+ }
116
+ // Start-of-frame markers carry the size (not DHT C4, JPG C8 or DAC CC).
117
+ if (s.marker >= 0xc0 && s.marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(s.marker)) {
118
+ facts.height = view(bytes).getUint16(s.start + 5);
119
+ facts.width = view(bytes).getUint16(s.start + 7);
120
+ }
121
+ }
122
+ return facts;
123
+ }
124
+
125
+ /** The same JPEG without EXIF, XMP, IPTC or comments. The image data is untouched. */
126
+ export function stripJpeg(bytes: Uint8Array): Uint8Array {
127
+ const { segments, dataStart } = jpegSegments(bytes);
128
+ const parts: Uint8Array[] = [bytes.subarray(0, 2)];
129
+ for (const s of segments) {
130
+ const metadata =
131
+ (s.marker >= 0xe1 && s.marker <= 0xef && !KEEP_APP.has(s.marker)) || s.marker === 0xfe;
132
+ if (!metadata) parts.push(bytes.subarray(s.start, s.end));
133
+ }
134
+ parts.push(bytes.subarray(dataStart));
135
+ return concat(parts);
136
+ }
137
+
138
+ // --- PNG ------------------------------------------------------------------
139
+
140
+ const PNG_METADATA = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']);
141
+
142
+ function pngChunks(bytes: Uint8Array): { type: string; start: number; end: number }[] {
143
+ const chunks: { type: string; start: number; end: number }[] = [];
144
+ let at = 8;
145
+ while (at + 12 <= bytes.length) {
146
+ const length = view(bytes).getUint32(at);
147
+ const type = ascii(bytes, at + 4, 4);
148
+ const end = at + 12 + length;
149
+ chunks.push({ type, start: at, end });
150
+ at = end;
151
+ if (type === 'IEND') break;
152
+ }
153
+ return chunks;
154
+ }
155
+
156
+ export function pngFacts(bytes: Uint8Array): ImageFacts {
157
+ const chunks = pngChunks(bytes);
158
+ const ihdr = chunks.find((c) => c.type === 'IHDR');
159
+ const exif = chunks.find((c) => c.type === 'eXIf');
160
+ return {
161
+ ...(ihdr
162
+ ? {
163
+ width: view(bytes).getUint32(ihdr.start + 8),
164
+ height: view(bytes).getUint32(ihdr.start + 12),
165
+ }
166
+ : {}),
167
+ ...(exif ? readExif(bytes.subarray(exif.start + 8, exif.end - 4)) : { hasLocation: false }),
168
+ hasMetadata: chunks.some((c) => PNG_METADATA.has(c.type)),
169
+ };
170
+ }
171
+
172
+ /** The same PNG without text, time or EXIF chunks. */
173
+ export function stripPng(bytes: Uint8Array): Uint8Array {
174
+ const parts = [bytes.subarray(0, 8)];
175
+ for (const c of pngChunks(bytes)) {
176
+ if (!PNG_METADATA.has(c.type)) parts.push(bytes.subarray(c.start, c.end));
177
+ }
178
+ return concat(parts);
179
+ }
180
+
181
+ // --- WebP -----------------------------------------------------------------
182
+
183
+ function webpChunks(bytes: Uint8Array): { type: string; start: number; end: number }[] {
184
+ const chunks: { type: string; start: number; end: number }[] = [];
185
+ let at = 12;
186
+ while (at + 8 <= bytes.length) {
187
+ const size = view(bytes).getUint32(at + 4, true);
188
+ const end = at + 8 + size + (size % 2);
189
+ chunks.push({ type: ascii(bytes, at, 4), start: at, end: Math.min(end, bytes.length) });
190
+ at = end;
191
+ }
192
+ return chunks;
193
+ }
194
+
195
+ export function webpFacts(bytes: Uint8Array): ImageFacts {
196
+ const chunks = webpChunks(bytes);
197
+ const data = view(bytes);
198
+ const facts: ImageFacts = {
199
+ hasLocation: false,
200
+ hasMetadata: chunks.some((c) => c.type === 'EXIF' || c.type === 'XMP '),
201
+ };
202
+ const exif = chunks.find((c) => c.type === 'EXIF');
203
+ if (exif) {
204
+ let tiff = bytes.subarray(exif.start + 8, exif.end);
205
+ if (ascii(tiff, 0, 6) === 'Exif\0\0') tiff = tiff.subarray(6);
206
+ Object.assign(facts, readExif(tiff));
207
+ }
208
+ for (const c of chunks) {
209
+ const d = c.start + 8;
210
+ if (c.type === 'VP8X') {
211
+ facts.width = 1 + (data.getUint32(d + 4, true) & 0xffffff);
212
+ facts.height = 1 + (data.getUint32(d + 7, true) & 0xffffff);
213
+ break;
214
+ }
215
+ if (c.type === 'VP8 ') {
216
+ facts.width = data.getUint16(d + 6, true) & 0x3fff;
217
+ facts.height = data.getUint16(d + 8, true) & 0x3fff;
218
+ break;
219
+ }
220
+ if (c.type === 'VP8L') {
221
+ const bits = data.getUint32(d + 1, true);
222
+ facts.width = 1 + (bits & 0x3fff);
223
+ facts.height = 1 + ((bits >> 14) & 0x3fff);
224
+ break;
225
+ }
226
+ }
227
+ return facts;
228
+ }
229
+
230
+ /** The same WebP without EXIF or XMP, with the header flags and size fixed up. */
231
+ export function stripWebp(bytes: Uint8Array): Uint8Array {
232
+ const parts: Uint8Array[] = [];
233
+ for (const c of webpChunks(bytes)) {
234
+ if (c.type === 'EXIF' || c.type === 'XMP ') continue;
235
+ const chunk = bytes.slice(c.start, c.end);
236
+ if (c.type === 'VP8X') chunk[8] = (chunk[8] ?? 0) & ~(0x08 | 0x04);
237
+ parts.push(chunk);
238
+ }
239
+ const body = concat(parts);
240
+ const out = new Uint8Array(12 + body.length);
241
+ out.set(bytes.subarray(0, 12));
242
+ out.set(body, 12);
243
+ new DataView(out.buffer).setUint32(4, out.length - 8, true);
244
+ return out;
245
+ }
246
+
247
+ export function imageFacts(type: DetectedType, bytes: Uint8Array): ImageFacts {
248
+ if (type === 'jpeg') return jpegFacts(bytes);
249
+ if (type === 'png') return pngFacts(bytes);
250
+ if (type === 'webp') return webpFacts(bytes);
251
+ return { hasLocation: false, hasMetadata: false };
252
+ }
253
+
254
+ function concat(parts: Uint8Array[]): Uint8Array {
255
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
256
+ let at = 0;
257
+ for (const part of parts) {
258
+ out.set(part, at);
259
+ at += part.length;
260
+ }
261
+ return out;
262
+ }