From feec3c51fafeb1f6f21f04c26e8cbba031493753 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Tue, 10 Mar 2026 16:57:52 -1000 Subject: [PATCH 1/2] feat: auto-update prismicio.ts routes for page types during sync After syncing custom types, appends missing page type routes to the `routes` array in `prismicio.ts` using recast AST manipulation. Only appends new entries; never modifies or removes existing ones. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 62 ++++++++++++++++++- package.json | 3 + src/commands/sync.ts | 3 + src/frameworks/index.ts | 4 ++ src/frameworks/nextjs.ts | 126 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 905710b..1ee38a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "prismic", "version": "0.0.0", "license": "Apache-2.0", + "dependencies": { + "recast": "^0.23.11" + }, "bin": { "prismic": "dist/index.mjs" }, @@ -30,7 +33,7 @@ "zod": "^4.3.6" }, "engines": { - "node": ">=24" + "node": ">=20" } }, "node_modules/@babel/code-frame": { @@ -2049,6 +2052,18 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", @@ -2340,6 +2355,19 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3495,6 +3523,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, "node_modules/redent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", @@ -3698,6 +3742,15 @@ "dev": true, "license": "ISC" }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3784,6 +3837,12 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3984,7 +4043,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-fest": { diff --git a/package.json b/package.json index 849f62e..7ebe013 100644 --- a/package.json +++ b/package.json @@ -61,5 +61,8 @@ "version": ">=24", "onFail": "warn" } + }, + "dependencies": { + "recast": "^0.23.11" } } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index b4c41b4..fc96927 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -237,6 +237,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 { diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index 13a484e..b128987 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -36,6 +36,10 @@ export abstract class FrameworkAdapter { abstract getDefaultSliceLibraryPath(projectRoot: URL): Promise; + async updateRoutesForPageTypes(_customTypes: CustomType[]): Promise { + // No-op by default. Override in framework-specific subclasses. + } + async initProject(): Promise { const deps = await this.getDependencies(); await addDependencies(deps); diff --git a/src/frameworks/nextjs.ts b/src/frameworks/nextjs.ts index 2f7a696..26a95f7 100644 --- a/src/frameworks/nextjs.ts +++ b/src/frameworks/nextjs.ts @@ -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 "."; @@ -93,6 +99,124 @@ export class NextJsFramework extends FrameworkAdapter { } } + async updateRoutesForPageTypes(customTypes: CustomType[]): Promise { + 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); + } + } + + #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 { const appPath = await this.#buildSrcPath("app"); return await exists(appPath); From b3a3716ce112c2bc6898e86b04626a8eb7a63fbf Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Tue, 10 Mar 2026 17:09:44 -1000 Subject: [PATCH 2/2] feat: remove prismicio.ts route entries when page types are deleted during sync Ports the route-removal behavior from adapter-next. When a page type is deleted remotely and synced, its route entry is removed from the routes array in prismicio.ts. Leading comments are preserved by transferring them to the next route element. Co-Authored-By: Claude Opus 4.6 --- src/commands/sync.ts | 3 ++ src/frameworks/index.ts | 4 ++ src/frameworks/nextjs.ts | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index fc96927..9bd1ed0 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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); } } diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index b128987..e3a4fe6 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -40,6 +40,10 @@ export abstract class FrameworkAdapter { // No-op by default. Override in framework-specific subclasses. } + async removeRoutesForPageType(_customTypeId: string): Promise { + // No-op by default. Override in framework-specific subclasses. + } + async initProject(): Promise { const deps = await this.getDependencies(); await addDependencies(deps); diff --git a/src/frameworks/nextjs.ts b/src/frameworks/nextjs.ts index 26a95f7..4e2b224 100644 --- a/src/frameworks/nextjs.ts +++ b/src/frameworks/nextjs.ts @@ -150,6 +150,86 @@ export class NextJsFramework extends FrameworkAdapter { } } + async removeRoutesForPageType(customTypeId: string): Promise { + 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;