canopycms-next 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-next",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Next.js adapter for CanopyCMS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,8 +17,7 @@
17
17
  "./client": "./src/client.tsx"
18
18
  },
19
19
  "files": [
20
- "dist",
21
- "src"
20
+ "dist"
22
21
  ],
23
22
  "publishConfig": {
24
23
  "exports": {
@@ -41,8 +40,8 @@
41
40
  "node": ">=18"
42
41
  },
43
42
  "peerDependencies": {
44
- "canopycms": "*",
45
- "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
43
+ "canopycms": "^0.0.1",
44
+ "next": "^0.0.1",
46
45
  "react": "^18.0.0 || ^19.0.0"
47
46
  },
48
47
  "devDependencies": {
@@ -1,194 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- // Mock next/server before any imports
4
- vi.mock('next/server', () => {
5
- return {
6
- NextResponse: {
7
- json: (body: any, init?: any) => ({
8
- body,
9
- status: init?.status ?? 200,
10
- headers: init?.headers,
11
- }),
12
- },
13
- }
14
- })
15
-
16
- // Mock canopycms/http to return a controlled response
17
- vi.mock('canopycms/http', async () => {
18
- return {
19
- createCanopyRequestHandler: vi.fn(() => {
20
- return async (req: any, segments: string[]) => {
21
- // Return different responses based on segments
22
- if (segments.length === 1 && segments[0] === 'branches') {
23
- return {
24
- status: 200,
25
- body: { ok: true, status: 200, data: { branches: [] } },
26
- }
27
- }
28
- if (segments.length === 0 || segments.includes('unknown')) {
29
- return {
30
- status: 404,
31
- body: { ok: false, status: 404, error: 'Not found' },
32
- }
33
- }
34
- return {
35
- status: 200,
36
- body: { ok: true, status: 200 },
37
- }
38
- }
39
- }),
40
- }
41
- })
42
-
43
- import { createCanopyCatchAllHandler } from './adapter'
44
- import { createMockAuthPlugin } from './test-utils'
45
-
46
- describe('Next.js adapter', () => {
47
- const mockAuthPlugin = createMockAuthPlugin({
48
- userId: 'test-user',
49
- groups: ['Admins'],
50
- })
51
-
52
- describe('createCanopyCatchAllHandler', () => {
53
- it('converts NextRequest to CanopyRequest and returns NextResponse', async () => {
54
- const handler = createCanopyCatchAllHandler({
55
- services: {} as any,
56
- authPlugin: mockAuthPlugin,
57
- })
58
-
59
- const mockNextRequest = {
60
- method: 'GET',
61
- url: 'http://localhost:3000/api/canopycms/branches',
62
- headers: { get: () => null },
63
- json: async () => undefined,
64
- } as any
65
-
66
- const response: any = await handler(mockNextRequest, {
67
- params: { canopycms: ['branches'] },
68
- })
69
-
70
- expect(response.status).toBe(200)
71
- expect(response.body).toHaveProperty('ok', true)
72
- })
73
-
74
- it('handles Next.js 14 direct params object', async () => {
75
- const handler = createCanopyCatchAllHandler({
76
- services: {} as any,
77
- authPlugin: mockAuthPlugin,
78
- })
79
-
80
- const mockNextRequest = {
81
- method: 'GET',
82
- url: 'http://localhost:3000/api/canopycms/branches',
83
- headers: { get: () => null },
84
- json: async () => undefined,
85
- } as any
86
-
87
- // Next.js 14 style - params is a direct object
88
- const response: any = await handler(mockNextRequest, {
89
- params: { canopycms: ['branches'] },
90
- })
91
-
92
- expect(response.status).toBe(200)
93
- })
94
-
95
- it('handles Next.js 15 async params Promise', async () => {
96
- const handler = createCanopyCatchAllHandler({
97
- services: {} as any,
98
- authPlugin: mockAuthPlugin,
99
- })
100
-
101
- const mockNextRequest = {
102
- method: 'GET',
103
- url: 'http://localhost:3000/api/canopycms/branches',
104
- headers: { get: () => null },
105
- json: async () => undefined,
106
- } as any
107
-
108
- // Next.js 15 style - params is a Promise
109
- const response: any = await handler(mockNextRequest, {
110
- params: Promise.resolve({ canopycms: ['branches'] }),
111
- })
112
-
113
- expect(response.status).toBe(200)
114
- })
115
-
116
- it('handles missing params gracefully', async () => {
117
- const handler = createCanopyCatchAllHandler({
118
- services: {} as any,
119
- authPlugin: mockAuthPlugin,
120
- })
121
-
122
- const mockNextRequest = {
123
- method: 'GET',
124
- url: 'http://localhost:3000/api/canopycms',
125
- headers: { get: () => null },
126
- json: async () => undefined,
127
- } as any
128
-
129
- // No params at all
130
- const response: any = await handler(mockNextRequest, undefined)
131
-
132
- expect(response.status).toBe(404)
133
- })
134
- })
135
- })
136
-
137
- describe('wrapNextRequest', () => {
138
- it('wraps NextRequest correctly', async () => {
139
- const { wrapNextRequest } = await import('./adapter')
140
-
141
- const mockReq = {
142
- method: 'POST',
143
- url: 'http://localhost:3000/api/canopycms/branches',
144
- headers: {
145
- get: (name: string) =>
146
- name.toLowerCase() === 'authorization' ? 'Bearer test-token' : null,
147
- },
148
- json: async () => ({ name: 'test-branch' }),
149
- } as any
150
-
151
- const wrapped = wrapNextRequest(mockReq)
152
-
153
- expect(wrapped.method).toBe('POST')
154
- expect(wrapped.url).toBe('http://localhost:3000/api/canopycms/branches')
155
- expect(wrapped.header('Authorization')).toBe('Bearer test-token')
156
- expect(await wrapped.json()).toEqual({ name: 'test-branch' })
157
- })
158
-
159
- it('returns null for missing headers', async () => {
160
- const { wrapNextRequest } = await import('./adapter')
161
-
162
- const mockReq = {
163
- method: 'GET',
164
- url: 'http://localhost:3000/api/test',
165
- headers: {
166
- get: () => null,
167
- },
168
- json: async () => undefined,
169
- } as any
170
-
171
- const wrapped = wrapNextRequest(mockReq)
172
-
173
- expect(wrapped.header('X-Custom-Header')).toBeNull()
174
- })
175
-
176
- it('returns undefined for GET request body', async () => {
177
- const { wrapNextRequest } = await import('./adapter')
178
-
179
- const mockReq = {
180
- method: 'GET',
181
- url: 'http://localhost:3000/api/test',
182
- headers: {
183
- get: () => null,
184
- },
185
- json: async () => {
186
- throw new Error('No body')
187
- },
188
- } as any
189
-
190
- const wrapped = wrapNextRequest(mockReq)
191
-
192
- expect(await wrapped.json()).toBeUndefined()
193
- })
194
- })
package/src/adapter.ts DELETED
@@ -1,105 +0,0 @@
1
- import type { NextRequest } from 'next/server'
2
- import { NextResponse } from 'next/server'
3
-
4
- import {
5
- createCanopyRequestHandler,
6
- type CanopyHandlerOptions,
7
- type CanopyRequest,
8
- type CanopyResponse,
9
- } from 'canopycms/http'
10
-
11
- /**
12
- * Options for creating a Canopy Next.js handler.
13
- * Same as core CanopyHandlerOptions - re-exported for convenience.
14
- */
15
- export interface CanopyNextOptions extends CanopyHandlerOptions {}
16
-
17
- /**
18
- * Wrap a Next.js request to implement the CanopyRequest interface.
19
- */
20
- export function wrapNextRequest(req: NextRequest): CanopyRequest {
21
- return {
22
- method: req.method,
23
- url: req.url,
24
-
25
- header(name: string): string | null {
26
- return req.headers.get(name)
27
- },
28
-
29
- async json(): Promise<unknown> {
30
- if (req.method === 'GET') return undefined
31
- try {
32
- return await req.json()
33
- } catch {
34
- return undefined
35
- }
36
- },
37
- }
38
- }
39
-
40
- /**
41
- * Convert a CanopyResponse to a NextResponse.
42
- */
43
- function toNextResponse(response: CanopyResponse<unknown>): ReturnType<typeof NextResponse.json> {
44
- return NextResponse.json(response.body, {
45
- status: response.status,
46
- headers: response.headers,
47
- })
48
- }
49
-
50
- /**
51
- * Extract path segments from Next.js catch-all route params.
52
- * Handles both Next.js 14 (direct object) and Next.js 15 (Promise) params.
53
- */
54
- async function extractPathSegments(ctx?: {
55
- params?: Promise<{ canopycms?: string[] }> | { canopycms?: string[] }
56
- }): Promise<string[]> {
57
- if (!ctx?.params) return []
58
- const resolvedParams = ctx.params instanceof Promise ? await ctx.params : ctx.params
59
- return (resolvedParams?.canopycms ?? []).filter(Boolean)
60
- }
61
-
62
- /**
63
- * Catch-all Next.js handler for a single API route (e.g., /api/canopycms/[...canopycms]).
64
- *
65
- * This is a thin adapter that:
66
- * 1. Converts NextRequest to CanopyRequest
67
- * 2. Extracts path segments from Next.js params
68
- * 3. Delegates to the core handler
69
- * 4. Converts CanopyResponse to NextResponse
70
- *
71
- * @example
72
- * ```ts
73
- * // app/api/canopycms/[...canopycms]/route.ts
74
- * import { createCanopyCatchAllHandler } from 'canopycms-next'
75
- * import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
76
- * import config from '../../../../canopycms.config'
77
- *
78
- * const handler = createCanopyCatchAllHandler({
79
- * config: config.server,
80
- * authPlugin: createClerkAuthPlugin({ useOrganizationsAsGroups: true }),
81
- * })
82
- *
83
- * export const GET = handler
84
- * export const POST = handler
85
- * export const PUT = handler
86
- * export const DELETE = handler
87
- * ```
88
- */
89
- export const createCanopyCatchAllHandler = (options: CanopyNextOptions) => {
90
- const coreHandler = createCanopyRequestHandler(options)
91
-
92
- return async (
93
- req: NextRequest,
94
- ctx?: {
95
- params?:
96
- | Promise<{ canopycms?: string[]; [key: string]: any }>
97
- | { canopycms?: string[]; [key: string]: any }
98
- },
99
- ) => {
100
- const canopyReq = wrapNextRequest(req)
101
- const segments = await extractPathSegments(ctx)
102
- const response = await coreHandler(canopyReq, segments)
103
- return toNextResponse(response)
104
- }
105
- }
package/src/client.tsx DELETED
@@ -1,36 +0,0 @@
1
- 'use client'
2
-
3
- import { useSearchParams } from 'next/navigation'
4
- import { CanopyEditorPage } from 'canopycms/client'
5
- import type { CanopyClientConfig } from 'canopycms/client'
6
-
7
- /**
8
- * Next.js-specific wrapper for CanopyEditorPage that automatically reads
9
- * URL search params (branch, entry) using Next.js's useSearchParams hook.
10
- *
11
- * @example
12
- * ```tsx
13
- * // app/edit/page.tsx
14
- * 'use client'
15
- * import { NextCanopyEditorPage } from 'canopycms-next/client'
16
- * import config from '../../canopycms.config'
17
- *
18
- * export default function EditPage() {
19
- * const clientConfig = config.client()
20
- * const EditorPage = NextCanopyEditorPage(clientConfig)
21
- * return <EditorPage />
22
- * }
23
- * ```
24
- */
25
- export const NextCanopyEditorPage = (config: CanopyClientConfig) => {
26
- const CorePage = CanopyEditorPage(config)
27
-
28
- return function NextEditorPage() {
29
- const urlSearchParams = useSearchParams()
30
- const searchParams = {
31
- branch: urlSearchParams.get('branch') ?? undefined,
32
- entry: urlSearchParams.get('entry') ?? undefined,
33
- }
34
- return <CorePage searchParams={searchParams} />
35
- }
36
- }
@@ -1,87 +0,0 @@
1
- import { cache } from 'react'
2
- import { headers } from 'next/headers'
3
- import { createCanopyContext, type CanopyContext, createCanopyServices } from 'canopycms/server'
4
- import type { CanopyConfig, AuthPlugin, CanopyUser, FieldConfig } from 'canopycms'
5
- import { authResultToCanopyUser } from 'canopycms'
6
- import { loadInternalGroups, loadBranchContext } from 'canopycms/server'
7
- import type { InternalGroup } from 'canopycms/server'
8
- import { createCanopyCatchAllHandler } from './adapter'
9
-
10
- let warnedNoAdmins = false
11
-
12
- export interface NextCanopyOptions {
13
- config: CanopyConfig
14
- authPlugin: AuthPlugin
15
- entrySchemaRegistry: Record<string, readonly FieldConfig[]>
16
- }
17
-
18
- /**
19
- * Create Next.js-specific wrapper around core context.
20
- * Adds React cache() for per-request memoization and API handler.
21
- * This function is async because it needs to load .collection.json meta files.
22
- */
23
- export async function createNextCanopyContext(options: NextCanopyOptions) {
24
- // Create services ONCE at initialization
25
- const services = await createCanopyServices(options.config, {
26
- entrySchemaRegistry: options.entrySchemaRegistry,
27
- })
28
-
29
- // User extractor: passes Next.js headers to auth plugin, loads internal groups, applies authorization
30
- const extractUser = async (): Promise<CanopyUser> => {
31
- const headersList = await headers()
32
- const authResult = await options.authPlugin.authenticate(headersList)
33
-
34
- // Load internal groups from main branch
35
- const baseBranch = services.config.defaultBaseBranch ?? 'main'
36
- const operatingMode = services.config.mode ?? 'dev'
37
- const mainBranchContext = await loadBranchContext({
38
- branchName: baseBranch,
39
- mode: operatingMode,
40
- })
41
- const internalGroups: InternalGroup[] = mainBranchContext
42
- ? await loadInternalGroups(
43
- mainBranchContext.branchRoot,
44
- operatingMode,
45
- services.bootstrapAdminIds,
46
- ).catch((err: unknown) => {
47
- console.warn('CanopyCMS: Failed to load internal groups from main branch:', err)
48
- return [] as InternalGroup[]
49
- })
50
- : []
51
-
52
- if (!warnedNoAdmins && Array.isArray(internalGroups)) {
53
- const adminsGroup = internalGroups.find((g) => g.id === 'Admins')
54
- if (!adminsGroup || adminsGroup.members.length === 0) {
55
- console.warn(
56
- 'CanopyCMS: No admin users configured. Set CANOPY_BOOTSTRAP_ADMIN_IDS or add members to the Admins group.',
57
- )
58
- }
59
- warnedNoAdmins = true
60
- }
61
-
62
- return authResultToCanopyUser(authResult, services.bootstrapAdminIds, internalGroups)
63
- }
64
-
65
- // Create core context with pre-created services (framework-agnostic)
66
- const coreContext = createCanopyContext({
67
- services,
68
- extractUser,
69
- })
70
-
71
- // Wrap with React cache() for per-request caching
72
- const getCanopy = cache((): Promise<CanopyContext> => {
73
- return coreContext.getContext()
74
- })
75
-
76
- // Create API handler using same services
77
- const handler = createCanopyCatchAllHandler({
78
- ...options,
79
- services,
80
- })
81
-
82
- return {
83
- getCanopy,
84
- handler,
85
- services,
86
- }
87
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export { createCanopyCatchAllHandler, wrapNextRequest, type CanopyNextOptions } from './adapter'
2
-
3
- export { createNextCanopyContext, type NextCanopyOptions } from './context-wrapper'
4
-
5
- export { createMockAuthPlugin, createRejectingAuthPlugin } from './test-utils'
package/src/test-utils.ts DELETED
@@ -1,42 +0,0 @@
1
- import type { AuthPlugin } from 'canopycms/auth'
2
- import type { AuthenticatedUser } from 'canopycms'
3
-
4
- const ADMINS = 'Admins'
5
-
6
- /**
7
- * Create a mock AuthPlugin for testing.
8
- * Returns a valid user by default (as Admin), or can be configured to return specific users.
9
- */
10
- export const createMockAuthPlugin = (
11
- user: AuthenticatedUser = {
12
- type: 'authenticated',
13
- userId: 'test-user',
14
- groups: [ADMINS],
15
- },
16
- ): AuthPlugin => ({
17
- authenticate: async () => ({
18
- success: true,
19
- user: {
20
- userId: user.userId,
21
- email: user.email,
22
- name: user.name,
23
- avatarUrl: user.avatarUrl,
24
- externalGroups: user.groups,
25
- },
26
- }),
27
- searchUsers: async () => [],
28
- getUserMetadata: async () => null,
29
- getGroupMetadata: async () => null,
30
- listGroups: async () => [],
31
- })
32
-
33
- /**
34
- * Create a mock AuthPlugin that rejects all authentication.
35
- */
36
- export const createRejectingAuthPlugin = (error = 'Unauthorized'): AuthPlugin => ({
37
- authenticate: async () => ({ success: false, error }),
38
- searchUsers: async () => [],
39
- getUserMetadata: async () => null,
40
- getGroupMetadata: async () => null,
41
- listGroups: async () => [],
42
- })