Skip to content

Commit 78c13b0

Browse files
feat: add remote modeling commands for custom types, page types, and slices (#83)
* feat: add remote modeling commands for custom types, page types, and slices Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct Custom Types API URLs and slice command messages The insert/update endpoints were using incorrect paths (e.g. `customtypes` instead of `customtypes/insert`), causing 401 errors on write operations. Also fixes copy-paste errors in slice command output messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add e2e tests for custom-type, page-type, and slice commands Covers create, list, view, remove for all three command groups, plus slice-specific connect, disconnect, add-variation, and remove-variation. All tests verify state against the Custom Types API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add default slice zone to page type and fix test types Page types need a slice zone in Main tab by default. Replace `as any` casts in slice connect/disconnect tests with proper types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add field management commands for remote modeling Adds `field add`, `field list`, and `field remove` commands that operate on custom types and slices via the Custom Types API, along with per-type `field add <type>` subcommands for all supported field types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing primary field to test slice builder Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add field edit command for remote modeling Adds `prismic field edit` to modify existing field properties (label, placeholder, type-specific options) on slices and custom types via the API. Extracts shared `resolveFieldContainer` from `resolveModel` for reuse across field-edit and field-remove. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: move sync logic into Adapter and sync after modeling commands Consolidates sync logic (syncModels, syncSlices, syncCustomTypes) into the Adapter class so all modeling commands can sync local files after remote changes. This ensures local models stay up-to-date after any create, remove, or field mutation command. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: replace syncModels with granular adapter methods Move adapter sync into resolveModel/resolveFieldContainer so field commands no longer depend on the adapter directly. Type/slice CRUD commands now call specific adapter methods (create/update/delete) instead of a full sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: unify `page-type` and `custom-type` into single `type` command (#104) * feat: unify `page-type` and `custom-type` into single `type` command Consolidates the separate `page-type` and `custom-type` top-level commands into a single `type` command with a `--format` flag on create. - `prismic type create <name> --format page` for page types - `prismic type create <name>` defaults to custom format - `prismic type list` shows all types with format in output - `prismic type view/remove` work regardless of format - Field targeting simplified: `--to-type`/`--from-type` replace `--to-page-type`/`--to-custom-type`/`--from-page-type`/`--from-custom-type` Closes #96 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use "content type" in command descriptions for clarity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add `type edit` command (#106) Closes #91 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add `slice edit` and `slice edit-variation` commands (#107) * feat: add `slice edit` and `slice edit-variation` commands Adds two new subcommands for editing slice and variation metadata after creation. Also adds E2E tests for the recently added `type edit` command. Closes #92 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add local fallback for `type edit` adapter call Wraps adapter.updateCustomType in try/catch with createCustomType fallback, matching the pattern used in other commands. Prevents crash when the local customtypes/ directory doesn't contain the edited type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove --repeatable/--single flags from `type edit` The Custom Types API does not support updating the repeatable property; it must be changed from the writing room UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove --description from `slice edit-variation` Edit commands should only expose options that the corresponding create command exposes. `slice add-variation` does not accept --description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add tab management commands (#108) * feat: add `type add-tab`, `type edit-tab`, and `type remove-tab` commands Adds tab management commands for content types: - `add-tab` creates a new tab, optionally with a slice zone - `edit-tab` renames a tab and/or adds/removes a slice zone - `remove-tab` deletes a tab (guards against removing the last one) Closes #94 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: validate mutually exclusive --with-slice-zone and --without-slice-zone flags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: rename `--in` flag to `--from-slice`/`--from-type` (#110) * refactor: rename `--in` flag to `--from-slice`/`--from-type` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update tests to use renamed flags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show fields inline in `view` commands and remove `field list` (#111) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add `field view` command (#112) * feat: add `field view` command Add a command to inspect a single field's full configuration, including label, placeholder, constraints, and type-specific settings. Resolves #93 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use compact buildSlice pattern in field-view tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: consolidate link-related field types (#114) * feat: consolidate link-related field types Merge `link-to-media` into `link` with a new `--allow` option that accepts `document`, `media`, or `web`. Omitting `--allow` creates a generic link. Keep `content-relationship` separate. Update descriptions on `link` and `content-relationship` so agents can tell when to use each one. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: validate --allow option and restore field-edit subtype guards Validate that --allow is one of document, media, or web. Restore subtype guards in field-edit so content-relationship fields can't receive link-only options and vice versa. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: allow all Link options regardless of select value Remove the select-based guard in field-edit so link fields with select: "document" can still be edited with link-specific options like --allow-target-blank and --allow-text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: improve `content-relationship` help text (#115) * feat: improve `content-relationship` help text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: drop FIELD CONSTRAINTS section from content-relationship help Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add a consistent table formatter for tabular output (#116) * feat: add a consistent table formatter for tabular output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add header support to formatTable and add headers to list commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: avoid blank line in `preview list` when only simulator URL exists Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename preview list header from LABEL to NAME Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: replace name-based model specifiers with IDs (#117) * feat: replace name-based model specifiers with IDs Slice commands now resolve by `id` instead of `name`, content type commands resolve by `id` instead of `label`, and variation commands resolve by `id` instead of `name`. Closes #105 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename `currentId` to `id` in slice-edit-variation The "current" prefix was a holdover from name-based specifiers where names could change. IDs are immutable, so the prefix is unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: add `getCustomType` and `getSlice` client functions (#124) * refactor: add `getCustomType` and `getSlice` client functions Replace the `getCustomTypes().find()` and `getSlices().find()` patterns with dedicated single-resource fetch functions that set contextual error messages on `NotFoundRequestError`. Add a central handler in the root router to print those messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: give `NotFoundRequestError` a user-friendly default message Overrides the raw "fetch failed: <url>" message with "Not found." so unhandled 404s from any endpoint still produce a clean message when caught by the root error handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: include URL in default NotFoundRequestError message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add `--field` option to `field add content-relationship` and `field edit` (#123) * feat: add `--field` option to `field add content-relationship` and `field edit` Add a repeatable `--field` flag for specifying which fields to fetch from related documents. Fields are validated against the production custom type model via the Custom Types API. Supports dot notation for nested selection: - `--field title` for top-level fields - `--field group.name` for group sub-fields - `--field cr.name` for CR target type fields - `--field group.cr.group.leaf` for full depth Closes #103 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use `getCustomType` instead of `getCustomTypes` + find Replace 7 call sites that fetch all custom types just to find one by ID with the single-type `GET /customtypes/{id}` endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: simplify field selection resolution with single recursive function Replace 7 functions (157 lines) with a single recursive `resolveFields` function and named `ResolvedField` type, reducing to 3 functions (107 lines). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unnatural phrasing from JSDoc comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: simplify test custom type construction to use default fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: handle type removal when documents exist (#127) Show a clear error message when attempting to remove a type that has associated documents, guiding users to delete the documents first. Closes #99 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add field reorder command (#129) * feat: add field reorder command Adds `prismic field reorder` to move a field before or after another field. Supports cross-tab moves in custom types (destination tab is determined by the anchor field) and dot-notation for group fields. Resolves #100 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: simplify test model setup in field-reorder tests Use buildSlice()/buildCustomType() with property assignment instead of verbose override objects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add `--screenshot` option to `slice add-variation` and `slice edit-variation` (#128) * feat: add `--screenshot` option to `slice add-variation` and `slice edit-variation` Upload a screenshot image (local file or URL) to Prismic's S3 via the ACL provider, storing the resulting imgix URL in the variation's `imageUrl` field. Closes #95 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback for screenshot upload - Use GET with auth headers (Authorization, Repository) for ACL provider - Match response schema from slice-machine: { values: { url, fields }, imgixEndpoint } - Support PRISMIC_HOST via getAclProviderUrl() helper - Use request() instead of fetch() for S3 upload - Fix buffer pooling issue by slicing to owned ArrayBuffer - Build supported extensions list from MIME_TYPES - Add local file path test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use response.blob() and URL for path construction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: accept Blob in uploadScreenshot and validate file type Separate file resolution (readURLFile) from upload logic so uploadScreenshot only deals with Blobs. Add UnsupportedFileTypeError for MIME type validation, fix getExtension edge case, and remove dead imports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add local screenshot file test for slice edit-variation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback for screenshot upload Rename MIME_TYPE_EXTENSIONS to SUPPORTED_IMAGE_MIME_TYPES to clarify intent. Use regex check for HTTP URLs instead of URL.canParse to avoid misidentifying Windows absolute paths as URLs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: misc fixes for remote modeling branch - Remove unnecessary type parameters from request calls - Fix field-edit to reference field.config instead of config - Fix sync watch to generate types once after both syncs - Use getCustomType in type-view instead of filtering list - Fix error message to show variationId instead of variation object Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve nested field lookup and missing existence check - Use root field ID for custom type tab lookup in resolveFieldContainer so dot-separated IDs (e.g. "my_group.subtitle") find the correct tab - Add field existence check in field-edit to throw a clean CommandError instead of a TypeError when the field doesn't exist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: join field IDs before fallback check in error message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: allow adding placeholder to fields that lack one and remove unused --tab from SOURCE_OPTIONS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 39fc1d7 commit 78c13b0

97 files changed

Lines changed: 5956 additions & 103 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/adapters/index.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
66
import { generateTypes } from "prismic-ts-codegen";
77
import { glob } from "tinyglobby";
88

9+
import { getCustomTypes, getSlices } from "../clients/custom-types";
910
import { addRoute, removeRoute, updateRoute } from "../project";
1011
import { readJsonFile, writeFileRecursive } from "../lib/file";
1112
import { stringify } from "../lib/json";
@@ -180,6 +181,89 @@ export abstract class Adapter {
180181
await this.onCustomTypeDeleted(id);
181182
}
182183

184+
async syncModels(config: {
185+
repo: string;
186+
token: string | undefined;
187+
host: string;
188+
}): Promise<void> {
189+
const { repo, token, host } = config;
190+
await Promise.all([
191+
this.syncSlices({ repo, token, host, generateTypes: false }),
192+
this.syncCustomTypes({ repo, token, host, generateTypes: false }),
193+
]);
194+
await this.generateTypes();
195+
}
196+
197+
async syncSlices(config: {
198+
repo: string;
199+
token: string | undefined;
200+
host: string;
201+
generateTypes?: boolean;
202+
}): Promise<void> {
203+
const { repo, token, host, generateTypes = true } = config;
204+
205+
const remoteSlices = await getSlices({ repo, token, host });
206+
const localSlices = await this.getSlices();
207+
208+
// Handle slices update
209+
for (const remoteSlice of remoteSlices) {
210+
const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id);
211+
if (localSlice) await this.updateSlice(remoteSlice);
212+
}
213+
214+
// Handle slices deletion
215+
for (const localSlice of localSlices) {
216+
const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id);
217+
if (!existsRemotely) await this.deleteSlice(localSlice.model.id);
218+
}
219+
220+
// Handle slices creation
221+
for (const remoteSlice of remoteSlices) {
222+
const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id);
223+
if (!existsLocally) await this.createSlice(remoteSlice);
224+
}
225+
226+
if (generateTypes) await this.generateTypes();
227+
}
228+
229+
async syncCustomTypes(config: {
230+
repo: string;
231+
token: string | undefined;
232+
host: string;
233+
generateTypes?: boolean;
234+
}): Promise<void> {
235+
const { repo, token, host, generateTypes = true } = config;
236+
237+
const remoteCustomTypes = await getCustomTypes({ repo, token, host });
238+
const localCustomTypes = await this.getCustomTypes();
239+
240+
// Handle custom types update
241+
for (const remoteCustomType of remoteCustomTypes) {
242+
const localCustomType = localCustomTypes.find(
243+
(customType) => customType.model.id === remoteCustomType.id,
244+
);
245+
if (localCustomType) await this.updateCustomType(remoteCustomType);
246+
}
247+
248+
// Handle custom types deletion
249+
for (const localCustomType of localCustomTypes) {
250+
const existsRemotely = remoteCustomTypes.some(
251+
(customType) => customType.id === localCustomType.model.id,
252+
);
253+
if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id);
254+
}
255+
256+
// Handle custom types creation
257+
for (const remoteCustomType of remoteCustomTypes) {
258+
const existsLocally = localCustomTypes.some(
259+
(customType) => customType.model.id === remoteCustomType.id,
260+
);
261+
if (!existsLocally) await this.createCustomType(remoteCustomType);
262+
}
263+
264+
if (generateTypes) await this.generateTypes();
265+
}
266+
183267
async generateTypes(): Promise<URL> {
184268
const projectRoot = await findProjectRoot();
185269
const output = new URL(TYPES_FILENAME, projectRoot);

src/clients/custom-types.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes";
22

3+
import { createHash } from "node:crypto";
4+
import * as z from "zod/mini";
5+
36
import { NotFoundRequestError, request } from "../lib/request";
7+
import { appendTrailingSlash } from "../lib/url";
48

59
export async function getCustomTypes(config: {
610
repo: string;
@@ -23,6 +27,66 @@ export async function getCustomTypes(config: {
2327
}
2428
}
2529

30+
export async function getCustomType(
31+
id: string,
32+
config: { repo: string; token: string | undefined; host: string },
33+
): Promise<CustomType> {
34+
const { repo, token, host } = config;
35+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
36+
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
37+
try {
38+
return await request<CustomType>(url, {
39+
headers: { repository: repo, Authorization: `Bearer ${token}` },
40+
});
41+
} catch (error) {
42+
if (error instanceof NotFoundRequestError) {
43+
error.message = `Type not found: ${id}`;
44+
}
45+
throw error;
46+
}
47+
}
48+
49+
export async function insertCustomType(
50+
model: CustomType,
51+
config: { repo: string; token: string | undefined; host: string },
52+
): Promise<void> {
53+
const { repo, token, host } = config;
54+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
55+
const url = new URL("customtypes/insert", customTypesServiceUrl);
56+
await request(url, {
57+
method: "POST",
58+
headers: { repository: repo, Authorization: `Bearer ${token}` },
59+
body: model,
60+
});
61+
}
62+
63+
export async function updateCustomType(
64+
model: CustomType,
65+
config: { repo: string; token: string | undefined; host: string },
66+
): Promise<void> {
67+
const { repo, token, host } = config;
68+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
69+
const url = new URL("customtypes/update", customTypesServiceUrl);
70+
await request(url, {
71+
method: "POST",
72+
headers: { repository: repo, Authorization: `Bearer ${token}` },
73+
body: model,
74+
});
75+
}
76+
77+
export async function removeCustomType(
78+
id: string,
79+
config: { repo: string; token: string | undefined; host: string },
80+
): Promise<void> {
81+
const { repo, token, host } = config;
82+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
83+
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
84+
await request(url, {
85+
method: "DELETE",
86+
headers: { repository: repo, Authorization: `Bearer ${token}` },
87+
});
88+
}
89+
2690
export async function getSlices(config: {
2791
repo: string;
2892
token: string | undefined;
@@ -44,6 +108,141 @@ export async function getSlices(config: {
44108
}
45109
}
46110

111+
export async function getSlice(
112+
id: string,
113+
config: { repo: string; token: string | undefined; host: string },
114+
): Promise<SharedSlice> {
115+
const { repo, token, host } = config;
116+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
117+
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
118+
try {
119+
return await request<SharedSlice>(url, {
120+
headers: { repository: repo, Authorization: `Bearer ${token}` },
121+
});
122+
} catch (error) {
123+
if (error instanceof NotFoundRequestError) {
124+
error.message = `Slice not found: ${id}`;
125+
}
126+
throw error;
127+
}
128+
}
129+
130+
export async function insertSlice(
131+
model: SharedSlice,
132+
config: { repo: string; token: string | undefined; host: string },
133+
): Promise<void> {
134+
const { repo, token, host } = config;
135+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
136+
const url = new URL("slices/insert", customTypesServiceUrl);
137+
await request(url, {
138+
method: "POST",
139+
headers: { repository: repo, Authorization: `Bearer ${token}` },
140+
body: model,
141+
});
142+
}
143+
144+
export async function updateSlice(
145+
model: SharedSlice,
146+
config: { repo: string; token: string | undefined; host: string },
147+
): Promise<void> {
148+
const { repo, token, host } = config;
149+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
150+
const url = new URL("slices/update", customTypesServiceUrl);
151+
await request(url, {
152+
method: "POST",
153+
headers: { repository: repo, Authorization: `Bearer ${token}` },
154+
body: model,
155+
});
156+
}
157+
158+
export async function removeSlice(
159+
id: string,
160+
config: { repo: string; token: string | undefined; host: string },
161+
): Promise<void> {
162+
const { repo, token, host } = config;
163+
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
164+
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
165+
await request(url, {
166+
method: "DELETE",
167+
headers: { repository: repo, Authorization: `Bearer ${token}` },
168+
});
169+
}
170+
171+
const AclCreateResponseSchema = z.object({
172+
values: z.object({
173+
url: z.string(),
174+
fields: z.record(z.string(), z.string()),
175+
}),
176+
imgixEndpoint: z.string(),
177+
});
178+
179+
const SUPPORTED_IMAGE_MIME_TYPES: Record<string, string> = {
180+
"image/png": ".png",
181+
"image/jpeg": ".jpg",
182+
"image/gif": ".gif",
183+
"image/webp": ".webp",
184+
};
185+
186+
export async function uploadScreenshot(
187+
blob: Blob,
188+
config: {
189+
sliceId: string;
190+
variationId: string;
191+
repo: string;
192+
token: string | undefined;
193+
host: string;
194+
},
195+
): Promise<URL> {
196+
const { sliceId, variationId, repo, token, host } = config;
197+
198+
const type = blob.type;
199+
if (!(type in SUPPORTED_IMAGE_MIME_TYPES)) {
200+
throw new UnsupportedFileTypeError(type);
201+
}
202+
203+
const aclUrl = new URL("create", getAclProviderUrl(host));
204+
const acl = await request(aclUrl, {
205+
headers: { Repository: repo, Authorization: `Bearer ${token}` },
206+
schema: AclCreateResponseSchema,
207+
});
208+
209+
const extension = SUPPORTED_IMAGE_MIME_TYPES[type];
210+
const digest = createHash("md5")
211+
.update(new Uint8Array(await blob.arrayBuffer()))
212+
.digest("hex");
213+
const key = `${repo}/shared-slices/${sliceId}/${variationId}/${digest}${extension}`;
214+
215+
const formData = new FormData();
216+
for (const [field, value] of Object.entries(acl.values.fields)) {
217+
formData.append(field, value);
218+
}
219+
formData.append("key", key);
220+
formData.append("Content-Type", type);
221+
formData.append("file", blob);
222+
223+
await request(acl.values.url, { method: "POST", body: formData });
224+
225+
const url = new URL(key, appendTrailingSlash(acl.imgixEndpoint));
226+
url.searchParams.set("auto", "compress,format");
227+
228+
return url;
229+
}
230+
231+
export class UnsupportedFileTypeError extends Error {
232+
name = "UnsupportedFileTypeError";
233+
234+
constructor(mimeType: string) {
235+
const supportedTypes = Object.keys(SUPPORTED_IMAGE_MIME_TYPES);
236+
super(
237+
`Unsupported file type: ${mimeType || "unknown"}. Supported: ${supportedTypes.join(", ")}`,
238+
);
239+
}
240+
}
241+
47242
function getCustomTypesServiceUrl(host: string): URL {
48243
return new URL(`https://customtypes.${host}/`);
49244
}
245+
246+
function getAclProviderUrl(host: string): URL {
247+
return new URL(`https://acl-provider.${host}/`);
248+
}

src/commands/field-add-boolean.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { BooleanField } from "@prismicio/types-internal/lib/customtypes";
2+
3+
import { capitalCase } from "change-case";
4+
5+
import { getHost, getToken } from "../auth";
6+
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
7+
import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models";
8+
import { getRepositoryName } from "../project";
9+
10+
const config = {
11+
name: "prismic field add boolean",
12+
description: "Add a boolean field to a slice or custom type.",
13+
positionals: {
14+
id: { description: "Field ID", required: true },
15+
},
16+
options: {
17+
...TARGET_OPTIONS,
18+
label: { type: "string", description: "Field label" },
19+
"default-value": { type: "boolean", description: "Default value" },
20+
"true-label": { type: "string", description: "Label for true value" },
21+
"false-label": { type: "string", description: "Label for false value" },
22+
},
23+
} satisfies CommandConfig;
24+
25+
export default createCommand(config, async ({ positionals, values }) => {
26+
const [id] = positionals;
27+
const {
28+
label,
29+
"default-value": default_value,
30+
"true-label": placeholder_true,
31+
"false-label": placeholder_false,
32+
repo = await getRepositoryName(),
33+
} = values;
34+
35+
const token = await getToken();
36+
const host = await getHost();
37+
const [fields, saveModel] = await resolveModel(values, { repo, token, host });
38+
const [targetFields, fieldId] = resolveFieldTarget(fields, id);
39+
40+
const field: BooleanField = {
41+
type: "Boolean",
42+
config: {
43+
label: label ?? capitalCase(fieldId),
44+
default_value,
45+
placeholder_true,
46+
placeholder_false,
47+
},
48+
};
49+
50+
if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`);
51+
targetFields[fieldId] = field;
52+
await saveModel();
53+
54+
console.info(`Field added: ${id}`);
55+
});

0 commit comments

Comments
 (0)