Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/clients/custom-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes";

import { createHash } from "node:crypto";
import * as z from "zod/mini";

import { NotFoundRequestError, request } from "../lib/request";
import { appendTrailingSlash } from "../lib/url";

export async function getCustomTypes(config: {
repo: string;
Expand Down Expand Up @@ -164,6 +168,81 @@ export async function removeSlice(
});
}

const AclCreateResponseSchema = z.object({
values: z.object({
url: z.string(),
fields: z.record(z.string(), z.string()),
}),
imgixEndpoint: z.string(),
});

const SUPPORTED_IMAGE_MIME_TYPES: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};
Comment thread
angeloashmore marked this conversation as resolved.

export async function uploadScreenshot(
blob: Blob,
config: {
sliceId: string;
variationId: string;
repo: string;
token: string | undefined;
host: string;
},
): Promise<URL> {
const { sliceId, variationId, repo, token, host } = config;

const type = blob.type;
if (!(type in SUPPORTED_IMAGE_MIME_TYPES)) {
throw new UnsupportedFileTypeError(type);
}

const aclUrl = new URL("create", getAclProviderUrl(host));
const acl = await request(aclUrl, {
headers: { Repository: repo, Authorization: `Bearer ${token}` },
schema: AclCreateResponseSchema,
});
Comment thread
cursor[bot] marked this conversation as resolved.

const extension = SUPPORTED_IMAGE_MIME_TYPES[type];
const digest = createHash("md5")
.update(new Uint8Array(await blob.arrayBuffer()))
.digest("hex");
const key = `${repo}/shared-slices/${sliceId}/${variationId}/${digest}${extension}`;

const formData = new FormData();
for (const [field, value] of Object.entries(acl.values.fields)) {
formData.append(field, value);
}
formData.append("key", key);
formData.append("Content-Type", type);
formData.append("file", blob);

await request(acl.values.url, { method: "POST", body: formData });

const url = new URL(key, appendTrailingSlash(acl.imgixEndpoint));
url.searchParams.set("auto", "compress,format");

return url;
}

export class UnsupportedFileTypeError extends Error {
name = "UnsupportedFileTypeError";

constructor(mimeType: string) {
const supportedTypes = Object.keys(SUPPORTED_IMAGE_MIME_TYPES);
super(
`Unsupported file type: ${mimeType || "unknown"}. Supported: ${supportedTypes.join(", ")}`,
);
}
}

function getCustomTypesServiceUrl(host: string): URL {
return new URL(`https://customtypes.${host}/`);
}

function getAclProviderUrl(host: string): URL {
return new URL(`https://acl-provider.${host}/`);
}
36 changes: 33 additions & 3 deletions src/commands/slice-add-variation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes";

import { camelCase } from "change-case";
import { pathToFileURL } from "node:url";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { getSlice, updateSlice } from "../clients/custom-types";
import {
getSlice,
UnsupportedFileTypeError,
updateSlice,
uploadScreenshot,
} from "../clients/custom-types";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { readURLFile } from "../lib/file";
import { UnknownRequestError } from "../lib/request";
import { getRepositoryName } from "../project";

