Skip to content
Draft
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
62 changes: 60 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@
"version": ">=24",
"onFail": "warn"
}
},
"dependencies": {
"recast": "^0.23.11"
}
}
6 changes: 6 additions & 0 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ export async function syncCustomTypes(repo: string, framework: FrameworkAdapter)
(customType) => customType.id === localCustomType.model.id,
);
if (!existsRemotely) {
if (localCustomType.model.format === "page") {
await framework.removeRoutesForPageType(localCustomType.model.id);
}
await framework.deleteCustomType(localCustomType.model.id);
}
}
Expand All @@ -237,6 +240,9 @@ export async function syncCustomTypes(repo: string, framework: FrameworkAdapter)
await framework.createCustomType(remoteCustomType);
}
}

// Append missing page type routes to prismicio.ts
await framework.updateRoutesForPageTypes(remoteCustomTypes);
}

function shutdown(): void {
Expand Down
8 changes: 8 additions & 0 deletions src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export abstract class FrameworkAdapter {

abstract getDefaultSliceLibraryPath(projectRoot: URL): Promise<URL>;

async updateRoutesForPageTypes(_customTypes: CustomType[]): Promise<void> {
// No-op by default. Override in framework-specific subclasses.
}

async removeRoutesForPageType(_customTypeId: string): Promise<void> {
// No-op by default. Override in framework-specific subclasses.
}

async initProject(): Promise<void> {
const deps = await this.getDependencies();
await addDependencies(deps);
Expand Down
206 changes: 205 additions & 1 deletion src/frameworks/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes";
import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes";

import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";

import { kebabCase } from "change-case";
import type { types } from "recast";
import * as recast from "recast";
import * as typescriptParser from "recast/parsers/typescript.js";

import type { Framework } from ".";

import { FrameworkAdapter } from ".";
Expand Down Expand Up @@ -93,6 +99,204 @@ export class NextJsFramework extends FrameworkAdapter {
}
}

async updateRoutesForPageTypes(customTypes: CustomType[]): Promise<void> {
const pageTypes = customTypes
.filter((ct) => ct.format === "page")
.sort((a, b) => a.id.localeCompare(b.id));

if (pageTypes.length === 0) {
return;
}

const extension = await this.getJsFileExtension();
const filePath = await this.#buildSrcPath(`prismicio.${extension}`);

if (!(await exists(filePath))) {
return;
}

const contents = await readFile(filePath, "utf8");

let ast: types.ASTNode;
try {
ast = recast.parse(contents, { parser: typescriptParser });
} catch {
return;
}

const routesArray = this.#findRoutesArray(ast);
if (!routesArray) {
return;
}

const existingTypes = new Set(
routesArray.elements.map((el) => this.#getRouteType(el)).filter(Boolean),
);

const missingTypes = pageTypes.filter((m) => !existingTypes.has(m.id));
if (missingTypes.length === 0) {
return;
}

for (const model of missingTypes) {
const routePath = this.#buildRoutePath(model.id, model.repeatable);
const routeObj = this.#createRouteObject(model.id, routePath);
routesArray.elements.push(routeObj);
}

const updated = recast.print(ast).code;
if (updated !== contents) {
await writeFileRecursive(filePath, updated);
}
}

async removeRoutesForPageType(customTypeId: string): Promise<void> {
const extension = await this.getJsFileExtension();
const filePath = await this.#buildSrcPath(`prismicio.${extension}`);

if (!(await exists(filePath))) {
return;
}

const contents = await readFile(filePath, "utf8");

let ast: types.ASTNode;
try {
ast = recast.parse(contents, { parser: typescriptParser });
} catch {
return;
}

const routesArray = this.#findRoutesArray(ast);
if (!routesArray) {
return;
}

// Find ALL elements with matching type (a type can have multiple route configs)
const indicesToRemove = routesArray.elements
.map((el, i) => (this.#getRouteType(el) === customTypeId ? i : -1))
.filter((i) => i !== -1);

if (indicesToRemove.length === 0) {
return;
}

// Process in reverse order to avoid index shifting issues
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
const indexToRemove = indicesToRemove[i];
const elementToRemove = routesArray.elements[indexToRemove];

// Preserve leading comments by attaching them to the next non-deleted element
const comments = this.#getNodeComments(elementToRemove);
const leadingComments = comments.filter((c) => c.leading);

if (leadingComments.length > 0) {
const nextIndex = routesArray.elements.findIndex(
(_, idx) => idx > indexToRemove && !indicesToRemove.includes(idx),
);

const nextElement = nextIndex !== -1 ? routesArray.elements[nextIndex] : null;

if (nextElement) {
const existingComments = this.#getNodeComments(nextElement);
this.#setNodeComments(nextElement, [...leadingComments, ...existingComments]);
}
}

routesArray.elements.splice(indexToRemove, 1);
}

const updated = recast.print(ast).code;
if (updated !== contents) {
await writeFileRecursive(filePath, updated);
}
}

#getNodeComments(node: unknown): { leading: boolean; value: string }[] {
if (
typeof node === "object" &&
node !== null &&
"comments" in node &&
Array.isArray(node.comments)
) {
return node.comments;
}
return [];
}

#setNodeComments(node: unknown, comments: { leading: boolean; value: string }[]): void {
if (typeof node === "object" && node !== null) {
Object.assign(node, { comments });
}
}

#findRoutesArray(ast: types.ASTNode): types.namedTypes.ArrayExpression | undefined {
const n = recast.types.namedTypes;
let routesArray: types.namedTypes.ArrayExpression | undefined;

recast.visit(ast, {
visitVariableDeclarator(nodePath): false | void {
const node = nodePath.node;
if (
n.Identifier.check(node.id) &&
node.id.name === "routes" &&
n.ArrayExpression.check(node.init)
) {
routesArray = node.init;
return false;
}
this.traverse(nodePath);
},
});

return routesArray;
}

#getRouteType(element: unknown): string | undefined {
const n = recast.types.namedTypes;

if (!n.ObjectExpression.check(element)) {
return;
}

for (const prop of element.properties) {
if (!n.Property.check(prop) && !n.ObjectProperty?.check?.(prop)) {
continue;
}

const keyName = n.Identifier.check(prop.key)
? prop.key.name
: n.StringLiteral.check(prop.key)
? prop.key.value
: n.Literal.check(prop.key) && typeof prop.key.value === "string"
? prop.key.value
: undefined;

if (keyName === "type") {
const val = prop.value;
if (n.StringLiteral.check(val)) {
return val.value;
}
if (n.Literal.check(val) && typeof val.value === "string") {
return val.value;
}
}
}
}

#createRouteObject(typeId: string, routePath: string): types.namedTypes.ObjectExpression {
const b = recast.types.builders;
return b.objectExpression([
b.property("init", b.identifier("type"), b.stringLiteral(typeId)),
b.property("init", b.identifier("path"), b.stringLiteral(routePath)),
]);
}

#buildRoutePath(typeId: string, repeatable?: boolean): string {
const segment = kebabCase(typeId);
return `/${segment}${repeatable ? "/:uid" : ""}`;
}

async #checkHasAppRouter(): Promise<boolean> {
const appPath = await this.#buildSrcPath("app");
return await exists(appPath);
Expand Down