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

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,10 +1,11 @@
1
1
  import archiver from 'archiver';
2
2
  import { createWriteStream } from 'fs';
3
- import fs from 'fs/promises';
4
3
  import path from 'path';
4
+ import { configureNextjsCache } from './configureNextjsCache.js';
5
5
  import { inspectNextjsOutput } from './deploy-output/inspectNextjsOutput.js';
6
6
  import { resolveOutputPath } from './deploy-output/resolveOutputPath.js';
7
- import { getDirectorySize } from './fs-utils.js';
7
+ import { walkBundleFiles } from './fs-utils.js';
8
+ import { isBundlableFile } from './secret-files.js';
8
9
  /**
9
10
  * AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
10
11
  * total must be strictly less than this value, so a bundle exactly equal to it is
@@ -31,17 +32,43 @@ import { getDirectorySize } from './fs-utils.js';
31
32
  if (!layout) {
32
33
  throw new Error(`Standalone build not found at ${outputPath}`);
33
34
  }
34
- const serverEntry = path.relative(layout.standalonePath, layout.serverEntryPath).split(path.sep).join('/');
35
- const runScript = createRunScript(serverEntry);
35
+ const files = (await walkBundleFiles(layout.standalonePath)).filter((file)=>isBundlableFile(file.relativePath));
36
+ // Override archive entries in memory without changing Next's standalone output.
37
+ const bundleFileOverrides = await createBundleFileOverrides(layout.standalonePath, layout.serverEntryPath);
36
38
  const zipPath = path.join(projectPath, 'lambda.zip');
37
- const existingRunScriptBytes = await getFileSize(path.join(layout.standalonePath, 'run.sh'));
38
- const unzippedBytes = await getDirectorySize(layout.standalonePath) - existingRunScriptBytes + Buffer.byteLength(runScript);
39
- await createZip(layout.standalonePath, zipPath, runScript);
39
+ let unzippedBytes = files.reduce((total, file)=>total + file.size, 0);
40
+ for (const [name, contents] of bundleFileOverrides){
41
+ const existingBytes = files.find((file)=>file.relativePath === name)?.size ?? 0;
42
+ unzippedBytes += contents.byteLength - existingBytes;
43
+ }
44
+ await createZip(files, zipPath, bundleFileOverrides);
40
45
  return {
41
46
  unzippedBytes,
42
47
  zipPath
43
48
  };
44
49
  }
50
+ /**
51
+ * Returns files that replace or extend the standalone output in the Lambda zip:
52
+ * the generated launcher, the cache-configured server entry, and its optional
53
+ * cache handler. All paths are relative to the zip root.
54
+ */ async function createBundleFileOverrides(standalonePath, serverEntryPath) {
55
+ const serverEntry = path.relative(standalonePath, serverEntryPath).split(path.sep).join('/');
56
+ const cacheConfiguration = await configureNextjsCache(serverEntryPath);
57
+ const bundleFileOverrides = new Map([
58
+ [
59
+ 'run.sh',
60
+ Buffer.from(createRunScript(serverEntry))
61
+ ],
62
+ [
63
+ serverEntry,
64
+ Buffer.from(cacheConfiguration.server)
65
+ ]
66
+ ]);
67
+ if (cacheConfiguration.handler) {
68
+ bundleFileOverrides.set(path.posix.join(path.posix.dirname(serverEntry), cacheConfiguration.handler.name), cacheConfiguration.handler.data);
69
+ }
70
+ return bundleFileOverrides;
71
+ }
45
72
  function createRunScript(serverEntry) {
46
73
  const escapedEntry = serverEntry.replaceAll("'", "'\\''");
47
74
  return `#!/bin/bash -x
@@ -49,10 +76,7 @@ function createRunScript(serverEntry) {
49
76
  NODE_ENV=production exec node '${escapedEntry}'
50
77
  `;
51
78
  }
