Skip to content

Commit 2352add

Browse files
committed
feat: import pages script
1 parent 188484f commit 2352add

22 files changed

Lines changed: 584 additions & 61 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"migrate:create": "payload migrate:create",
1414
"migrate:status": "payload migrate:status",
1515
"generate:importmap": "payload generate:importmap",
16+
"import:pages": "node --import tsx scripts/import-pages.ts",
17+
"import:pages:standalone": "node .next/standalone/scripts/import-pages.mjs",
1618
"lint": "next lint",
1719
"knip": "node --import tsx ./node_modules/knip/bin/knip.js",
1820
"generate": "payload generate:types"

public/robots.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ User-agent: *
33
Allow: /
44

55
# Host
6-
Host: https://localhost:3000
6+
Host: http://localhost:3000
77

88
# Sitemaps
9-
Sitemap: https://localhost:3000/sitemap.xml
10-
Sitemap: https://localhost:3000/server-sitemap.xml
9+
Sitemap: http://localhost:3000/sitemap.xml
10+
Sitemap: http://localhost:3000/server-sitemap.xml

public/sitemap.xml

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
3-
<url><loc>https://localhost:3000</loc><lastmod>2026-05-04T18:30:07.461Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="en" href="https://localhost:3000/en"/><xhtml:link rel="alternate" hreflang="de" href="https://localhost:3000/de"/></url>
4-
<url><loc>https://localhost:3000/robots.txt</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="en" href="https://localhost:3000/en/robots.txt"/><xhtml:link rel="alternate" hreflang="de" href="https://localhost:3000/de/robots.txt"/></url>
5-
<url><loc>https://localhost:3000/en</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>1</priority></url>
6-
<url><loc>https://localhost:3000/en/about-us</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
7-
<url><loc>https://localhost:3000/en/actions</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
8-
<url><loc>https://localhost:3000/en/blog</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
9-
<url><loc>https://localhost:3000/en/community-edition</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
10-
<url><loc>https://localhost:3000/en/contact</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
11-
<url><loc>https://localhost:3000/en/enterprise-edition</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
12-
<url><loc>https://localhost:3000/en/features</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
13-
<url><loc>https://localhost:3000/en/jobs</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
14-
<url><loc>https://localhost:3000/en/legal-notice</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
15-
<url><loc>https://localhost:3000/en/privacy</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
16-
<url><loc>https://localhost:3000/en/subscription</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
17-
<url><loc>https://localhost:3000/en/terms</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
18-
<url><loc>https://localhost:3000/de</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>1</priority></url>
19-
<url><loc>https://localhost:3000/de/about-us</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
20-
<url><loc>https://localhost:3000/de/actions</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
21-
<url><loc>https://localhost:3000/de/blog</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
22-
<url><loc>https://localhost:3000/de/community-edition</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
23-
<url><loc>https://localhost:3000/de/contact</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
24-
<url><loc>https://localhost:3000/de/enterprise-edition</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
25-
<url><loc>https://localhost:3000/de/features</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
26-
<url><loc>https://localhost:3000/de/jobs</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
27-
<url><loc>https://localhost:3000/de/legal-notice</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
28-
<url><loc>https://localhost:3000/de/privacy</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
29-
<url><loc>https://localhost:3000/de/subscription</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
30-
<url><loc>https://localhost:3000/de/terms</loc><lastmod>2026-05-04T18:30:07.462Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
3+
<url><loc>http://localhost:3000</loc><lastmod>2026-05-06T16:06:29.743Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="en" href="http://localhost:3000/en"/><xhtml:link rel="alternate" hreflang="de" href="http://localhost:3000/de"/></url>
4+
<url><loc>http://localhost:3000/robots.txt</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority><xhtml:link rel="alternate" hreflang="en" href="http://localhost:3000/en/robots.txt"/><xhtml:link rel="alternate" hreflang="de" href="http://localhost:3000/de/robots.txt"/></url>
5+
<url><loc>http://localhost:3000/en</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>1</priority></url>
6+
<url><loc>http://localhost:3000/en/about-us</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
7+
<url><loc>http://localhost:3000/en/actions</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
8+
<url><loc>http://localhost:3000/en/blog</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
9+
<url><loc>http://localhost:3000/en/community-edition</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
10+
<url><loc>http://localhost:3000/en/contact</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
11+
<url><loc>http://localhost:3000/en/enterprise-edition</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
12+
<url><loc>http://localhost:3000/en/features</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
13+
<url><loc>http://localhost:3000/en/jobs</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
14+
<url><loc>http://localhost:3000/en/legal-notice</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
15+
<url><loc>http://localhost:3000/en/privacy</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
16+
<url><loc>http://localhost:3000/en/subscription</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
17+
<url><loc>http://localhost:3000/en/terms</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
18+
<url><loc>http://localhost:3000/de</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>1</priority></url>
19+
<url><loc>http://localhost:3000/de/about-us</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
20+
<url><loc>http://localhost:3000/de/actions</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
21+
<url><loc>http://localhost:3000/de/blog</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
22+
<url><loc>http://localhost:3000/de/community-edition</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
23+
<url><loc>http://localhost:3000/de/contact</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
24+
<url><loc>http://localhost:3000/de/enterprise-edition</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
25+
<url><loc>http://localhost:3000/de/features</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
26+
<url><loc>http://localhost:3000/de/jobs</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
27+
<url><loc>http://localhost:3000/de/legal-notice</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
28+
<url><loc>http://localhost:3000/de/privacy</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
29+
<url><loc>http://localhost:3000/de/subscription</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
30+
<url><loc>http://localhost:3000/de/terms</loc><lastmod>2026-05-06T16:06:29.744Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
3131
</urlset>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import fs from "node:fs/promises"
2+
import path from "node:path"
3+
4+
const args = process.argv.slice(2)
5+
const positional = args.filter((arg) => !arg.startsWith("--"))
6+
const inputPath = path.resolve(process.cwd(), positional[0] ?? "pages.json")
7+
const locale = args.find((arg) => arg.startsWith("--locale="))?.split("=")[1] || "en"
8+
const url = args.find((arg) => arg.startsWith("--url="))?.split("=")[1] || process.env.NEXT_PUBLIC_APP_URL
9+
const dryRun = args.includes("--dry-run")
10+
11+
const endpoint = new URL("/api/import-pages", url)
12+
endpoint.searchParams.set("locale", locale)
13+
if (dryRun) endpoint.searchParams.set("dryRun", "true")
14+
15+
const response = await fetch(endpoint, {
16+
body: await fs.readFile(inputPath, "utf8"),
17+
headers: {
18+
"content-type": "application/json",
19+
},
20+
method: "POST",
21+
})
22+
23+
const text = await response.text()
24+
let result = text
25+
let rejected = 0
26+
27+
try {
28+
const parsed = JSON.parse(text)
29+
rejected = Number(parsed?.rejected ?? 0)
30+
result = JSON.stringify(parsed, null, 2)
31+
} catch {
32+
}
33+
34+
console.log(result)
35+
36+
if ((!response.ok && response.status !== 207) || rejected > 0) {
37+
process.exitCode = 1
38+
}

