Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bdbaa1e
feat: add remote modeling commands for custom types, page types, and …
angeloashmore Mar 31, 2026
e59258b
fix: correct Custom Types API URLs and slice command messages
angeloashmore Mar 31, 2026
ebb38ec
test: add e2e tests for custom-type, page-type, and slice commands
angeloashmore Mar 31, 2026
a90b570
fix: add default slice zone to page type and fix test types
angeloashmore Mar 31, 2026
16d120f
feat: add field management commands for remote modeling
angeloashmore Mar 31, 2026
56a739e
fix: add missing primary field to test slice builder
angeloashmore Mar 31, 2026
9cfd1c4
feat: add field edit command for remote modeling
angeloashmore Mar 31, 2026
8c765e3
feat: move sync logic into Adapter and sync after modeling commands
angeloashmore Mar 31, 2026
11dc23c
feat: replace syncModels with granular adapter methods
angeloashmore Apr 1, 2026
2133b8b
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 9, 2026
cf1f0d6
feat: unify `page-type` and `custom-type` into single `type` command …
angeloashmore Apr 10, 2026
901a0be
feat: add `type edit` command (#106)
angeloashmore Apr 10, 2026
ebf788b
feat: add `slice edit` and `slice edit-variation` commands (#107)
angeloashmore Apr 10, 2026
98ed067
feat: add tab management commands (#108)
angeloashmore Apr 10, 2026
ae4006a
feat: rename `--in` flag to `--from-slice`/`--from-type` (#110)
angeloashmore Apr 10, 2026
75f5bc9
feat: show fields inline in `view` commands and remove `field list` (…
angeloashmore Apr 10, 2026
68f620f
feat: add `field view` command (#112)
angeloashmore Apr 10, 2026
8d68cd1
feat: consolidate link-related field types (#114)
angeloashmore Apr 10, 2026
e23c6f6
feat: improve `content-relationship` help text (#115)
angeloashmore Apr 10, 2026
90227ab
feat: add a consistent table formatter for tabular output (#116)
angeloashmore Apr 10, 2026
774a581
feat: replace name-based model specifiers with IDs (#117)
angeloashmore Apr 10, 2026
29c12b7
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 11, 2026
af8d9d8
refactor: add `getCustomType` and `getSlice` client functions (#124)
angeloashmore Apr 14, 2026
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
84 changes: 84 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { generateTypes } from "prismic-ts-codegen";
import { glob } from "tinyglobby";

import { getCustomTypes, getSlices } from "../clients/custom-types";
import { addRoute, removeRoute, updateRoute } from "../config";
import { readJsonFile, writeFileRecursive } from "../lib/file";
import { stringify } from "../lib/json";
Expand Down Expand Up @@ -180,6 +181,89 @@ export abstract class Adapter {
await this.onCustomTypeDeleted(id);
}

async syncModels(config: {
repo: string;
token: string | undefined;
host: string;
}): Promise<void> {
const { repo, token, host } = config;
await Promise.all([
this.syncSlices({ repo, token, host, generateTypes: false }),
this.syncCustomTypes({ repo, token, host, generateTypes: false }),
]);
await this.generateTypes();
}

async syncSlices(config: {
repo: string;
token: string | undefined;
host: string;
generateTypes?: boolean;
}): Promise<void> {
const { repo, token, host, generateTypes = true } = config;

const remoteSlices = await getSlices({ repo, token, host });
const localSlices = await this.getSlices();

// Handle slices update
for (const remoteSlice of remoteSlices) {
const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id);
if (localSlice) await this.updateSlice(remoteSlice);
}

// Handle slices deletion
for (const localSlice of localSlices) {
const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id);
if (!existsRemotely) await this.deleteSlice(localSlice.model.id);
}

// Handle slices creation
for (const remoteSlice of remoteSlices) {
const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id);
if (!existsLocally) await this.createSlice(remoteSlice);
}

if (generateTypes) await this.generateTypes();
}

async syncCustomTypes(config: {
repo: string;
token: string | undefined;
host: string;
generateTypes?: boolean;
}): Promise<void> {
const { repo, token, host, generateTypes = true } = config;

const remoteCustomTypes = await getCustomTypes({ repo, token, host });
const localCustomTypes = await this.getCustomTypes();

// Handle custom types update
for (const remoteCustomType of remoteCustomTypes) {
const localCustomType = localCustomTypes.find(
(customType) => customType.model.id === remoteCustomType.id,
);
if (localCustomType) await this.updateCustomType(remoteCustomType);
}

// Handle custom types deletion
for (const localCustomType of localCustomTypes) {
const existsRemotely = remoteCustomTypes.some(
(customType) => customType.id === localCustomType.model.id,
);
if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id);
}

// Handle custom types creation
for (const remoteCustomType of remoteCustomTypes) {
const existsLocally = localCustomTypes.some(
(customType) => customType.model.id === remoteCustomType.id,
);
if (!existsLocally) await this.createCustomType(remoteCustomType);
}

if (generateTypes) await this.generateTypes();
}

async generateTypes(): Promise<URL> {
const projectRoot = await findProjectRoot();
const output = new URL(TYPES_FILENAME, projectRoot);
Expand Down
122 changes: 121 additions & 1 deletion src/clients/custom-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes";

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

export async function getCustomTypes(config: {
repo: string;
Expand All @@ -16,6 +16,66 @@ export async function getCustomTypes(config: {
return response;
}

export async function getCustomType(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<CustomType> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
try {
return await request<CustomType>(url, {
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
} catch (error) {
if (error instanceof NotFoundRequestError) {
error.message = `Type not found: ${id}`;
}
throw error;
}
}

export async function insertCustomType(
model: CustomType,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("customtypes/insert", customTypesServiceUrl);
await request<CustomType[]>(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function updateCustomType(
model: CustomType,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("customtypes/update", customTypesServiceUrl);
await request<void>(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function removeCustomType(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
await request<CustomType[]>(url, {
method: "DELETE",
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
}

export async function getSlices(config: {
repo: string;
token: string | undefined;
Expand All @@ -30,6 +90,66 @@ export async function getSlices(config: {
return response;
}

export async function getSlice(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<SharedSlice> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
try {
return await request<SharedSlice>(url, {
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
} catch (error) {
if (error instanceof NotFoundRequestError) {
error.message = `Slice not found: ${id}`;
}
throw error;
}
}

export async function insertSlice(
model: SharedSlice,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("slices/insert", customTypesServiceUrl);
await request<CustomType[]>(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function updateSlice(
model: SharedSlice,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("slices/update", customTypesServiceUrl);
await request<void>(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function removeSlice(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
await request<CustomType[]>(url, {
method: "DELETE",
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
}

function getCustomTypesServiceUrl(host: string): URL {
return new URL(`https://customtypes.${host}/`);
}
12 changes: 5 additions & 7 deletions src/commands/docs-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getDocsIndex, getDocsPageIndex } from "../clients/docs";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { stringify } from "../lib/json";
import { NotFoundRequestError, UnknownRequestError } from "../lib/request";
import { formatTable } from "../lib/string";

const config = {
name: "prismic docs list",
Expand Down Expand Up @@ -52,9 +53,8 @@ export default createCommand(config, async ({ positionals, values }) => {
return;
}

for (const anchor of entry.anchors) {
console.info(`${path}#${anchor.slug}: ${anchor.excerpt}`);
}
const rows = entry.anchors.map((anchor) => [`${path}#${anchor.slug}`, anchor.excerpt]);
console.info(formatTable(rows, { headers: ["PATH", "EXCERPT"] }));
} else {
let pages;
try {
Expand All @@ -79,9 +79,7 @@ export default createCommand(config, async ({ positionals, values }) => {
return;
}

for (const page of pages) {
const description = page.description ? ` — ${page.description}` : "";
console.info(`${page.path}: ${page.title}${description}`);
}
const rows = pages.map((page) => [page.path, page.title, page.description ?? ""]);
console.info(formatTable(rows, { headers: ["PATH", "TITLE", "DESCRIPTION"] }));
}
});
55 changes: 55 additions & 0 deletions src/commands/field-add-boolean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { BooleanField } from "@prismicio/types-internal/lib/customtypes";

import { capitalCase } from "change-case";

import { getHost, getToken } from "../auth";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models";
import { getRepositoryName } from "../project";

const config = {
name: "prismic field add boolean",
description: "Add a boolean field to a slice or custom type.",
positionals: {
id: { description: "Field ID", required: true },
},
options: {
...TARGET_OPTIONS,
label: { type: "string", description: "Field label" },
"default-value": { type: "boolean", description: "Default value" },
"true-label": { type: "string", description: "Label for true value" },
"false-label": { type: "string", description: "Label for false value" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [id] = positionals;
const {
label,
"default-value": default_value,
"true-label": placeholder_true,
"false-label": placeholder_false,
repo = await getRepositoryName(),
} = values;

const token = await getToken();
const host = await getHost();
const [fields, saveModel] = await resolveModel(values, { repo, token, host });
const [targetFields, fieldId] = resolveFieldTarget(fields, id);

const field: BooleanField = {
type: "Boolean",
config: {
label: label ?? capitalCase(fieldId),
default_value,
placeholder_true,
placeholder_false,
},
};

if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`);
targetFields[fieldId] = field;
await saveModel();

console.info(`Field added: ${id}`);
});
45 changes: 45 additions & 0 deletions src/commands/field-add-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Color } from "@prismicio/types-internal/lib/customtypes";

import { capitalCase } from "change-case";

import { getHost, getToken } from "../auth";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models";
import { getRepositoryName } from "../project";

const config = {
name: "prismic field add color",
description: "Add a color field to a slice or custom type.",
positionals: {
id: { description: "Field ID", required: true },
},
options: {
...TARGET_OPTIONS,
label: { type: "string", description: "Field label" },
placeholder: { type: "string", description: "Placeholder text" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [id] = positionals;
const { label, placeholder, repo = await getRepositoryName() } = values;

const token = await getToken();
const host = await getHost();
const [fields, saveModel] = await resolveModel(values, { repo, token, host });
const [targetFields, fieldId] = resolveFieldTarget(fields, id);

const field: Color = {
type: "Color",
config: {
label: label ?? capitalCase(fieldId),
placeholder,
},
};

if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`);
targetFields[fieldId] = field;
await saveModel();

console.info(`Field added: ${id}`);
});
Loading
Loading