52
- async function getFileSize(filePath) {
53
- return fs.stat(filePath).then((stat)=>stat.isFile() ? stat.size : 0).catch(()=>0);
54
- }
55
- function createZip(sourceDir, zipPath, runScript) {
79
+ function createZip(files, zipPath, bundleFileOverrides) {
56
80
  return new Promise((resolve, reject)=>{
57
81
  const output = createWriteStream(zipPath);
58
82
  const archive = archiver('zip', {
@@ -63,11 +87,20 @@ function createZip(sourceDir, zipPath, runScript) {
63
87
  output.on('close', ()=>resolve());
64
88
  archive.on('error', (err)=>reject(err));
65
89
  archive.pipe(output);
66
- archive.directory(sourceDir, false, (entry)=>entry.name === 'run.sh' ? false : entry);
67
- archive.append(runScript, {
68
- name: 'run.sh',
69
- mode: 0o755
70
- });
90
+ for (const file of files){
91
+ if (bundleFileOverrides.has(file.relativePath)) {
92
+ continue;
93
+ }
94
+ archive.file(file.sourcePath, {
95
+ name: file.relativePath
96
+ });
97
+ }
98
+ for (const [name, contents] of bundleFileOverrides){
99
+ archive.append(contents, {
100
+ name,
101
+ mode: name === 'run.sh' ? 0o755 : 0o644
102
+ });
103
+ }
71
104
  void archive.finalize();
72
105
  });
73
106
  }
@@ -0,0 +1,7 @@
1
+ export declare function configureNextjsCache(serverEntryPath: string): Promise<{
2
+ handler?: {
3
+ data: Buffer;
4
+ name: string;
5
+ };
6
+ server: string;
7
+ }>;
@@ -0,0 +1,55 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { Node, Project } from 'ts-morph';
4
+ import { info } from './log.js';
5
+ const CACHE_HANDLER_FILE = '__figma_nextjs_cache.mjs';
6
+ export async function configureNextjsCache(serverEntryPath) {
7
+ const original = await fs.readFile(serverEntryPath, 'utf8');
8
+ const project = new Project({
9
+ useInMemoryFileSystem: true
10
+ });
11
+ const source = project.createSourceFile('server.js', original);
12
+ const initializer = source.getVariableDeclaration('nextConfig')?.getInitializer();
13
+ if (!initializer || !Node.isObjectLiteralExpression(initializer)) {
14
+ throw new Error('Unsupported Next.js standalone server: expected a nextConfig object');
15
+ }
16
+ // Only accept Next's generated JSON, not arbitrary application code.
17
+ let config;
18
+ try {
19
+ config = JSON.parse(initializer.getText());
20
+ } catch {
21
+ throw new Error('Unsupported Next.js standalone server: expected generated JSON config');
22
+ }
23
+ const experimental = config.experimental;
24
+ // cacheHandler is the stable name; older Next.js builds use the experimental path.
25
+ if (config.cacheHandler || experimental?.incrementalCacheHandlerPath) {
26
+ info('Using the application cacheHandler; Figma fresh-only caching is not enabled.');
27
+ return {
28
+ server: original
29
+ };
30
+ }
31
+ const reservedPath = path.join(path.dirname(serverEntryPath), CACHE_HANDLER_FILE);
32
+ const exists = await fs.lstat(reservedPath).then(()=>true, (error)=>{
33
+ if (error.code === 'ENOENT') {
34
+ return false;
35
+ }
36
+ throw error;
37
+ });
38
+ if (exists) {
39
+ throw new Error(`Standalone output contains reserved file ${CACHE_HANDLER_FILE}`);
40
+ }
41
+ initializer.getProperty('"cacheHandler"')?.remove();
42
+ initializer.addPropertyAssignments([
43
+ {
44
+ name: 'cacheHandler',
45
+ initializer: `require.resolve('./${CACHE_HANDLER_FILE}')`
46
+ }
47
+ ]);
48
+ return {
49
+ handler: {
50
+ name: CACHE_HANDLER_FILE,
51
+ data: await fs.readFile(new URL('./lambda-runtime/nextjs-cache.js', import.meta.url))
52
+ },
53
+ server: source.getFullText()
54
+ };
55
+ }
@@ -3,10 +3,37 @@
3
3
  * returned as forward-slash-separated paths relative to baseDir.
4
4
  */
5
5
  export declare function collectFilesRecursive(dir: string, baseDir: string): Promise<string[]>;
6
+ export declare class EscapingSymlinkError extends Error {
7
+ readonly symlinkPath: string;
8
+ readonly targetPath: string;
9
+ constructor(symlinkPath: string, targetPath: string, root: string);
10
+ }
11
+ export interface BundleFile {
12
+ /** Forward-slash path relative to the walk root; used as the zip entry name. */
13
+ relativePath: string;
14
+ /**
15
+ * Bytes this entry contributes to unzipped size. Regular files use content
16
+ * size; in-tree symlinks use `lstat` size (the stored link path).
17
+ */
18
+ size: number;
19
+ /** Path passed to the archiver; may be an in-tree symlink. */
20
+ sourcePath: string;
21
+ }
6
22
  /**
7
- * Recursively sum the byte size of all files under a directory.
8
- * Returns 0 for a missing or unreadable directory. Symlinks are not
9
- * followed. Files that disappear or become unreadable between readdir
10
- * and stat are skipped (treated as 0 bytes).
23
+ * Recursively list files under `root` for Lambda bundle sizing and archiving.
24
+ *
25
+ * Regular files and directories are included. Symlinks are included (not
26
+ * followed) only when the resolved target stays within `root`; otherwise an
27
+ * {@link EscapingSymlinkError} is thrown. This matches `archiver.directory`,
28
+ * which stores zip symlink entries rather than copying target contents.
29
+ * Broken or unreadable files are skipped.
30
+ */
31
+ export declare function walkBundleFiles(root: string): Promise<BundleFile[]>;
32
+ /**
33
+ * Recursively sum the byte size of all files under a directory using the same
34
+ * symlink policy as {@link walkBundleFiles}. Returns 0 for a missing or
35
+ * unreadable directory. Escaping symlinks throw {@link EscapingSymlinkError}.
36
+ * Files that disappear or become unreadable between readdir and stat are
37
+ * skipped (treated as 0 bytes).
11
38
  */
12
39
  export declare function getDirectorySize(dir: string): Promise<number>;
@@ -23,33 +23,113 @@ import path from 'path';
23
23
  }
24
24
  return files;
25
25
  }
26
+ export class EscapingSymlinkError extends Error {
27
+ symlinkPath;
28
+ targetPath;
29
+ constructor(symlinkPath, targetPath, root){
30
+ super(`Symlink '${symlinkPath}' points outside ${root} (${targetPath})`), this.symlinkPath = symlinkPath, this.targetPath = targetPath;
31
+ this.name = 'EscapingSymlinkError';
32
+ }
33
+ }
34
+ /**
35
+ * Recursively list files under `root` for Lambda bundle sizing and archiving.
36
+ *
37
+ * Regular files and directories are included. Symlinks are included (not
38
+ * followed) only when the resolved target stays within `root`; otherwise an
39
+ * {@link EscapingSymlinkError} is thrown. This matches `archiver.directory`,
40
+ * which stores zip symlink entries rather than copying target contents.
41
+ * Broken or unreadable files are skipped.
42
+ */ export async function walkBundleFiles(root) {
43
+ const rootReal = await fs.realpath(root);
44
+ const files = [];
45
+ await walkBundleDir(root, root, rootReal, files);
46
+ return files;
47
+ }
26
48
  /**
27
- * Recursively sum the byte size of all files under a directory.
28
- * Returns 0 for a missing or unreadable directory. Symlinks are not
29
- * followed. Files that disappear or become unreadable between readdir
30
- * and stat are skipped (treated as 0 bytes).
49
+ * Recursively sum the byte size of all files under a directory using the same
50
+ * symlink policy as {@link walkBundleFiles}. Returns 0 for a missing or
51
+ * unreadable directory. Escaping symlinks throw {@link EscapingSymlinkError}.
52
+ * Files that disappear or become unreadable between readdir and stat are
53
+ * skipped (treated as 0 bytes).
31
54
  */ export async function getDirectorySize(dir) {
55
+ try {
56
+ const files = await walkBundleFiles(dir);
57
+ return files.reduce((total, file)=>total + file.size, 0);
58
+ } catch (error) {
59
+ if (error instanceof EscapingSymlinkError) {
60
+ throw error;
61
+ }
62
+ return 0;
63
+ }
64
+ }
65
+ function isPathWithin(rootReal, targetReal) {
66
+ const relative = path.relative(rootReal, targetReal);
67
+ return relative === '' || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
68
+ }
69
+ async function walkBundleDir(dir, root, rootReal, files) {
32
70
  let entries;
33
71
  try {
34
72
  entries = await fs.readdir(dir, {
35
73
  withFileTypes: true
36
74
  });
37
75
  } catch {
38
- return 0;
76
+ return;
39
77
  }
40
- let total = 0;
41
78
  for (const entry of entries){
42
79
  const fullPath = path.join(dir, entry.name);
43
- if (entry.isDirectory()) {
44
- total += await getDirectorySize(fullPath);
80
+ if (entry.isSymbolicLink()) {
81
+ await addSymlinkEntry(fullPath, root, rootReal, files);
82
+ } else if (entry.isDirectory()) {
83
+ await walkBundleDir(fullPath, root, rootReal, files);
45
84
  } else if (entry.isFile()) {
46
- try {
47
- const stats = await fs.stat(fullPath);
48
- total += stats.size;
49
- } catch {
50
- // File vanished or is unreadable between readdir and stat — skip it.
51
- }
85
+ await addRegularFile(fullPath, root, files);
86
+ }
87
+ }
88
+ }
89
+ async function addSymlinkEntry(fullPath, root, rootReal, files) {
90
+ const relativePath = toRelativePath(root, fullPath);
91
+ const targetPath = await resolveSymlinkTarget(fullPath);
92
+ if (targetPath === null) {
93
+ return;
94
+ }
95
+ if (!isPathWithin(rootReal, targetPath)) {
96
+ throw new EscapingSymlinkError(relativePath, targetPath, root);
97
+ }
98
+ try {
99
+ const stats = await fs.lstat(fullPath);
100
+ files.push({
101
+ relativePath,
102
+ size: stats.size,
103
+ sourcePath: fullPath
104
+ });
105
+ } catch {
106
+ // Symlink vanished between readdir and lstat — skip it.
107
+ }
108
+ }
109
+ async function resolveSymlinkTarget(fullPath) {
110
+ try {
111
+ return await fs.realpath(fullPath);
112
+ } catch {
113
+ try {
114
+ const link = await fs.readlink(fullPath);
115
+ return path.resolve(path.dirname(fullPath), link);
116
+ } catch {
117
+ return null;
52
118
  }
53
119
  }
54
- return total;
120
+ }
121
+ async function addRegularFile(fullPath, root, files) {
122
+ try {
123
+ const stats = await fs.stat(fullPath);
124
+ files.push({
125
+ relativePath: toRelativePath(root, fullPath),
126
+ size: stats.size,
127
+ sourcePath: fullPath
128
+ });
129
+ } catch {
130
+ // File vanished or is unreadable between readdir and stat — skip it.
131
+ }
132
+ }
133
+ function toRelativePath(root, fullPath) {
134
+ return path.relative(root, fullPath).split(path.sep).join('/');
55
135
  }
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { IndentationText, Node, Project, SyntaxKind } from 'ts-morph';
4
+ import { addFigmaImageLoaderConfig } from './next-image-loader-config.js';
5
+ import { writeFigmaImageLoaderFile } from './next-image-loader-file.js';
4
6
  import { ensurePnpmWorkspace } from './pnpm-builds.js';
5
7
  const RUN_SH_CONTENT = `#!/bin/bash -x
6
8
 
@@ -18,8 +20,9 @@ NODE_ENV=production exec node server.js
18
20
  const runShPath = path.join(projectPath, 'run.sh');
19
21
  await fs.writeFile(runShPath, RUN_SH_CONTENT, 'utf-8');
20
22
  await fs.chmod(runShPath, 0o755);
21
- // 2. Modify next config to add standalone output
23
+ // 2. Add Next.js-specific configuration.
22
24
  await addNextConfigProperties(projectPath);
25
+ await writeFigmaImageLoaderFile(projectPath);
23
26
  // 3. Add lambda:buildzip script to package.json
24
27
  await addLambdaBuildScript(projectPath, packageManager);
25
28
  // 4. Write pnpm-workspace.yaml (hoisted linker + build approvals)
@@ -48,6 +51,7 @@ NODE_ENV=production exec node server.js
48
51
  /**
49
52
  * Add required Next.js config properties using ts-morph AST parsing
50
53
  * - output: 'standalone' (for Lambda deployment)
54
+ * - images.loader: 'custom' and images.loaderFile for Payload media
51
55
  */ async function addNextConfigProperties(projectPath) {
52
56
  const nextConfigPath = await findNextConfigPath(projectPath);
53
57
  const project = new Project({
@@ -65,15 +69,16 @@ NODE_ENV=production exec node server.js
65
69
  if (!configObject) {
66
70
  throw new Error(`Could not find Next.js config object in ${path.basename(nextConfigPath)}. ` + `Expected either 'export default { ... }' or 'export default wrapper(configVar, ...)'`);
67
71
  }
68
- const hasOutput = configObject.getProperty('output') !== undefined;
69
- if (hasOutput) {
70
- return;
72
+ if (configObject.getProperty('output') === undefined) {
73
+ configObject.addPropertyAssignment({
74
+ name: 'output',
75
+ initializer: "'standalone'"
76
+ });
77
+ }
78
+ addFigmaImageLoaderConfig(configObject);
79
+ if (!sourceFile.isSaved()) {
80
+ await sourceFile.save();
71
81
  }
72
- configObject.addPropertyAssignment({
73
- name: 'output',
74
- initializer: "'standalone'"
75
- });
76
- await sourceFile.save();
77
82
  }
78
83
  /**
79
84
  * Find the config object literal from either:
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A process-local, fresh-only Next.js cacheHandler. The CDN owns stale serving.
3
+ * No filesystem access: Lambda's code directory is read-only. Each cold process
4
+ * starts empty; Gatekeeper serves the separately uploaded static assets.
5
+ */
6
+ export default class NextjsCacheHandler {
7
+ tagVersion: number;
8
+ get(key: any, context?: {}): Promise<{
9
+ value: any;
10
+ lastModified: any;
11
+ } | null>;
12
+ set(key: any, value: any, context?: {}): Promise<void>;
13
+ /**
14
+ * The revalidateTag function allows invalidating cached data on-demand based
15
+ * on the specified tags. For our purposes, since we are currently doing a
16
+ * local in-memory, per-process lambda cache, we can just go ahead and clear
17
+ * the cache. Eventually we will want to support doing tag invalidation at the
18
+ * CDN layer.
19
+ */
20
+ revalidateTag(tags: any, _durations?: {}): Promise<void>;
21
+ resetRequestCache(): void;
22
+ }
@@ -0,0 +1,162 @@
1
+ import { deserialize, serialize } from 'node:v8';
2
+ const MAX_CACHE_BYTES = 8 * 1024 * 1024;
3
+ // Approximate Map entry and metadata overhead; actual heap use can differ.
4
+ const ESTIMATED_ENTRY_OVERHEAD_BYTES = 64;
5
+ const cache = new Map();
6
+ let cacheBytes = 0;
7
+ // Tag invalidation advances this counter to reject writes from earlier requests.
8
+ let tagVersion = 0;
9
+ /**
10
+ * A process-local, fresh-only Next.js cacheHandler. The CDN owns stale serving.
11
+ * No filesystem access: Lambda's code directory is read-only. Each cold process
12
+ * starts empty; Gatekeeper serves the separately uploaded static assets.
13
+ */ export default class NextjsCacheHandler {
14
+ constructor(){
15
+ this.tagVersion = tagVersion;
16
+ }
17
+ async get(key, context = {}) {
18
+ const cacheKey = getCacheKey(key, context.kind === 'FETCH');
19
+ const entry = cache.get(cacheKey);
20
+ if (!entry) {
21
+ observe('miss', context.kind);
22
+ return null;
23
+ }
24
+ const effectiveTTL = context.kind === 'FETCH' && context.revalidate !== undefined ? Math.min(entry.seconds, lifetime(context.revalidate)) : entry.seconds;
25
+ const ageMs = Math.max(0, Date.now() - entry.lastModified);
26
+ if (ageMs >= effectiveTTL * 1000) {
27
+ if (ageMs >= entry.seconds * 1000) {
28
+ remove(cacheKey);
29
+ }
30
+ observe('expired', context.kind);
31
+ return null;
32
+ }
33
+ // Clone cached buffers/maps so a caller cannot mutate the stored value.
34
+ // V8 can return Buffer views into its input, so copy the serialized bytes.
35
+ const value = deserialize(Buffer.from(entry.data));
36
+ if (value && [
37
+ 'APP_PAGE',
38
+ 'APP_ROUTE',
39
+ 'PAGES'
40
+ ].includes(value.kind)) {
41
+ value.headers = withoutAge(value.headers);
42
+ value.headers.Age = String(Math.floor(ageMs / 1000));
43
+ }
44
+ cache.delete(cacheKey);
45
+ cache.set(cacheKey, entry);
46
+ observe('hit', context.kind);
47
+ return {
48
+ value,
49
+ lastModified: entry.lastModified
50
+ };
51
+ }
52
+ async set(key, value, context = {}) {
53
+ // A render that overlaps invalidation must not restore an old value.
54
+ if (this.tagVersion !== tagVersion) {
55
+ return;
56
+ }
57
+ const cacheKey = getCacheKey(key, context.fetchCache);
58
+ remove(cacheKey);
59
+ const seconds = lifetime(context.fetchCache ? value?.revalidate : context.cacheControl?.revalidate);
60
+ if (seconds === 0) {
61
+ return;
62
+ }
63
+ let data;
64
+ try {
65
+ data = serialize(value);
66
+ } catch {
67
+ observe('unsupported', value?.kind);
68
+ return;
69
+ }
70
+ const size = data.byteLength + Buffer.byteLength(cacheKey) + ESTIMATED_ENTRY_OVERHEAD_BYTES;
71
+ if (size > MAX_CACHE_BYTES) {
72
+ observe('oversized', value?.kind);
73
+ return;
74
+ }
75
+ // Preserve an upstream Age, including a previous cached response that Next
76
+ // attempts to write again after an unsuccessful on-demand revalidation.
77
+ const age = responseAge(value);
78
+ if (age >= seconds) {
79
+ return;
80
+ }
81
+ while(cacheBytes + size > MAX_CACHE_BYTES){
82
+ remove(cache.keys().next().value);
83
+ }
84
+ cache.set(cacheKey, {
85
+ data,
86
+ size,
87
+ seconds,
88
+ lastModified: Date.now() - age * 1000
89
+ });
90
+ cacheBytes += size;
91
+ }
92
+ /**
93
+ * The revalidateTag function allows invalidating cached data on-demand based
94
+ * on the specified tags. For our purposes, since we are currently doing a
95
+ * local in-memory, per-process lambda cache, we can just go ahead and clear
96
+ * the cache. Eventually we will want to support doing tag invalidation at the
97
+ * CDN layer.
98
+ */ async revalidateTag(tags, _durations = {}) {
99
+ const tagsArr = Array.isArray(tags) ? tags : [
100
+ tags
101
+ ];
102
+ const hasValidTag = tagsArr.some((tag)=>typeof tag === 'string' && tag.length);
103
+ if (!hasValidTag) {
104
+ return;
105
+ }
106
+ cache.clear();
107
+ cacheBytes = 0;
108
+ tagVersion += 1;
109
+ observe('invalidated', 'local');
110
+ }
111
+ // Outstanding writes still belong to the request that started before invalidation.
112
+ resetRequestCache() {}
113
+ }
114
+ function getCacheKey(key, fetchCache) {
115
+ return `${fetchCache ? 'fetch' : 'route'}:${key}`;
116
+ }
117
+ function lifetime(revalidate) {
118
+ if (revalidate === false) {
119
+ return Infinity;
120
+ }
121
+ return Number.isSafeInteger(revalidate) && revalidate > 0 ? revalidate : 0;
122
+ }
123
+ function remove(key) {
124
+ const entry = cache.get(key);
125
+ if (entry) {
126
+ cacheBytes -= entry.size;
127
+ }
128
+ cache.delete(key);
129
+ }
130
+ function withoutAge(headers) {
131
+ return Object.fromEntries(Object.entries(headers ?? {}).filter(([name])=>name.toLowerCase() !== 'age'));
132
+ }
133
+ function responseAge(value) {
134
+ if (!value || value.kind === 'FETCH') {
135
+ return 0;
136
+ }
137
+ const age = Object.entries(value.headers ?? {}).find(([name])=>name.toLowerCase() === 'age')?.[1];
138
+ if (age === undefined) {
139
+ return 0;
140
+ }
141
+ if (typeof age !== 'number' && (typeof age !== 'string' || !/^\d+$/.test(age))) {
142
+ return Infinity;
143
+ }
144
+ const seconds = Number(age);
145
+ return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : Infinity;
146
+ }
147
+ function observe(result, kind) {
148
+ if (![
149
+ 'APP_PAGE',
150
+ 'APP_ROUTE',
151
+ 'PAGES',
152
+ 'local'
153
+ ].includes(kind)) {
154
+ return;
155
+ }
156
+ // Bounded fields only: never log cache keys, URLs, values, or tags.
157
+ console.info(JSON.stringify({
158
+ event: 'figma_nextjs_cache',
159
+ result,
160
+ kind
161
+ }));
162
+ }
@@ -0,0 +1,2 @@
1
+ import type { ObjectLiteralExpression } from 'ts-morph';
2
+ export declare function addFigmaImageLoaderConfig(configObject: ObjectLiteralExpression): void;
@@ -0,0 +1,63 @@
1
+ import { Node, SyntaxKind } from 'ts-morph';
2
+ import { FIGMA_IMAGE_LOADER_PATH } from './next-image-loader-file.js';
3
+ export function addFigmaImageLoaderConfig(configObject) {
4
+ const imagesProperty = configObject.getProperty('images');
5
+ if (!imagesProperty) {
6
+ configObject.addPropertyAssignment({
7
+ name: 'images',
8
+ initializer: `{
9
+ loader: 'custom',
10
+ loaderFile: '${FIGMA_IMAGE_LOADER_PATH}',
11
+ }`
12
+ });
13
+ return;
14
+ }
15
+ let imagesValue;
16
+ if (Node.isPropertyAssignment(imagesProperty)) {
17
+ imagesValue = imagesProperty.getInitializer();
18
+ } else if (Node.isShorthandPropertyAssignment(imagesProperty)) {
19
+ imagesValue = imagesProperty.getNameNode();
20
+ }
21
+ const imagesObject = resolveLocalObjectLiteral(imagesValue);
22
+ if (!imagesObject) {
23
+ throw new Error('Figma could not add the image loader because it could not inspect the `images` configuration. ' + 'Define `images` as an object in this Next.js config file and rerun ' + '`npx @payloadcms/figma init`.');
24
+ }
25
+ const loaderProperty = imagesObject.getProperty('loader');
26
+ const loaderFileProperty = imagesObject.getProperty('loaderFile');
27
+ if (loaderProperty || loaderFileProperty) {
28
+ const isFigmaLoader = getStringPropertyValue(loaderProperty) === 'custom' && getStringPropertyValue(loaderFileProperty) === FIGMA_IMAGE_LOADER_PATH;
29
+ if (!isFigmaLoader) {
30
+ throw new Error('A custom Next.js image loader is already configured, so Figma left it unchanged. ' + 'Remove the existing images.loader and images.loaderFile settings and rerun ' + '`npx @payloadcms/figma init`.');
31
+ }
32
+ return;
33
+ }
34
+ imagesObject.addPropertyAssignment({
35
+ name: 'loader',
36
+ initializer: "'custom'"
37
+ });
38
+ imagesObject.addPropertyAssignment({
39
+ name: 'loaderFile',
40
+ initializer: `'${FIGMA_IMAGE_LOADER_PATH}'`
41
+ });
42
+ }
43
+ function resolveLocalObjectLiteral(node) {
44
+ if (Node.isObjectLiteralExpression(node)) {
45
+ return node;
46
+ }
47
+ if (!Node.isIdentifier(node)) {
48
+ return undefined;
49
+ }
50
+ const sourceFile = node.getSourceFile();
51
+ for (const definition of node.getDefinitionNodes()){
52
+ if (Node.isVariableDeclaration(definition) && definition.getSourceFile() === sourceFile) {
53
+ return definition.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ function getStringPropertyValue(property) {
59
+ if (!property || !Node.isPropertyAssignment(property)) {
60
+ return undefined;
61
+ }
62
+ return property.getInitializerIfKind(SyntaxKind.StringLiteral)?.getLiteralValue();
63
+ }
@@ -0,0 +1,2 @@
1
+ export declare const FIGMA_IMAGE_LOADER_PATH = "./figma-image-loader.ts";
2
+ export declare function writeFigmaImageLoaderFile(projectPath: string): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export const FIGMA_IMAGE_LOADER_PATH = './figma-image-loader.ts';
4
+ const FIGMA_IMAGE_LOADER_CONTENT = `'use client'
5
+
6
+ // Generated by @payloadcms/figma. Do not edit.
7
+ export { figmaNextImageLoader as default } from '@payloadcms/figma/next-image-loader'
8
+ `;
9
+ export async function writeFigmaImageLoaderFile(projectPath) {
10
+ await fs.writeFile(path.join(projectPath, FIGMA_IMAGE_LOADER_PATH), FIGMA_IMAGE_LOADER_CONTENT);
11
+ }