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..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); } } @@ -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 { diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index 13a484e..e3a4fe6 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -36,6 +36,14 @@ export abstract class FrameworkAdapter { abstract getDefaultSliceLibraryPath(projectRoot: URL): Promise; + async updateRoutesForPageTypes(_customTypes: CustomType[]): Promise { + // 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 2f7a696..4e2b224 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,204 @@ 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); + } + } + + 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; + + 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);