@payloadcms/figma 0.1.0-alpha.5 → 0.1.0-alpha.7

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/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2025 Figma
1
+ Copyright (c) 2026 Figma
2
2
 
3
3
  This source code is protected under international copyright law. All rights
4
4
  reserved and protected by the copyright holders.
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export { figmaContentSystem } from './plugin/adapter.js';
4
4
  export type { FigmaContentSystemOptions } from './plugin/adapter.js';
5
5
  export { buildFigmaConfig } from './plugin/build-config.js';
6
6
  export type { FigmaConfig } from './plugin/build-config.js';
7
+ export { passesMakeViewerWriteGate } from './plugin/make-permission-access/make-permission-access.js';
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export { contentAPIAdapter } from './db-content-api/index.js';
4
4
  export { figmaContentSystem } from './plugin/adapter.js';
5
5
  // Export buildConfig wrapper and its config type
6
6
  export { buildFigmaConfig } from './plugin/build-config.js';
7
+ // Export the Make permission predicate for slug-specific baseAccess overrides.
8
+ export { passesMakeViewerWriteGate } from './plugin/make-permission-access/make-permission-access.js';
@@ -1,7 +1,6 @@
1
1
  import type { CollectionConfig, EmailField, TextField } from 'payload';
2
2
  import type { VerifyFunction } from './types.js';
3
3
  export declare const DEFAULT_USER_INFO_COOKIE_NAME = "figma-user-info";
4
- export declare const FIGMA_OAUTH_CALLBACK_PATH = "/users/sso/login";
5
4
  export declare const defaultScope: string[];
6
5
  export declare const defaultUsernameField: TextField;
7
6
  export declare const defaultVerify: ({ collection, strategyName, userInfoCookieName, usernameField, }: {
@@ -3,7 +3,6 @@ import { v4 as uuid } from 'uuid';
3
3
  import { hasUserTokenPropsChanged } from './utilities/hasUserTokenPropsChanged.js';
4
4
  import { isDuplicateKeyError } from './utilities/isDuplicateKeyError.js';
5
5
  export const DEFAULT_USER_INFO_COOKIE_NAME = 'figma-user-info';
6
- export const FIGMA_OAUTH_CALLBACK_PATH = '/users/sso/login';
7
6
  function getFigmaUserInfo(headers, cookieName) {
8
7
  try {
9
8
  const cookies = parseCookies(headers);
@@ -6,7 +6,6 @@ import { createDebugLogger } from '../utilities/createDebugLogger.js';
6
6
  import { establishSession } from '../utilities/establishSession.js';
7
7
  import { exchangeCodeForAccessToken } from '../utilities/exchangeCodeForAccessToken.js';
8
8
  import { extractOrigin } from '../utilities/extractOrigin.js';
9
- import { getAdminCollectionSlug } from '../utilities/getAdminCollectionSlug.js';
10
9
  import { buildCsrfCookieClearHeader, getOAuthCallbackPath, OAUTH_STATE_CSRF_COOKIE_NAME } from '../utilities/getAuthorizeURL.js';
11
10
  import { isAbsoluteURL } from '../utilities/isAbsoluteURL.js';
12
11
  export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy })=>({
@@ -153,7 +152,6 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
153
152
  const redirectUri = formatAdminURL({
154
153
  apiRoute: config.routes?.api || '/api',
155
154
  path: getOAuthCallbackPath({
156
- adminCollectionSlug: getAdminCollectionSlug(config),
157
155
  collection,
158
156
  endpointSlug
159
157
  }),
@@ -1,5 +1,5 @@
1
1
  import { fieldAffectsData } from 'payload/shared';
2
- import { defaultVerify, FIGMA_OAUTH_CALLBACK_PATH } from './defaults.js';
2
+ import { defaultVerify } from './defaults.js';
3
3
  import { getLoginEndpoint } from './endpoints/getLoginEndpoint.js';
4
4
  import { getLogoutEndpoint } from './endpoints/getLogoutEndpoint.js';
5
5
  import { getMetaEndpoint } from './endpoints/getMetaEndpoint.js';
@@ -50,7 +50,6 @@ export const oAuth2Plugin = (pluginOptions)=>(config)=>{
50
50
  path: '@payloadcms/figma/client#DefaultLoginButton'
51
51
  };
52
52
  }
53
- let callbackEndpoint;
54
53
  return {
55
54
  ...config,
56
55
  admin: {
@@ -156,13 +155,6 @@ export const oAuth2Plugin = (pluginOptions)=>(config)=>{
156
155
  pluginOptions,
157
156
  strategy
158
157
  });
159
- const collectionCallbackPath = `/${existingCollection.slug}/${endpointSlug}/login`;
160
- if (existingCollection.slug === adminCollectionSlug && FIGMA_OAUTH_CALLBACK_PATH !== collectionCallbackPath) {
161
- callbackEndpoint = {
162
- ...loginEndpoint,
163
- path: FIGMA_OAUTH_CALLBACK_PATH
164
- };
165
- }
166
158
  const authStrategy = {
167
159
  name: `${existingCollection.slug}-${strategyName}`,
168
160
  // Bind 'this' to ensure the authenticate function has the correct context
@@ -246,12 +238,6 @@ export const oAuth2Plugin = (pluginOptions)=>(config)=>{
246
238
  ]
247
239
  }
248
240
  };
249
- }),
250
- endpoints: [
251
- ...config.endpoints ?? [],
252
- ...callbackEndpoint ? [
253
- callbackEndpoint
254
- ] : []
255
- ]
241
+ })
256
242
  };