scripts/import-pages.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import nextEnv from '@next/env'
5+
import { getPayload } from 'payload'
6+
import type { Payload } from 'payload'
7+
8+
type ImportPage = {
9+
id?: number | string
10+
slug?: string
11+
title?: unknown
12+
layout?: unknown
13+
[key: string]: unknown
14+
}
15+
16+
type ImportLocale = 'en' | 'de'
17+
18+
const filename = fileURLToPath(import.meta.url)
19+
const dirname = path.dirname(filename)
20+
21+
const args = process.argv.slice(2)
22+
const positional = args.filter((arg) => !arg.startsWith('--'))
23+
const inputPath = path.resolve(process.cwd(), positional[0] ?? 'pages.json')
24+
const localeArg = args.find((arg) => arg.startsWith('--locale='))
25+
const localeInput = localeArg?.split('=')[1] || process.env.PAYLOAD_IMPORT_LOCALE || 'en'
26+
const dryRun = args.includes('--dry-run')
27+
28+
const systemFields = new Set(['id', 'createdAt', 'updatedAt'])
29+
const supportedLocales = new Set(['en', 'de'])
30+
31+
if (!supportedLocales.has(localeInput)) {
32+
throw new Error(`Unsupported locale "${localeInput}". Expected "en" or "de".`)
33+
}
34+
35+
const locale = localeInput as ImportLocale
36+
37+
const toErrorDetails = (error: unknown) => {
38+
if (error instanceof Error) {
39+
return {
40+
message: error.message,
41+
name: error.name,
42+
stack: error.stack,
43+
...(typeof error === 'object' ? error : {}),
44+
}
45+
}
46+
47+
return error
48+
}
49+
50+
const normalizeDocs = (raw: unknown): ImportPage[] => {
51+
if (Array.isArray(raw)) {
52+
return raw as ImportPage[]
53+
}
54+
55+
if (
56+
raw &&
57+
typeof raw === 'object' &&
58+
'docs' in raw &&
59+
Array.isArray((raw as { docs?: unknown }).docs)
60+
) {
61+
return (raw as { docs: ImportPage[] }).docs
62+
}
63+
64+
throw new Error('Expected pages.json to contain an array or an object with a docs array.')
65+
}
66+
67+
const removeSystemFields = (doc: ImportPage) => {
68+
return Object.fromEntries(Object.entries(doc).filter(([key]) => !systemFields.has(key)))
69+
}
70+
71+
const isRecord = (value: unknown): value is Record<string, unknown> => {
72+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
73+
}
74+
75+
const isPayloadRelationshipObject = (value: Record<string, unknown>) => {
76+
return (
77+
'id' in value &&
78+
!('blockType' in value) &&
79+
(
80+
'relationTo' in value ||
81+
'filename' in value ||
82+
'mimeType' in value ||
83+
'filesize' in value
84+
)
85+
)
86+
}
87+
88+
const isLocalizedValue = (value: Record<string, unknown>) => {
89+
const keys = Object.keys(value)
90+
91+
return keys.length > 0 && keys.every((key) => supportedLocales.has(key))
92+
}
93+
94+
const normalizeRelationships = (value: unknown): unknown => {
95+
if (Array.isArray(value)) {
96+
return value.map((item) => normalizeRelationships(item))
97+
}
98+
99+
if (!isRecord(value)) {
100+
return value
101+
}
102+
103+
if (isPayloadRelationshipObject(value)) {
104+
return value.id
105+
}
106+
107+
if (isLocalizedValue(value)) {
108+
return normalizeRelationships(value[locale] ?? value.en ?? value.de)
109+
}
110+
111+
return Object.fromEntries(
112+
Object.entries(value).map(([key, nestedValue]) => [key, normalizeRelationships(nestedValue)]),
113+
)
114+
}
115+
116+
const main = async () => {
117+
nextEnv.loadEnvConfig(process.cwd())
118+
119+
const { default: config } = await import('../src/payload.config')
120+
const raw = await fs.readFile(inputPath, 'utf8')
121+
const docs = normalizeDocs(JSON.parse(raw))
122+
let payload: Payload | undefined
123+
const results: Array<{ id?: number | string; slug?: string; status: string; error?: unknown }> = []
124+
125+
try {
126+
payload = await getPayload({ config })
127+
128+
for (const doc of docs) {
129+
if (!doc.slug || typeof doc.slug !== 'string') {
130+
results.push({
131+
id: doc.id,
132+
status: 'rejected',
133+
error: 'Missing required string field: slug',
134+
})
135+
continue
136+
}
137+
138+
const data = normalizeRelationships(removeSystemFields(doc)) as any
139+
140+
try {
141+
const existing = await payload.find({
142+
collection: 'pages',
143+
depth: 0,
144+
limit: 1,
145+
locale,
146+
overrideAccess: true,
147+
where: {
148+
slug: {
149+
equals: doc.slug,
150+
},
151+
},
152+
})
153+
154+
if (dryRun) {
155+
results.push({
156+
id: existing.docs[0]?.id ?? doc.id,
157+
slug: doc.slug,
158+
status: existing.totalDocs > 0 ? 'would-update' : 'would-create',
159+
})
160+
continue
161+
}
162+
163+
if (existing.docs[0]) {
164+
const updated = await payload.update({
165+
id: existing.docs[0].id,
166+
collection: 'pages',
167+
data,
168+
locale,
169+
overrideAccess: true,
170+
})
171+
172+
results.push({ id: updated.id, slug: doc.slug, status: 'updated' })
173+
} else {
174+
const created = await payload.create({
175+
collection: 'pages',
176+
data,
177+
locale,
178+
overrideAccess: true,
179+
})
180+
181+
results.push({ id: created.id, slug: doc.slug, status: 'created' })
182+
}
183+
} catch (error) {
184+
results.push({
185+
id: doc.id,
186+
slug: doc.slug,
187+
status: 'rejected',
188+
error: toErrorDetails(error),
189+
})
190+
}
191+
}
192+
} finally {
193+
await payload?.db?.destroy?.()
194+
}
195+
196+
const rejected = results.filter((result) => result.status === 'rejected')
197+
198+
console.log(
199+
JSON.stringify(
200+
{
201+
file: path.relative(path.resolve(dirname, '..'), inputPath),
202+
locale,
203+
dryRun,
204+
total: results.length,
205+
rejected: rejected.length,
206+
results,
207+
},
208+
null,
209+
2,
210+
),
211+
)
212+
213+
if (rejected.length > 0) {
214+
process.exitCode = 1
215+
}
216+
}
217+
218+
main().catch((error) => {
219+
console.error(toErrorDetails(error))
220+
process.exit(1)
221+
})

scripts/prepare-standalone.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const publicDest = join(standaloneDir, "public")
1212
const mediaSrc = "media"
1313
const mediaDest = join(standaloneDir, "media")
1414

15+
const standaloneScriptsSrc = join("scripts", "import-pages-standalone.mjs")
16+
const standaloneScriptsDest = join(standaloneDir, "scripts", "import-pages.mjs")
17+
1518
if (!existsSync(standaloneDir)) {
1619
throw new Error("Missing .next/standalone. Run `next build` with `output: 'standalone'` first.")
1720
}
@@ -31,4 +34,9 @@ if (existsSync(mediaSrc)) {
3134
cpSync(mediaSrc, mediaDest, { recursive: true, force: true })
3235
}
3336

37+
if (existsSync(standaloneScriptsSrc)) {
38+
mkdirSync(join(standaloneDir, "scripts"), { recursive: true })
39+
cpSync(standaloneScriptsSrc, standaloneScriptsDest, { force: true })
40+
}
41+
3442
console.log("Standalone assets prepared.")

0 commit comments

Comments
 (0)