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

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.
@@ -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
  })}`);
@@ -0,0 +1 @@
1
+ export declare const buildBaseMediaResponseHeaders: () => Headers;
@@ -0,0 +1,6 @@
1
+ export const buildBaseMediaResponseHeaders = ()=>new Headers({
2
+ 'Accept-Ranges': 'bytes',
3
+ 'Cache-Control': 'private, no-store',
4
+ Vary: 'Cookie',
5
+ 'X-Content-Type-Options': 'nosniff'
6
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Payload's server-side upload fetch-back uses the response Content-Type to decide whether to
3
+ * inspect image dimensions. For an active image type such as SVG, omit the sanitized type so
4
+ * Payload falls back to the original upload MIME type. Public file requests never use this helper.
5
+ */
6
+ export declare const buildClientUploadFetchBackHeaders: ({ fallbackContentType, filename, upstream, }: {
7
+ fallbackContentType?: string;
8
+ filename: string;
9
+ upstream: Headers;
10
+ }) => Headers;
@@ -0,0 +1,21 @@
1
+ import { buildMediaResponseHeaders } from './buildMediaResponseHeaders.js';
2
+ import { getInlineContentType } from './getInlineContentType.js';
3
+ /**
4
+ * Payload's server-side upload fetch-back uses the response Content-Type to decide whether to
5
+ * inspect image dimensions. For an active image type such as SVG, omit the sanitized type so
6
+ * Payload falls back to the original upload MIME type. Public file requests never use this helper.
7
+ */ export const buildClientUploadFetchBackHeaders = ({ fallbackContentType, filename, upstream })=>{
8
+ const effectiveContentType = upstream.get('Content-Type') || fallbackContentType;
9
+ const upstreamWithFallback = new Headers(upstream);
10
+ if (effectiveContentType && !upstream.get('Content-Type')) {
11
+ upstreamWithFallback.set('Content-Type', effectiveContentType);
12
+ }
13
+ const headers = buildMediaResponseHeaders({
14
+ filename,
15
+ upstream: upstreamWithFallback
16
+ });
17
+ if (effectiveContentType && !getInlineContentType(effectiveContentType)) {
18
+ headers.delete('Content-Type');
19
+ }
20
+ return headers;
21
+ };
@@ -0,0 +1,4 @@
1
+ export declare const buildMediaResponseHeaders: ({ filename, upstream, }: {
2
+ filename: string;
3
+ upstream: Headers;
4
+ }) => Headers;
@@ -0,0 +1,45 @@
1
+ import { buildBaseMediaResponseHeaders } from './buildBaseMediaResponseHeaders.js';
2
+ import { getInlineContentType } from './getInlineContentType.js';
3
+ const FORWARDED_MEDIA_RESPONSE_HEADERS = [
4
+ 'Cache-Control',
5
+ 'Content-Length',
6
+ 'Content-Range',
7
+ 'ETag',
8
+ 'Last-Modified'
9
+ ];
10
+ const ATTACHMENT_CSP = "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'";
11
+ export const buildMediaResponseHeaders = ({ filename, upstream })=>{
12
+ const headers = buildBaseMediaResponseHeaders();
13
+ for (const name of FORWARDED_MEDIA_RESPONSE_HEADERS){
14
+ const value = upstream.get(name);
15
+ if (value) {
16
+ headers.set(name, value);
17
+ }
18
+ }
19
+ const contentType = getInlineContentType(upstream.get('Content-Type'));
20
+ if (contentType) {
21
+ headers.set('Content-Type', contentType);
22
+ } else {
23
+ headers.set('Content-Type', 'application/octet-stream');
24
+ headers.set('Content-Disposition', attachmentDisposition(filename));
25
+ headers.set('Content-Security-Policy', ATTACHMENT_CSP);
26
+ }
27
+ return headers;
28
+ };
29
+ const attachmentDisposition = (filename)=>{
30
+ let decodedName;
31
+ try {
32
+ decodedName = decodeURIComponent(filename);
33
+ } catch {
34
+ decodedName = 'download';
35
+ }
36
+ const sanitizedName = sanitizeFilename(decodedName);
37
+ const safeName = Array.from(sanitizedName).slice(0, 255).join('') || 'download';
38
+ const asciiName = safeName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
39
+ const utf8Name = encodeURIComponent(safeName).replace(/[!'()*]/g, (character)=>`%${character.charCodeAt(0).toString(16).toUpperCase()}`);
40
+ return `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`;
41
+ };
42
+ const sanitizeFilename = (filename)=>Array.from(filename, (character)=>{
43
+ const codePoint = character.codePointAt(0);
44
+ return codePoint < 32 || codePoint === 127 || character === '/' || character === '\\' ? '_' : character;
45
+ }).join('');
@@ -0,0 +1 @@
1
+ export declare const getInlineContentType: (value: null | string | undefined) => string | null;
@@ -0,0 +1,33 @@
1
+ // Keep this allowlist aligned with Gatekeeper's presignedMediaHeaders.ts. The plugin cannot
2
+ // import the private Gatekeeper service, but both same-origin media proxies need the same policy.
3
+ const INLINE_CONTENT_TYPES = new Set([
4
+ 'application/font-woff',
5
+ 'audio/aac',
6
+ 'audio/flac',
7
+ 'audio/mp4',
8
+ 'audio/mpeg',
9
+ 'audio/ogg',
10
+ 'audio/wav',
11
+ 'audio/webm',
12
+ 'font/otf',
13
+ 'font/ttf',
14
+ 'font/woff',
15
+ 'font/woff2',
16
+ 'image/avif',
17
+ 'image/bmp',
18
+ 'image/gif',
19
+ 'image/jpeg',
20
+ 'image/png',
21
+ 'image/vnd.microsoft.icon',
22
+ 'image/webp',
23
+ 'image/x-icon',
24
+ 'video/mp4',
25
+ 'video/mpeg',
26
+ 'video/ogg',
27
+ 'video/quicktime',
28
+ 'video/webm'
29
+ ]);
30
+ export const getInlineContentType = (value)=>{
31
+ const normalized = value?.split(';', 1).at(0)?.trim().toLowerCase();
32
+ return normalized && INLINE_CONTENT_TYPES.has(normalized) ? normalized : null;
33
+ };
@@ -0,0 +1 @@
1
+ export declare const hasSupportedContentEncoding: (headers: Headers) => boolean;
@@ -0,0 +1,4 @@
1
+ export const hasSupportedContentEncoding = (headers)=>{
2
+ const contentEncoding = headers.get('content-encoding')?.trim().toLowerCase();
3
+ return contentEncoding == null || contentEncoding === '' || contentEncoding === 'identity';
4
+ };
@@ -0,0 +1 @@
1
+ export declare const mediaErrorResponse: (status: number, body: string, contentRange?: string) => Response;
@@ -0,0 +1,12 @@
1
+ import { buildBaseMediaResponseHeaders } from './buildBaseMediaResponseHeaders.js';
2
+ export const mediaErrorResponse = (status, body, contentRange)=>{
3
+ const headers = buildBaseMediaResponseHeaders();
4
+ headers.set('Content-Type', 'text/plain;charset=UTF-8');
5
+ if (contentRange) {
6
+ headers.set('Content-Range', contentRange);
7
+ }
8
+ return new Response(body, {
9
+ headers,
10
+ status
11
+ });
12
+ };
@@ -1,7 +1,29 @@
1
1
  import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities';
2
2
  import path from 'path';
3
3
  import { isImage } from 'payload/shared';
4
+ import { buildClientUploadFetchBackHeaders } from './buildClientUploadFetchBackHeaders.js';
5
+ import { buildMediaResponseHeaders } from './buildMediaResponseHeaders.js';
6
+ import { hasSupportedContentEncoding } from './hasSupportedContentEncoding.js';
7
+ import { mediaErrorResponse } from './mediaErrorResponse.js';
4
8
  import { parseUploadReference } from './utilities/index.js';
9
+ const isRunningInLambda = ()=>process.env.AWS_EXECUTION_ENV?.startsWith('AWS_Lambda_') === true;
10
+ const shouldReturnRedirect = ({ uploadReference })=>{
11
+ if (!isRunningInLambda()) {
12
+ return false;
13
+ }
14
+ if (!uploadReference) {
15
+ return true;
16
+ }
17
+ const { mimeType } = parseUploadReference(uploadReference);
18
+ return Boolean(mimeType && !isImage(mimeType));
19
+ };
20
+ const cancelResponseBody = async (response)=>{
21
+ try {
22
+ await response.body?.cancel();
23
+ } catch {
24
+ // The response will not be consumed after an error; cancellation is best-effort cleanup.
25
+ }
26
+ };
5
27
  export const getHandler = ({ client, collection })=>{
6
28
  return async (req, { params: { filename, uploadReference } })=>{
7
29
  try {
@@ -25,29 +47,68 @@ export const getHandler = ({ client, collection })=>{
25
47
  status: 500
26
48
  });
27
49
  }
28
- // On a client-upload fetch-back, core reads this response body into req.file.data to
29
- // size the image; a 302's empty body crashes getImageSize. Return real bytes for
30
- // images (and unknown mimetypes, to be safe), but keep the redirect otherwise so we
31
- // don't download large non-images server-side.
32
- if (uploadReference) {
33
- const { mimeType } = parseUploadReference(uploadReference);
34
- if (!mimeType || isImage(mimeType)) {
35
- const fileResponse = await fetch(data.url);
36
- if (!fileResponse.ok) {
37
- req.payload.logger.error(`Failed to fetch file for client-upload buffer fetch-back: ${fileResponse.status}`);
38
- return new Response('Internal Server Error', {
39
- status: 500
40
- });
41
- }
42
- const contentType = fileResponse.headers.get('Content-Type') ?? mimeType ?? 'application/octet-stream';
43
- return new Response(await fileResponse.arrayBuffer(), {
44
- headers: {
45
- 'Content-Type': contentType
46
- }
47
- });
50
+ // Lambda responses have a 6 MB limit, so deployed apps redirect to Gatekeeper, which
51
+ // validates the signed URL and streams the file. Other runtimes do not have Gatekeeper
52
+ // in front of them and must stream the file through Payload instead.
53
+ // An image uploadReference marks an internal, one-time fetch-back. Payload core consumes
54
+ // the body to size the image, so a redirect would leave it empty even in Lambda.
55
+ if (shouldReturnRedirect({
56
+ uploadReference
57
+ })) {
58
+ return Response.redirect(data.url, 302);
59
+ }
60
+ const requestHeaders = new Headers({
61
+ 'Accept-Encoding': 'identity'
62
+ });
63
+ const range = req.headers.get('Range');
64
+ if (range) {
65
+ requestHeaders.set('Range', range);
66
+ const ifRange = req.headers.get('If-Range');
67
+ if (ifRange) {
68
+ requestHeaders.set('If-Range', ifRange);
48
69
  }
49
70
  }
50
- return Response.redirect(data.url, 302);
71
+ let fileResponse;
72
+ try {
73
+ fileResponse = await fetch(data.url, {
74
+ credentials: 'omit',
75
+ headers: requestHeaders,
76
+ redirect: 'manual',
77
+ referrerPolicy: 'no-referrer',
78
+ signal: req.signal
79
+ });
80
+ } catch {
81
+ req.payload.logger.error('Failed to fetch file from storage');
82
+ return mediaErrorResponse(500, 'Internal Server Error');
83
+ }
84
+ if (fileResponse.status === 416 && range) {
85
+ await cancelResponseBody(fileResponse);
86
+ return mediaErrorResponse(416, 'Range Not Satisfiable', fileResponse.headers.get('Content-Range') ?? undefined);
87
+ }
88
+ if (!fileResponse.ok) {
89
+ await cancelResponseBody(fileResponse);
90
+ req.payload.logger.error(`Failed to fetch file from storage: ${fileResponse.status}`);
91
+ return mediaErrorResponse(500, 'Internal Server Error');
92
+ }
93
+ if (!hasSupportedContentEncoding(fileResponse.headers)) {
94
+ await cancelResponseBody(fileResponse);
95
+ req.payload.logger.error('Unsupported Content-Encoding from storage');
96
+ return mediaErrorResponse(502, 'Bad Gateway');
97
+ }
98
+ const clientUploadMimeType = uploadReference ? parseUploadReference(uploadReference).mimeType : undefined;
99
+ const responseHeaders = uploadReference ? buildClientUploadFetchBackHeaders({
100
+ fallbackContentType: clientUploadMimeType,
101
+ filename,
102
+ upstream: fileResponse.headers
103
+ }) : buildMediaResponseHeaders({
104
+ filename,
105
+ upstream: fileResponse.headers
106
+ });
107
+ return new Response(fileResponse.body, {
108
+ headers: responseHeaders,
109
+ status: fileResponse.status,
110
+ statusText: fileResponse.statusText
111
+ });
51
112
  } catch (err) {
52
113
  req.payload.logger.error({
53
114
  err,
@@ -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,10 +27,14 @@ 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) {
@@ -27,7 +27,7 @@ export type SSGAssets = {
27
27
  /**
28
28
  * Collect SSG asset paths by parsing .next/prerender-manifest.json
29
29
  *
30
- * For each route in the manifest's `routes` object, collects three files:
30
+ * For each page route in the manifest's `routes` object, collects three files:
31
31
  * - {route}.html (full HTML)
32
32
  * - {route}.rsc (React Server Components payload)
33
33
  * - {route}.meta (response headers JSON)
@@ -39,6 +39,14 @@ export type SSGAssets = {
39
39
  * @returns SSG asset keys and a pathMap for filesystem resolution
40
40
  */
41
41
  export declare function collectSSGAssets(projectPath: string, output?: string): Promise<SSGAssets>;
42
+ /**
43
+ * Collect file-based Next.js metadata icons that were prerendered as static route handlers.
44
+ *
45
+ * Maps public asset keys such as `shop/icon1.png` to their raw
46
+ * `.next/server/app/shop/icon1.png.body` output.
47
+ * Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
48
+ */
49
+ export declare function collectStaticMetadataAssets(projectPath: string, output?: string): Promise<SSGAssets>;
42
50
  /**
43
51
  * Create Lambda deployment zip
44
52
  *
@@ -77,7 +77,7 @@ 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 three files:
81
81
  * - {route}.html (full HTML)
82
82
  * - {route}.rsc (React Server Components payload)
83
83
  * - {route}.meta (response headers JSON)
@@ -113,7 +113,13 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
113
113
  '.rsc',
114
114
  '.meta'
115
115
  ];
116
- for (const routeKey of Object.keys(manifest.routes)){
116
+ for (const [routeKey, route] of Object.entries(manifest.routes)){
117
+ // Current manifests classify app pages explicitly. Older App Router manifests
118
+ // can be identified by their RSC data route. Other entries may be route handlers.
119
+ const isPageRoute = route.routeType === 'page' || route.routeType === undefined && typeof route.dataRoute === 'string' && route.dataRoute.endsWith('.rsc');
120
+ if (!isPageRoute) {
121
+ continue;
122
+ }
117
123
  // Strip leading slash for S3 key; handle root "/" → "index"
118
124
  const stripped = routeKey === '/' ? 'index' : routeKey.replace(/^\//, '');
119
125
  for (const ext of extensions){
@@ -127,6 +133,55 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
127
133
  pathMap
128
134
  };
129
135
  }
136
+ /**
137
+ * Collect file-based Next.js metadata icons that were prerendered as static route handlers.
138
+ *
139
+ * Maps public asset keys such as `shop/icon1.png` to their raw
140
+ * `.next/server/app/shop/icon1.png.body` output.
141
+ * Documentation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons
142
+ */ export async function collectStaticMetadataAssets(projectPath, output) {
143
+ const outputPath = resolveOutputPath(projectPath, 'nextjs', output);
144
+ const manifestPath = path.join(outputPath, 'prerender-manifest.json');
145
+ let manifest;
146
+ try {
147
+ const raw = await fs.readFile(manifestPath, 'utf-8');
148
+ manifest = JSON.parse(raw);
149
+ } catch {
150
+ return {
151
+ keys: [],
152
+ pathMap: {}
153
+ };
154
+ }
155
+ if (!manifest.routes || typeof manifest.routes !== 'object') {
156
+ return {
157
+ keys: [],
158
+ pathMap: {}
159
+ };
160
+ }
161
+ const keys = [];
162
+ const pathMap = {};
163
+ for (const [routeKey, route] of Object.entries(manifest.routes)){
164
+ if (route.routeType !== 'route' || route.compute !== 'static' || route.response !== 'complete' || !isFileBasedMetadataIconRoute(routeKey)) {
165
+ continue;
166
+ }
167
+ const assetKey = routeKey.replace(/^\/+/, '');
168
+ const bodyPath = path.join(outputPath, 'server', 'app', `${assetKey}.body`);
169
+ try {
170
+ const bodyStat = await fs.stat(bodyPath);
171
+ if (!bodyStat.isFile()) {
172
+ continue;
173
+ }
174
+ } catch {
175
+ continue;
176
+ }
177
+ keys.push(assetKey);
178
+ pathMap[assetKey] = path.relative(projectPath, bodyPath);
179
+ }
180
+ return {
181
+ keys,
182
+ pathMap
183
+ };
184
+ }
130
185
  /**
131
186
  * Create Lambda deployment zip
132
187
  *
@@ -137,3 +192,10 @@ const MAX_ASSET_SIZE = 50 * 1024 * 1024 // 50MB
137
192
  */ export async function createLambdaZip(projectPath) {
138
193
  await buildLambdaZip(projectPath);
139
194
  }
195
+ function isFileBasedMetadataIconRoute(routeKey) {
196
+ const basename = path.posix.basename(routeKey);
197
+ if (routeKey === '/favicon.ico') {
198
+ return true;
199
+ }
200
+ 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);
201
+ }
@@ -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.6",
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",
@@ -75,7 +77,10 @@
75
77
  "peerDependencies": {
76
78
  "@payloadcms/plugin-cloud-storage": ">=4.0.0-canary.24 <4.0.0-internal",
77
79
  "@payloadcms/richtext-lexical": ">=4.0.0-canary.24 <4.0.0-internal",
78
- "payload": ">=4.0.0-canary.24 <4.0.0-internal"
80
+ "@payloadcms/translations": ">=4.0.0-canary.24 <4.0.0-internal",
81
+ "@payloadcms/ui": ">=4.0.0-canary.24 <4.0.0-internal",
82
+ "payload": ">=4.0.0-canary.24 <4.0.0-internal",
83
+ "react": "^19.0.1 || ^19.1.2 || ^19.2.1"
79
84
  },
80
85
  "peerDependenciesMeta": {
81
86
  "@payloadcms/plugin-cloud-storage": {