257
243
  };
@@ -45,9 +45,7 @@ export interface AuthorizeURLResult {
45
45
  csrfNonce: string;
46
46
  params: AuthorizeURLParams;
47
47
  }
48
- export declare const getOAuthCallbackPath: ({ adminCollectionSlug, collection, endpointSlug, }: {
49
- adminCollectionSlug: string;
50
- } & Pick<Args, "collection" | "endpointSlug">) => `/${string}`;
48
+ export declare const getOAuthCallbackPath: ({ collection, endpointSlug, }: Pick<Args, "collection" | "endpointSlug">) => `/${string}`;
51
49
  export declare const getAuthorizeURL: ({ collection, collectionOptions, endpointSlug, existingCsrfNonce, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy, }: Args) => Promise<AuthorizeURLResult>;
52
50
  /**
53
51
  * Name of the HttpOnly cookie that carries the browser-bound CSRF nonce
@@ -1,9 +1,8 @@
1
1
  import crypto from 'crypto';
2
2
  import * as QueryString from 'qs-esm';
3
3
  import { v4 as uuid } from 'uuid';
4
- import { defaultScope, FIGMA_OAUTH_CALLBACK_PATH } from '../defaults.js';
5
- import { getAdminCollectionSlug } from './getAdminCollectionSlug.js';
6
- export const getOAuthCallbackPath = ({ adminCollectionSlug, collection, endpointSlug })=>collection.slug === adminCollectionSlug ? FIGMA_OAUTH_CALLBACK_PATH : `/${collection.slug}/${endpointSlug}/login`;
4
+ import { defaultScope } from '../defaults.js';
5
+ export const getOAuthCallbackPath = ({ collection, endpointSlug })=>`/${collection.slug}/${endpointSlug}/login`;
7
6
  export const getAuthorizeURL = async ({ collection, collectionOptions, endpointSlug, existingCsrfNonce, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy })=>{
8
7
  const { redirectServerURL } = pluginOptions;
9
8
  await strategy.ensureMeta();
@@ -33,7 +32,6 @@ export const getAuthorizeURL = async ({ collection, collectionOptions, endpointS
33
32
  }
34
33
  const baseRedirectURL = redirectServerURL || payload.config.serverURL || state.serverURL;
35
34
  const loginRedirectURL = encodeURI(`${baseRedirectURL}${payload.config.routes?.api || '/api'}${getOAuthCallbackPath({
36
- adminCollectionSlug: getAdminCollectionSlug(payload.config),
37
35
  collection,
38
36
  endpointSlug
39
37
  })}`);
@@ -1,4 +1,4 @@
1
- import type { Config, SanitizedConfig } from 'payload';
1
+ import type { Config, SanitizedConfig, UploadInstructionsAccess } from 'payload';
2
2
  /**
3
3
  * Worker-trusted marker injected by Figma's sites-worker after it validates the
4
4
  * X-Figma-Job-Queues-Auth-Bypass secret on a Payload jobs endpoint. The default
@@ -48,7 +48,9 @@ export type FigmaConfig = {
48
48
  figma: {
49
49
  contentSystemId?: string;
50
50
  environmentId?: string;
51
- storage?: boolean;
51
+ storage?: {
52
+ uploadInstructionsAccess?: UploadInstructionsAccess;
53
+ } | boolean;
52
54
  useContentSystem?: boolean;
53
55
  };
54
56
  } & Omit<Config, 'db' | 'editor' | 'secret'> & Partial<Pick<Config, 'db'>>;
@@ -22,6 +22,7 @@ import { logMissingCliAuth } from './auth-preflight.js';
22
22
  import { logMissingContentSystemId } from './bootstrap-preflight.js';
23
23
  import { LIMIT_REACHED_VIEW_KEY, LIMIT_REACHED_VIEW_PATH } from './cloud-limits/routes.js';
24
24
  import { getDevCookieNames } from './dev-cookie-names.js';
25
+ import { passesMakeViewerWriteGate, withMakeViewerWriteBaseAccessDefaults } from './make-permission-access/make-permission-access.js';
25
26
  import { createSandboxUploadFetchPlugin } from './sandbox-upload-fetch.js';
26
27
  /**
27
28
  * Worker-trusted marker injected by Figma's sites-worker after it validates the
@@ -170,23 +171,26 @@ function messageOf(error) {
170
171
  }
171
172
  /**
172
173
  * Creates storage configuration for upload collections
173
- */ function createStorageConfig(url, contentSystemId) {
174
- const storageConfig = process.env.FIGMA_CONTENT_API_ACCESS_KEY ? {
175
- baseUrl: url,
176
- contentApiKey: process.env.FIGMA_CONTENT_API_ACCESS_KEY
177
- } : process.env.FIGMA_DEV_JWT === 'true' ? {
178
- auth: {
179
- mode: 'devJwt'
180
- },
181
- baseUrl: url,
182
- contentSystemId
183
- } : {
184
- auth: {
185
- mode: 'tokenStore',
186
- tokenStore: getTokenStore()
174
+ */ function createStorageConfig(url, contentSystemId, uploadInstructionsAccess) {
175
+ const storageConfig = {
176
+ ...process.env.FIGMA_CONTENT_API_ACCESS_KEY ? {
177
+ baseUrl: url,
178
+ contentApiKey: process.env.FIGMA_CONTENT_API_ACCESS_KEY
179
+ } : process.env.FIGMA_DEV_JWT === 'true' ? {
180
+ auth: {
181
+ mode: 'devJwt'
182
+ },
183
+ baseUrl: url,
184
+ contentSystemId
185
+ } : {
186
+ auth: {
187
+ mode: 'tokenStore',
188
+ tokenStore: getTokenStore()
189
+ },
190
+ baseUrl: url,
191
+ contentSystemId
187
192
  },
188
- baseUrl: url,
189
- contentSystemId
193
+ uploadInstructionsAccess
190
194
  };
191
195
  const adapter = contentApiStorageAdapter(storageConfig);
192
196
  const storageClient = createStorageClient(storageConfig);
@@ -198,13 +202,13 @@ function messageOf(error) {
198
202
  /**
199
203
  * Plugin that applies cloud storage configuration to all upload-enabled collections.
200
204
  * Runs at the end of the plugin chain so it sees collections added by user plugins too.
201
- */ function createStoragePlugin(url, contentSystemId) {
205
+ */ function createStoragePlugin(url, contentSystemId, uploadInstructionsAccess) {
202
206
  return (incomingConfig)=>{
203
207
  const uploadCollections = (incomingConfig.collections || []).filter((c)=>c.upload);
204
208
  if (uploadCollections.length === 0) {
205
209
  return incomingConfig;
206
210
  }
207
- const { adapter } = createStorageConfig(url, contentSystemId);
211
+ const { adapter } = createStorageConfig(url, contentSystemId, uploadInstructionsAccess);
208
212
  const collectionsMap = uploadCollections.reduce((acc, c)=>{
209
213
  acc[c.slug] = {
210
214
  adapter,
@@ -338,9 +342,15 @@ export async function buildFigmaConfig(config) {
338
342
  ]
339
343
  }
340
344
  },
345
+ // Payload applies this policy during sanitization, after user plugins and
346
+ // generated resources have registered their collections and globals.
347
+ baseAccess: withMakeViewerWriteBaseAccessDefaults(config.baseAccess),
341
348
  custom: {
342
349
  figma: {
343
350
  ...config.figma,
351
+ ...typeof config.figma.storage === 'object' ? {
352
+ storage: true
353
+ } : {},
344
354
  contentSystemId,
345
355
  environmentId
346
356
  }
@@ -358,8 +368,8 @@ export async function buildFigmaConfig(config) {
358
368
  },
359
369
  // Gate /api/payload-jobs/run and /api/payload-jobs/handle-schedules. Honor the
360
370
  // sites-worker trust marker so Figma infra job runs never break, defer to the
361
- // tenant's jobs.access.run if they set one, otherwise fall back to Payload's
362
- // "logged-in user" default so the admin "Run jobs" UI keeps working.
371
+ // tenant's jobs.access.run if they set one, otherwise require an authenticated
372
+ // user who is not a known Make viewer.
363
373
  jobs: {
364
374
  ...config.jobs ?? {},
365
375
  access: {
@@ -371,7 +381,9 @@ export async function buildFigmaConfig(config) {
371
381
  if (userJobsAccessRun) {
372
382
  return userJobsAccessRun(args);
373
383
  }
374
- return !!args.req.user;
384
+ // Allow undefined Make permissions during rollout, but fail closed for empty,
385
+ // explicit view, and other unrecognized values.
386
+ return Boolean(args.req.user) && passesMakeViewerWriteGate(args.req.user);
375
387
  }
376
388
  }
377
389
  },
@@ -385,7 +397,7 @@ export async function buildFigmaConfig(config) {
385
397
  plugins: [
386
398
  ...config.plugins ?? [],
387
399
  ...config.figma.storage !== false ? [
388
- createStoragePlugin(url, contentSystemId)
400
+ createStoragePlugin(url, contentSystemId, typeof config.figma.storage === 'object' ? config.figma.storage.uploadInstructionsAccess : undefined)
389
401
  ] : [],
390
402
  oAuth2Plugin({
391
403
  collections: [
@@ -0,0 +1,8 @@
1
+ import type { Access, BaseAccess } from 'payload';
2
+ /**
3
+ * we will still let a user edit if makePermission is null or undefined, because we are
4
+ * specifically restricting users for a figma cloud project backed by make
5
+ */
6
+ export declare function passesMakeViewerWriteGate(user: unknown): boolean;
7
+ export declare const makeViewerWriteBaseAccess: Access;
8
+ export declare function withMakeViewerWriteBaseAccessDefaults(existingBaseAccess?: BaseAccess): BaseAccess;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * we will still let a user edit if makePermission is null or undefined, because we are
3
+ * specifically restricting users for a figma cloud project backed by make
4
+ */ export function passesMakeViewerWriteGate(user) {
5
+ const makePermission = user?.makePermission;
6
+ return makePermission === undefined || makePermission === null || makePermission === 'edit';
7
+ }
8
+ export const makeViewerWriteBaseAccess = ({ req })=>passesMakeViewerWriteGate(req.user);
9
+ export function withMakeViewerWriteBaseAccessDefaults(existingBaseAccess = {}) {
10
+ return {
11
+ ...existingBaseAccess,
12
+ collections: {
13
+ ...existingBaseAccess.collections,
14
+ create: existingBaseAccess.collections?.create ?? makeViewerWriteBaseAccess,
15
+ delete: existingBaseAccess.collections?.delete ?? makeViewerWriteBaseAccess,
16
+ unlock: existingBaseAccess.collections?.unlock ?? makeViewerWriteBaseAccess,
17
+ update: existingBaseAccess.collections?.update ?? makeViewerWriteBaseAccess
18
+ },
19
+ globals: {
20
+ ...existingBaseAccess.globals,
21
+ update: existingBaseAccess.globals?.update ?? makeViewerWriteBaseAccess
22
+ }
23
+ };
24
+ }
@@ -1,7 +1,8 @@
1
1
  import path from 'path';
2
2
  import { APIError, Forbidden } from 'payload';
3
+ import { passesMakeViewerWriteGate } from '../../plugin/make-permission-access/make-permission-access.js';
3
4
  import { getSafeFilename } from '../utilities/index.js';
4
- const defaultAccess = ({ req })=>!!req.user;
5
+ const defaultAccess = ({ req })=>Boolean(req.user) && passesMakeViewerWriteGate(req.user);
5
6
  /**
6
7
  * v4 client-upload entry point. Payload core calls this from
7
8
  * `POST /api/upload-instructions`; we sign a Content API upload URL and hand the
@@ -28,6 +28,7 @@ export function contentApiStorageAdapter(config) {
28
28
  uploadInstructions: {
29
29
  enabled: true,
30
30
  generate: getGenerateUploadInstructions({
31
+ access: config.uploadInstructionsAccess,
31
32
  client
32
33
  }),
33
34
  useInAdmin: true
@@ -1,9 +1,12 @@
1
+ import type { UploadInstructionsAccess } from 'payload';
1
2
  import type { AuthMode } from '../db-content-api/utilities/auth.js';
2
3
  export type StorageClientConfig = {
4
+ uploadInstructionsAccess?: UploadInstructionsAccess;
5
+ } & ({
3
6
  auth: AuthMode;
4
7
  baseUrl: string;
5
8
  contentSystemId: string;
6
9
  } | {
7
10
  baseUrl: string;
8
11
  contentApiKey: string;
9
- };
12
+ });
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { collectSSGAssets, collectStaticAssets } from '../asset-collection.js';
3
+ import { collectSSGAssets, collectStaticAssets, collectStaticMetadataAssets } from '../asset-collection.js';
4
4
  import { buildLambdaZip } from '../build-lambda-zip.js';
5
5
  import { resolveOutputPath } from '../deploy-output/resolveOutputPath.js';
6
6
  import { collectFilesRecursive } from '../fs-utils.js';
@@ -19,10 +19,7 @@ export class NextjsAdapter {
19
19
  } catch {
20
20
  // No public directory
21
21
  }
22
- const allKeys = [
23
- ...staticAssets,
24
- ...publicAssets
25
- ];
22
+ const metadataAssets = await collectStaticMetadataAssets(projectPath, output);
26
23
  const pathMap = {};
27
24
  for (const key of staticAssets){
28
25
  pathMap[key] = path.relative(projectPath, path.join(outputPath, key.replace(/^_next\//, '')));
@@ -30,29 +27,25 @@ export class NextjsAdapter {
30
27
  for (const key of publicAssets){
31
28
  pathMap[key] = path.join('public', key);
32
29
  }
30
+ for (const key of metadataAssets.keys){
31
+ pathMap[key] = metadataAssets.pathMap[key];
32
+ }
33
+ const uploadKeys = Object.keys(pathMap);
33
34
  return {
34
35
  pathMap,
35
- routes: allKeys.map((k)=>`/${k}`),
36
- uploadKeys: allKeys
36
+ routes: uploadKeys.map((key)=>`/${key}`),
37
+ uploadKeys
37
38
  };
38
39
  }
39
40
  async collectPages(projectPath, output) {
40
41
  const ssg = await collectSSGAssets(projectPath, output);
41
- // Build route asset keys mapping and extract unique routes
42
- const routeAssetMap = {};
43
- for (const key of ssg.keys){
44
- // Strip extension to get the base, then derive the route
45
- const base = key.replace(/\.(?:html|rsc|meta)$/, '');
46
- const route = base === 'index' ? '/' : `/${base}`;
47
- if (!routeAssetMap[route]) {
48
- routeAssetMap[route] = [];
49
- }
50
- routeAssetMap[route].push(key);
51
- }
42
+ // collectSSGAssets records each key's manifest route while scanning the
43
+ // output, preserving the existing mapping for HTML/RSC/meta files while
44
+ // supporting segment keys whose nested names do not encode the page route.
52
45
  return {
53
46
  pathMap: ssg.pathMap,
54
- routeAssetMap,
55
- routes: Object.keys(routeAssetMap),
47
+ routeAssetMap: ssg.routeAssetMap,
48
+ routes: Object.keys(ssg.routeAssetMap),
56
49
  uploadKeys: ssg.keys
57
50
  };
58
51
  }
@@ -23,14 +23,18 @@ export type SSGAssets = {
23
23
  keys: string[];
24
24
  /** Map of S3 key → relative filesystem path from project root */
25
25
  pathMap: Record<string, string>;
26
+ /** Map of page route → S3 keys generated for that page */
27
+ routeAssetMap: Record<string, string[]>;
26
28
  };
29
+ type StaticMetadataAssets = Omit<SSGAssets, 'routeAssetMap'>;
27
30
  /**
28
31
  * Collect SSG asset paths by parsing .next/prerender-manifest.json
29
32
  *
30
- * For each route in the manifest's `routes` object, collects three files:
33
+ * For each page route in the manifest's `routes` object, collects:
31
34
  * - {route}.html (full HTML)
32
35
  * - {route}.rsc (React Server Components payload)
33
36
  * - {route}.meta (response headers JSON)
37
+ * - Nested `.segment.rsc` files under {route}.segments (segment-prefetch payloads)
34
38
  *
35
39
  * S3 keys use the route path without leading slash.
36
40
  * Filesystem paths point to .next/server/app/{route}.{ext}
@@ -39,6 +43,14 @@ export type SSGAssets = {
39
43
  * @returns SSG asset keys and a pathMap for filesystem resolution
40
44
  */
41
45
  export declare function collectSSGAssets(projectPath: string, output?: string): Promise<SSGAssets>;
46
+ /**
47
+ * Collect file-based Next.js metadata icons that were prerendered as static route handlers.
48
+ *
49
+ * Maps public asset keys such as `shop/icon1.png` to their raw
50
+ * `.next/server/app/shop/icon1.png.body` output.
51
+ * Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
52
+ */
53
+ export declare function collectStaticMetadataAssets(projectPath: string, output?: string): Promise<StaticMetadataAssets>;
42
54
  /**
43
55
  * Create Lambda deployment zip
44
56
  *
@@ -48,3 +60,4 @@ export declare function collectSSGAssets(projectPath: string, output?: string):
48
60
  * @throws Error if build missing or zip creation fails
49
61
  */
50
62
  export declare function createLambdaZip(projectPath: string): Promise<void>;
63
+ export {};
@@ -77,10 +77,11 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
77
77
  /**
78
78
  * Collect SSG asset paths by parsing .next/prerender-manifest.json
79
79
  *
80
- * For each route in the manifest's `routes` object, collects three files:
80
+ * For each page route in the manifest's `routes` object, collects:
81
81
  * - {route}.html (full HTML)
82
82
  * - {route}.rsc (React Server Components payload)
83
83
  * - {route}.meta (response headers JSON)
84
+ * - Nested `.segment.rsc` files under {route}.segments (segment-prefetch payloads)
84
85
  *
85
86
  * S3 keys use the route path without leading slash.
86
87
  * Filesystem paths point to .next/server/app/{route}.{ext}
@@ -97,30 +98,106 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
97
98
  } catch {
98
99
  return {
99
100
  keys: [],
100
- pathMap: {}
101
+ pathMap: {},
102
+ routeAssetMap: {}
101
103
  };
102
104
  }
103
105
  if (!manifest.routes || typeof manifest.routes !== 'object') {
104
106
  return {
105
107
  keys: [],
106
- pathMap: {}
108
+ pathMap: {},
109
+ routeAssetMap: {}
107
110
  };
108
111
  }
109
112
  const keys = [];
110
113
  const pathMap = {};
114
+ const routeAssetMap = {};
111
115
  const extensions = [
112
116
  '.html',
113
117
  '.rsc',
114
118
  '.meta'
115
119
  ];
116
- for (const routeKey of Object.keys(manifest.routes)){
120
+ const appDir = path.join(outputPath, 'server', 'app');
121
+ for (const [routeKey, route] of Object.entries(manifest.routes)){
122
+ // Current manifests classify app pages explicitly. Older App Router manifests
123
+ // can be identified by their RSC data route. Other entries may be route handlers.
124
+ const isPageRoute = route.routeType === 'page' || route.routeType === undefined && typeof route.dataRoute === 'string' && route.dataRoute.endsWith('.rsc');
125
+ if (!isPageRoute) {
126
+ continue;
127
+ }
117
128
  // Strip leading slash for S3 key; handle root "/" → "index"
118
129
  const stripped = routeKey === '/' ? 'index' : routeKey.replace(/^\//, '');
130
+ const routeAssets = [];
119
131
  for (const ext of extensions){
120
132
  const s3Key = `${stripped}${ext}`;
121
133
  keys.push(s3Key);
122
- pathMap[s3Key] = path.relative(projectPath, path.join(outputPath, 'server', 'app', `${stripped}${ext}`));
134
+ routeAssets.push(s3Key);
135
+ pathMap[s3Key] = path.relative(projectPath, path.join(appDir, s3Key));
136
+ }
137
+ // Next.js stores segment-prefetch payloads beside each prerendered page in a
138
+ // `<page>.segments` tree. Scan the generated tree instead of reconstructing
139
+ // its opaque, potentially nested segment names from route information.
140
+ const segmentFiles = await collectFiles(path.join(appDir, `${stripped}.segments`), appDir);
141
+ for (const segmentFile of segmentFiles.filter((file)=>file.endsWith('.segment.rsc'))){
142
+ const s3Key = segmentFile.split(path.sep).join('/');
143
+ keys.push(s3Key);
144
+ routeAssets.push(s3Key);
145
+ pathMap[s3Key] = path.relative(projectPath, path.join(appDir, segmentFile));
123
146
  }
147
+ // Preserve the manifest route association for the original HTML/RSC/meta
148
+ // files and the new segment files; segment keys cannot be mapped back to a
149
+ // page reliably from their filename in NextjsAdapter.
150
+ routeAssetMap[routeKey] = routeAssets;
151
+ }
152
+ return {
153
+ keys,
154
+ pathMap,
155
+ routeAssetMap
156
+ };
157
+ }
158
+ /**
159
+ * Collect file-based Next.js metadata icons that were prerendered as static route handlers.
160
+ *
161
+ * Maps public asset keys such as `shop/icon1.png` to their raw
162
+ * `.next/server/app/shop/icon1.png.body` output.
163
+ * Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
164
+ */ export async function collectStaticMetadataAssets(projectPath, output) {
165
+ const outputPath = resolveOutputPath(projectPath, 'nextjs', output);
166
+ const manifestPath = path.join(outputPath, 'prerender-manifest.json');
167
+ let manifest;
168
+ try {
169
+ const raw = await fs.readFile(manifestPath, 'utf-8');
170
+ manifest = JSON.parse(raw);
171
+ } catch {
172
+ return {
173
+ keys: [],
174
+ pathMap: {}
175
+ };
176
+ }
177
+ if (!manifest.routes || typeof manifest.routes !== 'object') {
178
+ return {
179
+ keys: [],
180
+ pathMap: {}
181
+ };
182
+ }
183
+ const keys = [];
184
+ const pathMap = {};
185
+ for (const [routeKey, route] of Object.entries(manifest.routes)){
186
+ if (route.routeType !== 'route' || route.compute !== 'static' || route.response !== 'complete' || !isFileBasedMetadataIconRoute(routeKey)) {
187
+ continue;
188
+ }
189
+ const assetKey = routeKey.replace(/^\/+/, '');
190
+ const bodyPath = path.join(outputPath, 'server', 'app', `${assetKey}.body`);
191
+ try {
192
+ const bodyStat = await fs.stat(bodyPath);
193
+ if (!bodyStat.isFile()) {
194
+ continue;
195
+ }
196
+ } catch {
197
+ continue;
198
+ }
199
+ keys.push(assetKey);
200
+ pathMap[assetKey] = path.relative(projectPath, bodyPath);
124
201
  }
125
202
  return {
126
203
  keys,
@@ -137,3 +214,10 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
137
214
  */ export async function createLambdaZip(projectPath) {
138
215
  await buildLambdaZip(projectPath);
139
216
  }
217
+ function isFileBasedMetadataIconRoute(routeKey) {
218
+ const basename = path.posix.basename(routeKey);
219
+ if (routeKey === '/favicon.ico') {
220
+ return true;
221
+ }
222
+ return /^icon\d*(?:-[0-9a-z]{6})?\.(?:ico|jpe?g|png|svg)$/.test(basename) || /^apple-icon\d*(?:-[0-9a-z]{6})?\.(?:jpe?g|png)$/.test(basename);
223
+ }
@@ -141,16 +141,19 @@ const INITIAL_RETRY_DELAY = 1000 // 1 second
141
141
  const fsPath = pathMap?.[assetPath] ?? assetPath.replace(/^_next\//, '.next/');
142
142
  const fullPath = path.join(projectPath, fsPath);
143
143
  const signedUrl = assetUrls[assetPath];
144
- await uploadFile(fullPath, signedUrl);
144
+ const contentType = MIME_TYPES[path.extname(assetPath).toLowerCase()];
145
+ await uploadFile(fullPath, signedUrl, contentType);
145
146
  results.assetsUploaded++;
146
147
  // Track bytes uploaded
147
148
  const stats = await fs.stat(fullPath);
148
149
  results.totalBytesUploaded += stats.size;
149
150
  // Report progress
150
151
  onProgress?.(results.assetsUploaded, assetPaths.length);
152
+ return undefined;
151
153
  } catch (error) {
152
154
  results.assetsFailed++;
153
155
  log.debug(`Failed to upload ${assetPath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
156
+ return undefined;
154
157
  }
155
158
  }));
156
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,6 +47,8 @@
47
47
  "open": "^10.2.0",
48
48
  "openapi-fetch": "0.15.0",
49
49
  "picocolors": "1.1.1",
50
+ "qs-esm": "^8.0.1",
51
+ "sanitize-filename": "^1.6.4",
50
52
  "skills": "1.4.4",
51
53
  "tar": "^7.5.13",
52
54
  "terminal-link": "^5.0.0",
@@ -56,7 +58,11 @@
56
58
  "devDependencies": {
57
59
  "@payloadcms/eslint-config": "3.28.0",
58
60
  "@payloadcms/eslint-plugin": "3.28.0",
59
- "@payloadcms/next": "4.0.0-canary.24",
61
+ "@payloadcms/next": "4.0.0-canary.28",
62
+ "@payloadcms/plugin-cloud-storage": "4.0.0-canary.28",
63
+ "@payloadcms/richtext-lexical": "4.0.0-canary.28",
64
+ "@payloadcms/translations": "4.0.0-canary.28",
65
+ "@payloadcms/ui": "4.0.0-canary.28",
60
66
  "@swc/cli": "0.7.7",
61
67
  "@types/archiver": "7.0.0",
62
68
  "@types/cross-spawn": "6.0.6",
@@ -67,15 +73,19 @@
67
73
  "eslint": "9.22.0",
68
74
  "next": "^16.2.6",
69
75
  "openapi-typescript": "^7.13.0",
76
+ "payload": "4.0.0-canary.28",
70
77
  "rimraf": "^6.1.3",
71
78
  "tsx": "4.20.6",
72
79
  "typescript": "5.7.3",
73
80
  "vitest": "4.0.15"
74
81
  },
75
82
  "peerDependencies": {
76
- "@payloadcms/plugin-cloud-storage": ">=4.0.0-canary.24 <4.0.0-internal",
77
- "@payloadcms/richtext-lexical": ">=4.0.0-canary.24 <4.0.0-internal",
78
- "payload": ">=4.0.0-canary.24 <4.0.0-internal"
83
+ "@payloadcms/plugin-cloud-storage": ">=4.0.0-canary.28 <4.0.0-internal",
84
+ "@payloadcms/richtext-lexical": ">=4.0.0-canary.28 <4.0.0-internal",
85
+ "@payloadcms/translations": ">=4.0.0-canary.28 <4.0.0-internal",
86
+ "@payloadcms/ui": ">=4.0.0-canary.28 <4.0.0-internal",
87
+ "payload": ">=4.0.0-canary.28 <4.0.0-internal",
88
+ "react": "^19.0.1 || ^19.1.2 || ^19.2.1"
79
89
  },
80
90
  "peerDependenciesMeta": {
81
91
  "@payloadcms/plugin-cloud-storage": {