Expand All @@ -18,13 +25,14 @@ const config = {
options: {
to: { type: "string", required: true, description: "ID of the slice" },
id: { type: "string", description: "Custom ID for the variation" },
screenshot: { type: "string", short: "s", description: "Screenshot image file path or URL" },
repo: { type: "string", short: "r", description: "Repository domain" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [name] = positionals;
const { to, id = camelCase(name), repo = await getRepositoryName() } = values;
const { to, id = camelCase(name), screenshot, repo = await getRepositoryName() } = values;

const adapter = await getAdapter();
const token = await getToken();
Expand All @@ -35,6 +43,28 @@ export default createCommand(config, async ({ positionals, values }) => {
throw new CommandError(`Variation "${id}" already exists in slice "${to}".`);
}

let imageUrl = "";
if (screenshot) {
const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot);
const blob = await readURLFile(url);
let screenshotUrl;
try {
screenshotUrl = await uploadScreenshot(blob, {
sliceId: slice.id,
variationId: id,
repo,
token,
host,
});
} catch (error) {
if (error instanceof UnsupportedFileTypeError) {
throw new CommandError(error.message);
}
throw error;
}
imageUrl = screenshotUrl.toString();
}

const updatedSlice: SharedSlice = {
...slice,
variations: [
Expand All @@ -44,7 +74,7 @@ export default createCommand(config, async ({ positionals, values }) => {
name,
description: name,
docURL: "",
imageUrl: "",
imageUrl,
version: "",
primary: {},
},
Expand Down
41 changes: 33 additions & 8 deletions src/commands/slice-edit-variation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes";
import { pathToFileURL } from "node:url";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { getSlice, updateSlice } from "../clients/custom-types";
import {
getSlice,
UnsupportedFileTypeError,
updateSlice,
uploadScreenshot,
} from "../clients/custom-types";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { readURLFile } from "../lib/file";
import { UnknownRequestError } from "../lib/request";
import { getRepositoryName } from "../project";

Expand All @@ -16,31 +22,50 @@ const config = {
options: {
"from-slice": { type: "string", required: true, description: "ID of the slice" },
name: { type: "string", short: "n", description: "New name for the variation" },
screenshot: { type: "string", short: "s", description: "Screenshot image file path or URL" },
repo: { type: "string", short: "r", description: "Repository domain" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [id] = positionals;
const { "from-slice": sliceId, repo = await getRepositoryName() } = values;
const { "from-slice": sliceId, screenshot, repo = await getRepositoryName() } = values;

const adapter = await getAdapter();
const token = await getToken();
const host = await getHost();
const slice = await getSlice(sliceId, { repo, token, host });

const variation = slice.variations.find((v) => v.id === id);

if (!variation) {
throw new CommandError(`Variation "${id}" not found in slice "${sliceId}".`);
}

if ("name" in values) variation.name = values.name!;

const updatedSlice: SharedSlice = { ...slice };
if (screenshot) {
const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot);
const blob = await readURLFile(url);
let screenshotUrl;
try {
screenshotUrl = await uploadScreenshot(blob, {
sliceId: slice.id,
variationId: variation.id,
repo,
token,
host,
});
} catch (error) {
if (error instanceof UnsupportedFileTypeError) {
throw new CommandError(error.message);
}
throw error;
}
variation.imageUrl = screenshotUrl.toString();
}

try {
await updateSlice(updatedSlice, { repo, host, token });
await updateSlice(slice, { repo, host, token });
} catch (error) {
if (error instanceof UnknownRequestError) {
const message = await error.text();
Expand All @@ -50,9 +75,9 @@ export default createCommand(config, async ({ positionals, values }) => {
}

try {
await adapter.updateSlice(updatedSlice);
await adapter.updateSlice(slice);
} catch {
await adapter.createSlice(updatedSlice);
await adapter.createSlice(slice);
}
await adapter.generateTypes();

Expand Down
33 changes: 32 additions & 1 deletion src/lib/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import * as z from "zod/mini";

import { appendTrailingSlash } from "./url";
import { appendTrailingSlash, getExtension } from "./url";

export async function findUpward(
name: string,
Expand Down Expand Up @@ -68,3 +68,34 @@ export async function readJsonFile<T = unknown>(
if (schema) return z.parse(schema, json);
return json;
}

const MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
};

export async function readURLFile(url: URL): Promise<Blob> {
if (url.protocol === "http:" || url.protocol === "https:") {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download file from "${url.toString()}" (HTTP ${response.status}).`,
);
}
return await response.blob();
}

if (url.protocol === "file:") {
const buffer = await readFile(url);
const extension = getExtension(url);
const type = extension
? MIME_TYPES[extension] || "application/octet-stream"
: "application/octet-stream";
return new Blob([buffer], { type });
}

throw new Error(`Unsupported file protocol: ${url.protocol}`);
}
6 changes: 6 additions & 0 deletions src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ export function appendTrailingSlash(url: string | URL): URL {
export function relativePathname(a: URL, b: URL): string {
return relative(fileURLToPath(a), fileURLToPath(b));
}

export function getExtension(url: URL): string | undefined {
const dotIndex = url.pathname.lastIndexOf(".");
if (dotIndex === -1) return undefined;
return url.pathname.slice(dotIndex + 1).toLowerCase() || undefined;
}
68 changes: 68 additions & 0 deletions test/slice-add-variation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";

import { buildSlice, it } from "./it";
import { getSlices, insertSlice } from "./prismic";

Expand Down Expand Up @@ -28,3 +31,68 @@ it("adds a variation to a slice", async ({ expect, prismic, repo, token, host })
const variation = updated?.variations.find((v) => v.name === variationName);
expect(variation).toBeDefined();
});

it("adds a variation with a screenshot URL", async ({ expect, prismic, repo, token, host }) => {
Comment thread
angeloashmore marked this conversation as resolved.
const slice = buildSlice();
await insertSlice(slice, { repo, token, host });

const variationName = `Variation${crypto.randomUUID().split("-")[0]}`;

const { stdout, exitCode } = await prismic("slice", [
"add-variation",
variationName,
"--to",
slice.id,
"--screenshot",
"https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain(`Added variation "${variationName}"`);

const slices = await getSlices({ repo, token, host });
const updated = slices.find((s) => s.id === slice.id);
const variation = updated?.variations.find((v) => v.name === variationName);
expect(variation).toBeDefined();
expect(variation?.imageUrl).toContain("https://");
expect(variation?.imageUrl).toContain(".png");
});

it("adds a variation with a local screenshot file", async ({
expect,
prismic,
project,
repo,
token,
host,
}) => {
const slice = buildSlice();
await insertSlice(slice, { repo, token, host });

const screenshotUrl =
"https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png";
const response = await fetch(screenshotUrl);
const data = new Uint8Array(await response.arrayBuffer());
const screenshotFileUrl = new URL("screenshot.png", project);
await writeFile(screenshotFileUrl, data);
const screenshotPath = fileURLToPath(screenshotFileUrl);

const variationName = `Variation${crypto.randomUUID().split("-")[0]}`;

const { stdout, exitCode } = await prismic("slice", [
"add-variation",
variationName,
"--to",
slice.id,
"--screenshot",
screenshotPath,
]);
expect(exitCode).toBe(0);
expect(stdout).toContain(`Added variation "${variationName}"`);

const slices = await getSlices({ repo, token, host });
const updated = slices.find((s) => s.id === slice.id);
const variation = updated?.variations.find((v) => v.name === variationName);
expect(variation).toBeDefined();
expect(variation?.imageUrl).toContain("https://");
expect(variation?.imageUrl).toContain(".png");
});
Loading
Loading