@taprootio/site-authoring 0.14.1 → 0.15.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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ VERB_APPLY,
2
3
  VERB_APPROVE,
3
4
  VERB_DELIVERY_CHECK,
4
5
  VERB_DEPLOY,
@@ -12,6 +13,9 @@ import {
12
13
  VERB_MEDIA_UPLOAD,
13
14
  VERB_NAV_PUSH,
14
15
  VERB_PAGES_PUSH,
16
+ VERB_PLACES_SEARCH,
17
+ VERB_PLACES_SELECT,
18
+ VERB_PLAN,
15
19
  VERB_PREVIEW_PAGE,
16
20
  VERB_PREVIEW_REVOKE,
17
21
  VERB_PULL,
@@ -39,6 +43,8 @@ import { logout } from "./logout.js";
39
43
  import { mediaUpload } from "./media-upload.js";
40
44
  import { navPush } from "./nav-push.js";
41
45
  import { pagesPush } from "./pages-push.js";
46
+ import { placesSearch, placesSelect } from "./places.js";
47
+ import { apply, plan } from "./plan.js";
42
48
  import { previewPage } from "./preview-page.js";
43
49
  import { previewRevoke } from "./preview-revoke.js";
44
50
  import { pull } from "./pull.js";
@@ -70,6 +76,7 @@ import { whoami } from "./whoami.js";
70
76
  * deployTarget, // "staging" | "production" for `deploy`, else undefined
71
77
  * quiet, // --quiet; suppresses progress, never the JSON result
72
78
  * keyName, // --name; `login` only, undefined everywhere else
79
+ * planHash, // --plan; `apply` only: the plan it may run
73
80
  * onProgress, // human progress sink (stderr, or a no-op when quiet)
74
81
  * fetch, // injectable fetch, for tests
75
82
  * signal, // optional AbortSignal
@@ -92,6 +99,8 @@ import { whoami } from "./whoami.js";
92
99
  * // with none, the workspace's media/ directory is walked
93
100
  * formKeys, // forms validate / push: narrow to these form keys;
94
101
  * // with none, every file under forms/
102
+ * placeQuery, // places search: the words of the query
103
+ * placeSelection, // places select: [googlePlaceId, sessionToken]
95
104
  * pagePaths, // approve: narrow to these page paths; with none, every
96
105
  * // draft the workspace manifest tracks is staged
97
106
  * pageId, // preview page: the one persisted draft UUID to render
@@ -127,6 +136,10 @@ export const VERB_HANDLERS = Object.freeze({
127
136
  [VERB_PULL]: pull,
128
137
  [VERB_PAGES_PUSH]: pagesPush,
129
138
  [VERB_NAV_PUSH]: navPush,
139
+ [VERB_PLAN]: plan,
140
+ [VERB_PLACES_SEARCH]: placesSearch,
141
+ [VERB_PLACES_SELECT]: placesSelect,
142
+ [VERB_APPLY]: apply,
130
143
  [VERB_REDIRECTS_CHECK]: redirectsCheck,
131
144
  [VERB_REDIRECTS_PULL]: redirectsPull,
132
145
  [VERB_REDIRECTS_PUSH]: redirectsPush,
@@ -17,10 +17,11 @@ import {
17
17
  import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
18
18
  import { SiteAuthoringError, sanitizeDiagnostic } from "../errors.js";
19
19
  import { contentHash, inspectImageBytes } from "../image-metadata.js";
20
+ import { CHECK_AREA, collectProblems, problemsOf } from "../problems.js";
20
21
  import { ApiError } from "../transport.js";
21
22
  import { sniffVideoContentType, VIDEO_EXTENSIONS, videoTooLarge } from "../video-metadata.js";
22
- import { mp4Name, prepareVideoFile } from "../video-prepare.js";
23
- import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
23
+ import { mp4Name, prepareVideoFile, videoAcceptance } from "../video-prepare.js";
24
+ import { boundedByBytes, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
24
25
  import {
25
26
  ensureWorkspaceRoot,
26
27
  inspectWorkspaceEntry,
@@ -30,6 +31,7 @@ import {
30
31
  openWorkspaceFileStream,
31
32
  readMediaManifest,
32
33
  readWorkspaceFile,
34
+ recordedAlt,
33
35
  resolveWorkspacePath,
34
36
  SAFE_MEDIA_SEGMENT,
35
37
  walkWorkspaceFiles,
@@ -72,7 +74,8 @@ import {
72
74
  * it for evidence of a pull that never happened.
73
75
  */
74
76
 
75
- const MAXIMUM_REPORTED = 200;
77
+ // Each list's share of the result's 64 KiB; the counts are always complete.
78
+ const ITEMS_BYTES = 24 * 1024;
76
79
  const MAXIMUM_FILES = 500;
77
80
  const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ...VIDEO_EXTENSIONS];
78
81
  const MEDIA_WALK_OPTIONS = Object.freeze({
@@ -80,6 +83,118 @@ const MEDIA_WALK_OPTIONS = Object.freeze({
80
83
  segmentDescription: "letters, digits, '.', '_', '-', and '@'",
81
84
  });
82
85
 
86
+ /**
87
+ * The files under `media/` an upload would still send (TR00823): an image
88
+ * whose bytes the media manifest does not record, and a video with no
89
+ * confirmed upload. Each is classified and checked against the limits the
90
+ * upload applies, so a plan does not call ready a file the upload would
91
+ * refuse, and carries an identity of its content, so a plan made before the
92
+ * file was replaced does not describe it. A recorded upload is done only when
93
+ * the site still holds it: an image processed (one still processing, or gone,
94
+ * is sent again; the upload deduplicates and waits; one that failed is a
95
+ * problem), a video in its library. `site` answers both:
96
+ * `imageStates` maps the recorded image ids to what the site holds, and
97
+ * `videos` is the video library as `listVideos` reads it. Read only.
98
+ */
99
+ export async function pendingMediaFiles(workspaceDir, mediaManifest, site) {
100
+ const files = await walkWorkspaceFiles(workspaceDir, MEDIA_DIRECTORY, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS);
101
+ const pending = [];
102
+ const problems = [];
103
+ const listedVideoIds = new Set(site.videos.videos.map((candidate) => candidate.videoId));
104
+ for (const file of files) {
105
+ const video = mediaManifest.videos?.[file];
106
+ const image = mediaManifest.media?.[file];
107
+ const entry = await collectProblems(problems, { area: CHECK_AREA.media, file }, async () => {
108
+ if (typeof image?.imageId === "string") {
109
+ const bytes = await readWorkspaceFile(workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
110
+ if (image.contentHash === contentHash(bytes)) {
111
+ const held = site.imageStates.get(image.imageId);
112
+ if (held === IMAGE_PROCESSING_STATE_FAILED) {
113
+ throw new SiteAuthoringError(
114
+ "media.processing_failed",
115
+ `Taproot failed to process '${file}' (image ${image.imageId}), so it cannot be published. Replace the `
116
+ + "file with one that processes, or remove it and the references to it.",
117
+ { field: file, status: IMAGE_PROCESSING_STATE_FAILED },
118
+ );
119
+ }
120
+ if (held === IMAGE_PROCESSING_STATE_COMPLETE) return undefined;
121
+ }
122
+ }
123
+ const { source, identity, bytes } = await mediaFileIdentity(workspaceDir, file);
124
+ // One that still matches its confirmed video upload is done.
125
+ if (
126
+ typeof video?.videoId === "string"
127
+ && video.pendingConfirm !== true
128
+ && video.byteLength === source.byteLength
129
+ && video.modifiedMilliseconds === source.modifiedMilliseconds
130
+ ) {
131
+ if (listedVideoIds.has(video.videoId)) return undefined;
132
+ // The same refusal the upload gives: a listing cut short cannot say the video is gone.
133
+ if (site.videos.truncated) throw videoLibraryUnverifiable(file, video.videoId, site.videos.videos.length);
134
+ }
135
+ if (sniffVideoContentType(source.header) !== undefined) {
136
+ if (source.byteLength > WORKSPACE_LIMITS.videoBytes) throw videoTooLarge(file, WORKSPACE_LIMITS.videoBytes);
137
+ const acceptance = await videoAcceptance({
138
+ filePath: resolveWorkspacePath(workspaceDir, file),
139
+ byteLength: source.byteLength,
140
+ });
141
+ if (!acceptance.accepted) {
142
+ throw new SiteAuthoringError(
143
+ "media.video_unsupported",
144
+ acceptance.videoCodec === "" && acceptance.audioCodec === ""
145
+ ? `'${file}' could not be read as a video, so the upload would be refused.`
146
+ : `'${file}' is ${acceptance.videoCodec ? `${acceptance.videoCodec} video` : "no video"} with `
147
+ + `${acceptance.audioCodec ? `${acceptance.audioCodec} audio` : "no audio"}. `
148
+ + "Taproot accepts H.264 video with AAC audio or none: export it that way and upload it again.",
149
+ { field: file },
150
+ );
151
+ }
152
+ return { file, kind: "video", identity };
153
+ }
154
+ const { width, height } = inspectImageBytes(bytes, file);
155
+ return { file, kind: "image", identity, width, height };
156
+ });
157
+ if (entry !== undefined) pending.push(entry);
158
+ }
159
+ if (pending.length > MAXIMUM_FILES) {
160
+ problems.push(...problemsOf(tooManyFiles(pending.length), { area: CHECK_AREA.media }));
161
+ }
162
+ return { files: pending, problems };
163
+ }
164
+
165
+ /**
166
+ * What a media file is, for telling a plan's file from a replaced one: a
167
+ * video by its size and modification time, as the upload records it; anything
168
+ * else by its bytes, which are returned too.
169
+ */
170
+ async function mediaFileIdentity(workspaceDir, file) {
171
+ const source = await openWorkspaceFileStream(workspaceDir, file, Number.MAX_SAFE_INTEGER);
172
+ await source.close();
173
+ if (sniffVideoContentType(source.header) !== undefined) {
174
+ return { source, identity: `${source.byteLength}:${source.modifiedMilliseconds}` };
175
+ }
176
+ const bytes = await readWorkspaceFile(workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
177
+ return { source, identity: contentHash(bytes), bytes };
178
+ }
179
+
180
+ function videoLibraryUnverifiable(file, videoId, listed) {
181
+ return new SiteAuthoringError(
182
+ "media.video_library_unverifiable",
183
+ `'${file}' was uploaded as video ${videoId}, which is not among the first ${listed} videos of this `
184
+ + "site's library, and the library is larger than one run can check. Remove the file's entry from "
185
+ + `${MEDIA_MANIFEST_FILE_NAME} to upload it again, or leave it.`,
186
+ { field: file },
187
+ );
188
+ }
189
+
190
+ function tooManyFiles(count) {
191
+ return new SiteAuthoringError(
192
+ "media.too_many_files",
193
+ `This run would upload ${count} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
194
+ { field: MEDIA_DIRECTORY },
195
+ );
196
+ }
197
+
83
198
  function requireUploadCapability(response, fileName) {
84
199
  if (
85
200
  typeof response.presignedUrl !== "string"
@@ -339,13 +454,7 @@ async function uploadVideoFile({
339
454
  // A listing cut short cannot say the video is gone, and uploading again
340
455
  // would spend storage on a video that may well be there.
341
456
  if (truncated) {
342
- throw new SiteAuthoringError(
343
- "media.video_library_unverifiable",
344
- `'${file}' was uploaded as video ${recorded.videoId}, which is not among the first ${byId.size} videos of this `
345
- + "site's library, and the library is larger than one run can check. Remove the file's entry from "
346
- + `${MEDIA_MANIFEST_FILE_NAME} to upload it again, or leave it.`,
347
- { field: file },
348
- );
457
+ throw videoLibraryUnverifiable(file, recorded.videoId, byId.size);
349
458
  }
350
459
  }
351
460
  }
@@ -441,13 +550,7 @@ export async function mediaUpload(invocation) {
441
550
  { field: selected.length > 0 ? normalizePositional(selected[0]) : MEDIA_DIRECTORY },
442
551
  );
443
552
  }
444
- if (files.length > MAXIMUM_FILES) {
445
- throw new SiteAuthoringError(
446
- "media.too_many_files",
447
- `This run would upload ${files.length} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
448
- { field: MEDIA_DIRECTORY },
449
- );
450
- }
553
+ if (files.length > MAXIMUM_FILES) throw tooManyFiles(files.length);
451
554
 
452
555
  const uploaded = [];
453
556
  const uploadedVideos = [];
@@ -553,7 +656,7 @@ export async function mediaUpload(invocation) {
553
656
  // Preserve the author-owned alt text. Delivery fields start with
554
657
  // whatever confirm returned and are refreshed from the completed
555
658
  // library record below.
556
- alt: typeof mediaManifest.media[file]?.alt === "string" ? mediaManifest.media[file].alt : "",
659
+ alt: recordedAlt(mediaManifest.media[file]),
557
660
  src: image.url,
558
661
  urls: image.responsiveUrls,
559
662
  deduplicated,
@@ -599,7 +702,9 @@ export async function mediaUpload(invocation) {
599
702
  manifestEntry.urls = image.responsiveUrls;
600
703
  }
601
704
  await writeMediaManifest(config.workspaceDir, mediaManifest);
602
- const reported = boundedList(
705
+ // The delivery URLs stay in the media manifest: a signed URL list per
706
+ // file is what pushed a bulk upload past the result's 64 KiB (TR01001).
707
+ const reported = boundedByBytes(
603
708
  uploaded.map((entry) => ({
604
709
  file: entry.file,
605
710
  imageId: entry.imageId,
@@ -607,18 +712,10 @@ export async function mediaUpload(invocation) {
607
712
  width: entry.width,
608
713
  height: entry.height,
609
714
  processingState: observed.get(entry.imageId)?.processingState ?? IMAGE_PROCESSING_STATE_COMPLETE,
610
- media: {
611
- imageId: entry.imageId,
612
- src: observed.get(entry.imageId)?.url ?? "",
613
- urls: observed.get(entry.imageId)?.responsiveUrls ?? [],
614
- width: entry.width,
615
- height: entry.height,
616
- alt: mediaManifest.media[entry.file]?.alt ?? "",
617
- },
618
715
  })),
619
- MAXIMUM_REPORTED,
716
+ ITEMS_BYTES,
620
717
  );
621
- const reportedVideos = boundedList(
718
+ const reportedVideos = boundedByBytes(
622
719
  uploadedVideos.map((entry) => ({
623
720
  file: entry.file,
624
721
  videoId: entry.videoId,
@@ -635,7 +732,7 @@ export async function mediaUpload(invocation) {
635
732
  // block for `.pm.json` sources.
636
733
  component: videoComponent(entry.videoId),
637
734
  })),
638
- MAXIMUM_REPORTED,
735
+ ITEMS_BYTES,
639
736
  );
640
737
  return successResult(VERB_MEDIA_UPLOAD, siteId, {
641
738
  mediaManifestFile: MEDIA_MANIFEST_FILE_NAME,
@@ -15,6 +15,8 @@ import {
15
15
  isContactOrWebUrl,
16
16
  } from "../contact-url.js";
17
17
  import { SiteAuthoringError } from "../errors.js";
18
+ import { stableJson } from "../footer-draft-hash.js";
19
+ import { planStale } from "../problems.js";
18
20
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
19
21
  import {
20
22
  NAVIGATION_FILE_NAME,
@@ -304,6 +306,11 @@ export async function navPush(invocation) {
304
306
  // small as the contract allows.
305
307
  onProgress("Re-reading the live navigation tree immediately before replacing it.");
306
308
  const liveItems = await getNavigation(client, siteId);
309
+ // Under apply, the tree replaced must be the one its plan read: a change
310
+ // made since was never reviewed, and this write would erase it.
311
+ if (invocation.plannedLiveNavigation !== undefined && stableJson(liveItems) !== invocation.plannedLiveNavigation) {
312
+ throw planStale("The site's navigation", NAVIGATION_FILE_NAME);
313
+ }
307
314
  const liveIds = collectIds(liveItems);
308
315
  const pushedIds = collectIds(navItems);
309
316
  const removed = [...liveIds].filter((id) => !pushedIds.has(id)).sort();