create-avocado-site 0.9.0
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 +201 -0
- package/README.md +65 -0
- package/assets/generated-images/gdrive_1ql_bdk2KM_27u01rkMd8KoiCRJ_R7UOy.webp +0 -0
- package/assets/generated-images/gen_1774991902955_0p1i2o.webp +0 -0
- package/assets/generated-images/gen_1776458907692_zlrbkg.webp +0 -0
- package/assets/generated-images/gen_1788955681679_454dc316.webp +0 -0
- package/assets/generated-images/var_1773434968055_8g99nk.webp +0 -0
- package/assets/generated-images/var_1773435042084_wvet78.webp +0 -0
- package/assets/generated-images/var_1773435061562_6hf8s6.webp +0 -0
- package/assets/generated-images/var_1773435080764_6ocahu.webp +0 -0
- package/assets/generated-images/var_1773435101045_1y544z.webp +0 -0
- package/assets/generated-images/var_1773872370443_poycxy.webp +0 -0
- package/assets/generated-images/var_1776858715876_iuu72w.webp +0 -0
- package/assets/logos/avocado-hub.svg +7 -0
- package/dist/cms-config.d.ts +34 -0
- package/dist/cms-config.js +103 -0
- package/dist/generator.d.ts +22 -0
- package/dist/generator.js +92 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +109 -0
- package/dist/instructions.d.ts +19 -0
- package/dist/instructions.js +82 -0
- package/dist/next-version.d.ts +15 -0
- package/dist/next-version.js +43 -0
- package/dist/ports.d.ts +11 -0
- package/dist/ports.js +61 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +119 -0
- package/dist/templates/assets.d.ts +2 -0
- package/dist/templates/assets.js +41 -0
- package/dist/templates/common.d.ts +45 -0
- package/dist/templates/common.js +174 -0
- package/dist/templates/contentful.d.ts +2 -0
- package/dist/templates/contentful.js +379 -0
- package/dist/templates/demo-app.d.ts +3 -0
- package/dist/templates/demo-app.js +465 -0
- package/dist/templates/demo-content.d.ts +2 -0
- package/dist/templates/demo-content.js +7 -0
- package/dist/templates/sanity.d.ts +2 -0
- package/dist/templates/sanity.js +289 -0
- package/dist/templates/static.d.ts +2 -0
- package/dist/templates/static.js +81 -0
- package/dist/templates/strapi.d.ts +2 -0
- package/dist/templates/strapi.js +235 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +1 -0
- package/dist/versions.d.ts +32 -0
- package/dist/versions.js +32 -0
- package/package.json +51 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { CMS_CONFIGS } from "../cms-config.js";
|
|
2
|
+
export function editorApiRoute(config) {
|
|
3
|
+
const c = CMS_CONFIGS[config.cms];
|
|
4
|
+
return `import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
|
|
5
|
+
${c.publishImport}
|
|
6
|
+
${c.getPagesImport ? c.getPagesImport + "\n" : ""}
|
|
7
|
+
export const { GET, POST, OPTIONS } = createEditorApiHandler({
|
|
8
|
+
getPages: ${c.getPages},
|
|
9
|
+
publishSecret: process.env.PUBLISH_TOKEN?.trim() || undefined,
|
|
10
|
+
onPublish: ${c.onPublish},
|
|
11
|
+
})
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
export function revalidateRoute(config) {
|
|
15
|
+
const c = CMS_CONFIGS[config.cms];
|
|
16
|
+
if (!c.revalidate)
|
|
17
|
+
return null;
|
|
18
|
+
const r = c.revalidate;
|
|
19
|
+
const headerLine = r.secretHeader
|
|
20
|
+
? ` secretHeader: ${r.secretHeader},\n`
|
|
21
|
+
: "";
|
|
22
|
+
return `import { createRevalidateHandler } from "@avocadostudio-ai/site-sdk/routes"
|
|
23
|
+
${r.getPagesImport}
|
|
24
|
+
|
|
25
|
+
export const POST = createRevalidateHandler({
|
|
26
|
+
secretEnvVar: "${r.secretEnvVar}",
|
|
27
|
+
${headerLine} extractSlug: ${r.extractSlug},
|
|
28
|
+
getPages: ${r.getPagesFn},
|
|
29
|
+
siteId: "${config.siteId}",
|
|
30
|
+
})
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
export function manifestFile(config) {
|
|
34
|
+
if (config.blocksMode === "custom") {
|
|
35
|
+
return `import type { BlockManifest } from "@avocadostudio-ai/shared"
|
|
36
|
+
import { getManifestImageFields } from "@avocadostudio-ai/site-sdk/routes"
|
|
37
|
+
|
|
38
|
+
const manifest: BlockManifest = {
|
|
39
|
+
version: 1,
|
|
40
|
+
blocks: [
|
|
41
|
+
// {
|
|
42
|
+
// type: "Hero",
|
|
43
|
+
// displayName: "Hero Section",
|
|
44
|
+
// propsSchema: {
|
|
45
|
+
// type: "object",
|
|
46
|
+
// properties: {
|
|
47
|
+
// heading: { type: "string" },
|
|
48
|
+
// imageUrl: { type: "string" },
|
|
49
|
+
// },
|
|
50
|
+
// required: ["heading"],
|
|
51
|
+
// },
|
|
52
|
+
// defaultProps: { heading: "Welcome" },
|
|
53
|
+
// },
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getManifest() { return manifest }
|
|
58
|
+
export const { imageFields, listImageFields, listFieldNames } = getManifestImageFields(manifest)
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
return `import { buildBlockManifest } from "@avocadostudio-ai/site-sdk/editor-manifest"
|
|
62
|
+
import { getManifestImageFields } from "@avocadostudio-ai/site-sdk/routes"
|
|
63
|
+
|
|
64
|
+
export const { imageFields, listImageFields, listFieldNames } = getManifestImageFields(buildBlockManifest())
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
function adjustImportDepth(pageImport, mode) {
|
|
68
|
+
// CMS config encodes import paths for the single-route layout
|
|
69
|
+
// (`app/[[...slug]]/page.tsx`). The preview page lives one directory deeper
|
|
70
|
+
// (`app/preview-draft/[[...slug]]/page.tsx`), so prefix one extra `../` on
|
|
71
|
+
// every relative import. "auto" and "static" share the shallower depth.
|
|
72
|
+
if (mode !== "preview")
|
|
73
|
+
return pageImport;
|
|
74
|
+
return pageImport.replace(/from "\.\.\//g, `from "../../`);
|
|
75
|
+
}
|
|
76
|
+
export function pageFile(config, mode = "static") {
|
|
77
|
+
const c = CMS_CONFIGS[config.cms];
|
|
78
|
+
const pageImport = adjustImportDepth(c.pageImport, mode);
|
|
79
|
+
// `generateStaticParams` is returned in every mode but is meaningless in the
|
|
80
|
+
// two dynamic ones, and exporting it from a `force-dynamic` route is a
|
|
81
|
+
// contradiction Next does not report.
|
|
82
|
+
const staticParams = mode === "static";
|
|
83
|
+
const factoryReturn = staticParams ? "{ Page, generateStaticParams, generateMetadata }" : "{ Page, generateMetadata }";
|
|
84
|
+
const dynamicLine = mode === "preview" ? `\nexport const dynamic = "force-dynamic"\n` : "";
|
|
85
|
+
const exports = staticParams
|
|
86
|
+
? "\nexport default Page\nexport { generateStaticParams, generateMetadata }\n"
|
|
87
|
+
: "\nexport default Page\nexport { generateMetadata }\n";
|
|
88
|
+
return `import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
|
|
89
|
+
${pageImport}
|
|
90
|
+
${dynamicLine}
|
|
91
|
+
const ${factoryReturn} = createSitePage({
|
|
92
|
+
mode: "${mode}",
|
|
93
|
+
siteId: "${config.siteId}",
|
|
94
|
+
getPage: ${c.getPage},
|
|
95
|
+
getSlugs: ${c.getSlugs},${c.getSiteConfig ? `\n getSiteConfig: ${c.getSiteConfig},` : ""}
|
|
96
|
+
})${exports}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `createSitePage` calls `notFound()` for an unknown slug, which renders this
|
|
100
|
+
* file with a real 404 status. Without it Next falls back to its own bare
|
|
101
|
+
* default page — correct status, but none of the site's chrome.
|
|
102
|
+
*/
|
|
103
|
+
export function notFoundFile() {
|
|
104
|
+
return `export default function NotFound() {
|
|
105
|
+
return (
|
|
106
|
+
<main style={{ padding: "4rem", textAlign: "center" }}>
|
|
107
|
+
<h1>404</h1>
|
|
108
|
+
<p>Page not found.</p>
|
|
109
|
+
</main>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
/** Next.js 15 and earlier: the rewrite lives in `middleware.ts`. */
|
|
115
|
+
export function middlewareFile() {
|
|
116
|
+
return `import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
|
|
117
|
+
|
|
118
|
+
export const { middleware, config } = createEditorMiddleware()
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Next.js 16+: `middleware` was renamed to the `proxy` convention, and `config`
|
|
123
|
+
* must be a static object literal — Next 16 reads it by static analysis and
|
|
124
|
+
* rejects a factory return value or an imported constant.
|
|
125
|
+
*/
|
|
126
|
+
export function proxyFile() {
|
|
127
|
+
return `import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
|
|
128
|
+
|
|
129
|
+
export const proxy = createEditorProxy().proxy
|
|
130
|
+
|
|
131
|
+
// Must stay a literal — Next.js 16 cannot read \`config\` from a factory result.
|
|
132
|
+
export const config = {
|
|
133
|
+
matcher: [
|
|
134
|
+
"/((?!_next|preview-draft|api|favicon\\\\.ico|icon\\\\.svg|logos/|generated-images/|.*\\\\.).*)",
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The integrate path's `.env.local.example`.
|
|
141
|
+
*
|
|
142
|
+
* It used to contradict the instructions printed beside it. Step 4 mounts the
|
|
143
|
+
* orchestrator inside the app at `/api/avocado` — there is no separate service
|
|
144
|
+
* — while this file set `ORCHESTRATOR_URL=http://localhost:4200`, pointing at
|
|
145
|
+
* the standalone server the integrator was never told to run. The env var
|
|
146
|
+
* wins, so every draft read went to a port with nothing on it and the editor
|
|
147
|
+
* showed published content with the edits silently dropped.
|
|
148
|
+
*
|
|
149
|
+
* `DRAFT_MODE_SECRET` was likewise pre-filled with `dev-secret` next to a step
|
|
150
|
+
* saying to set it to a long random string; it is generated now, as on the
|
|
151
|
+
* demo path.
|
|
152
|
+
*/
|
|
153
|
+
export function envExample(config) {
|
|
154
|
+
const c = CMS_CONFIGS[config.cms];
|
|
155
|
+
const common = `# The orchestrator runs inside this app, at /api/avocado. There is no separate
|
|
156
|
+
# service to start — this must name this app's own origin, not port 4200.
|
|
157
|
+
ORCHESTRATOR_URL=http://localhost:${config.sitePort}/api/avocado
|
|
158
|
+
|
|
159
|
+
# Where the editor is served from. Named twice on every request — CORS on the
|
|
160
|
+
# editor API and \`frame-ancestors\` on the preview — and \`withAvocado\` derives
|
|
161
|
+
# both from this one variable. Get it wrong and the site reads as offline while
|
|
162
|
+
# the preview stays a blank rectangle, neither of which mentions a port.
|
|
163
|
+
EDITOR_CORS_ORIGINS=http://localhost:${config.editorPort}
|
|
164
|
+
|
|
165
|
+
# Any long random string; this one was generated for you. Without it draft mode
|
|
166
|
+
# does not work, and the editor quietly renders the published page instead of
|
|
167
|
+
# your edits.
|
|
168
|
+
DRAFT_MODE_SECRET=${config.draftSecret}
|
|
169
|
+
|
|
170
|
+
# Optional: protects the publish endpoint.
|
|
171
|
+
# PUBLISH_TOKEN=
|
|
172
|
+
`;
|
|
173
|
+
return c.envVars ? `${c.envVars}\n\n${common}` : common;
|
|
174
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
export function contentfulTemplates(_config) {
|
|
2
|
+
return [
|
|
3
|
+
{ path: "lib/contentful.ts", content: CONTENTFUL_ADAPTER },
|
|
4
|
+
{ path: "lib/publish.ts", content: CONTENTFUL_PUBLISH },
|
|
5
|
+
];
|
|
6
|
+
}
|
|
7
|
+
const CONTENTFUL_ADAPTER = `import { createClient, type Entry, type Asset } from "contentful"
|
|
8
|
+
import { imageFields, listImageFields } from "./manifest"
|
|
9
|
+
import type { PageDoc, SiteConfig, BlockInstance } from "@avocadostudio-ai/shared"
|
|
10
|
+
|
|
11
|
+
let cachedClient: ReturnType<typeof createClient> | null = null
|
|
12
|
+
|
|
13
|
+
function getClient() {
|
|
14
|
+
if (cachedClient) return cachedClient
|
|
15
|
+
const spaceId = process.env.CONTENTFUL_SPACE_ID
|
|
16
|
+
const accessToken = process.env.CONTENTFUL_DELIVERY_TOKEN
|
|
17
|
+
if (!spaceId || !accessToken) throw new Error("CONTENTFUL_SPACE_ID and CONTENTFUL_DELIVERY_TOKEN required")
|
|
18
|
+
cachedClient = createClient({
|
|
19
|
+
space: spaceId, accessToken,
|
|
20
|
+
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
|
|
21
|
+
})
|
|
22
|
+
return cachedClient
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assetToUrl(asset: Asset | undefined): string {
|
|
26
|
+
if (!asset?.fields?.file) return ""
|
|
27
|
+
const file = asset.fields.file as { url?: string }
|
|
28
|
+
if (!file.url) return ""
|
|
29
|
+
return file.url.startsWith("//") ? \`https:\${file.url}\` : file.url
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function entryToBlockInstance(entry: Entry, includes?: { Asset?: Asset[] }): BlockInstance | null {
|
|
33
|
+
const contentTypeId = entry.sys.contentType?.sys?.id
|
|
34
|
+
if (!contentTypeId?.startsWith("block")) return null
|
|
35
|
+
const blockType = contentTypeId.replace(/^block/, "")
|
|
36
|
+
const imgFields = imageFields.get(blockType) ?? new Set<string>()
|
|
37
|
+
const listImgFieldsForType = listImageFields.get(blockType)
|
|
38
|
+
const props: Record<string, unknown> = {}
|
|
39
|
+
const fields = entry.fields as Record<string, unknown>
|
|
40
|
+
|
|
41
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
42
|
+
if (imgFields.has(key)) {
|
|
43
|
+
const linked = value as { sys?: { id?: string } } | undefined
|
|
44
|
+
if (linked?.sys?.id && includes?.Asset) {
|
|
45
|
+
const asset = includes.Asset.find((a) => a.sys.id === linked.sys.id)
|
|
46
|
+
props[key] = assetToUrl(asset as Asset)
|
|
47
|
+
} else if (linked && typeof linked === "object" && "fields" in linked) {
|
|
48
|
+
props[key] = assetToUrl(linked as Asset)
|
|
49
|
+
} else { props[key] = "" }
|
|
50
|
+
} else if (listImgFieldsForType?.has(key) && blockType === "CardGrid" && key === "cards") {
|
|
51
|
+
const refs = value as Array<Entry | { sys: { id: string } }>
|
|
52
|
+
if (Array.isArray(refs)) {
|
|
53
|
+
props[key] = refs.map((ref) => {
|
|
54
|
+
if ("fields" in ref) {
|
|
55
|
+
const cardInstance = entryToBlockInstance(ref as Entry, includes)
|
|
56
|
+
return cardInstance?.props
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}).filter(Boolean)
|
|
60
|
+
} else { props[key] = [] }
|
|
61
|
+
} else { props[key] = value }
|
|
62
|
+
}
|
|
63
|
+
return { id: entry.sys.id.replace(/^block_/, ""), type: blockType, props }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function entryToPageDoc(page: Entry, includes?: { Asset?: Asset[] }): PageDoc | null {
|
|
67
|
+
const fields = page.fields as Record<string, unknown>
|
|
68
|
+
const slug = fields.slug as string | undefined
|
|
69
|
+
if (!slug) return null
|
|
70
|
+
const blockRefs = fields.blocks as Array<Entry | { sys: { id: string } }> | undefined
|
|
71
|
+
const blocks: BlockInstance[] = []
|
|
72
|
+
if (Array.isArray(blockRefs)) {
|
|
73
|
+
for (const ref of blockRefs) {
|
|
74
|
+
if ("fields" in ref) {
|
|
75
|
+
const block = entryToBlockInstance(ref as Entry, includes)
|
|
76
|
+
if (block) blocks.push(block)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
id: (fields.pageId as string) ?? page.sys.id,
|
|
82
|
+
slug, title: fields.title as string, blocks,
|
|
83
|
+
meta: fields.meta as PageDoc["meta"],
|
|
84
|
+
updatedAt: (fields.updatedAt as string) ?? page.sys.updatedAt,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function getContentfulPage(slug: string): Promise<PageDoc | null> {
|
|
89
|
+
const entries = await getClient().getEntries({ content_type: "page", "fields.slug": slug, include: 2, limit: 1 })
|
|
90
|
+
if (entries.items.length === 0) return null
|
|
91
|
+
return entryToPageDoc(entries.items[0], entries.includes as { Asset?: Asset[] })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getContentfulSlugs(): Promise<string[]> {
|
|
95
|
+
const entries = await getClient().getEntries({ content_type: "page", select: ["fields.slug"], limit: 100 })
|
|
96
|
+
return entries.items
|
|
97
|
+
.map((item) => (item.fields as Record<string, unknown>).slug)
|
|
98
|
+
.filter((slug): slug is string => typeof slug === "string" && slug.length > 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getContentfulPages(): Promise<PageDoc[]> {
|
|
102
|
+
const entries = await getClient().getEntries({ content_type: "page", include: 2, limit: 100 })
|
|
103
|
+
return entries.items
|
|
104
|
+
.map((page) => entryToPageDoc(page, entries.includes as { Asset?: Asset[] }))
|
|
105
|
+
.filter((p): p is PageDoc => p !== null)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function getContentfulSiteConfig(): Promise<SiteConfig> {
|
|
109
|
+
try {
|
|
110
|
+
const entries = await getClient().getEntries({ content_type: "siteConfig", "fields.configKey": "default", limit: 1 })
|
|
111
|
+
if (entries.items.length === 0) return {}
|
|
112
|
+
const fields = entries.items[0].fields as Record<string, unknown>
|
|
113
|
+
return {
|
|
114
|
+
name: (fields.name as string) || undefined,
|
|
115
|
+
logo: (fields.logo as string) || undefined,
|
|
116
|
+
navLabels: (fields.navLabels as Record<string, string>) || undefined,
|
|
117
|
+
}
|
|
118
|
+
} catch { return {} }
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
const CONTENTFUL_PUBLISH = `import type { OnPublishFn, InlineAsset } from "@avocadostudio-ai/site-sdk/routes"
|
|
122
|
+
import { isSafeImageUrl } from "@avocadostudio-ai/site-sdk/routes"
|
|
123
|
+
import { imageFields, listFieldNames } from "./manifest"
|
|
124
|
+
|
|
125
|
+
export interface ContentfulPublishOptions {
|
|
126
|
+
spaceId: string
|
|
127
|
+
environmentId?: string
|
|
128
|
+
managementToken: string
|
|
129
|
+
locale?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createContentfulPublishHandler(opts: ContentfulPublishOptions): OnPublishFn {
|
|
133
|
+
const locale = opts.locale ?? "en-US"
|
|
134
|
+
|
|
135
|
+
let cachedEnvPromise: ReturnType<typeof loadEnvironment> | null = null
|
|
136
|
+
|
|
137
|
+
async function loadEnvironment() {
|
|
138
|
+
const mod = await import("contentful-management")
|
|
139
|
+
const factory = ("default" in mod && typeof (mod.default as Record<string, unknown>)?.createClient === "function")
|
|
140
|
+
? (mod.default as { createClient: typeof mod.createClient }).createClient
|
|
141
|
+
: mod.createClient
|
|
142
|
+
const client = factory({ accessToken: opts.managementToken })
|
|
143
|
+
const space = await client.getSpace(opts.spaceId)
|
|
144
|
+
return space.getEnvironment(opts.environmentId ?? "master")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getEnvironment() {
|
|
148
|
+
if (!cachedEnvPromise) {
|
|
149
|
+
cachedEnvPromise = loadEnvironment().catch((err) => { cachedEnvPromise = null; throw err })
|
|
150
|
+
}
|
|
151
|
+
return cachedEnvPromise
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isReferenceList(blockType: string, listKey: string): string | null {
|
|
155
|
+
if (blockType === "CardGrid" && listKey === "cards") return "blockCard"
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CfEnv = Awaited<ReturnType<typeof loadEnvironment>>
|
|
160
|
+
type CfEntry = Awaited<ReturnType<CfEnv["getEntries"]>>["items"][0]
|
|
161
|
+
|
|
162
|
+
async function ensureAsset(
|
|
163
|
+
env: CfEnv, imageUrl: string, alt: string, assets?: Record<string, InlineAsset>
|
|
164
|
+
): Promise<string | null> {
|
|
165
|
+
const inlineAsset = assets?.[imageUrl]
|
|
166
|
+
if (inlineAsset) {
|
|
167
|
+
try {
|
|
168
|
+
const bytes = Buffer.from(inlineAsset.data, "base64")
|
|
169
|
+
const upload = await env.createUpload({ file: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer })
|
|
170
|
+
const asset = await env.createAsset({
|
|
171
|
+
fields: {
|
|
172
|
+
title: { [locale]: alt || inlineAsset.fileName },
|
|
173
|
+
description: { [locale]: imageUrl },
|
|
174
|
+
file: { [locale]: { contentType: inlineAsset.mimeType, fileName: inlineAsset.fileName, uploadFrom: { sys: { type: "Link", linkType: "Upload", id: upload.sys.id } } } },
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
const processed = await asset.processForAllLocales()
|
|
178
|
+
const published = await processed.publish()
|
|
179
|
+
return published.sys.id
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.warn(\`[contentful-publish] inline asset upload failed for \${imageUrl}: \${err instanceof Error ? err.message : err}\`)
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!imageUrl.startsWith("http") || !isSafeImageUrl(imageUrl)) {
|
|
187
|
+
try {
|
|
188
|
+
const asset = await env.createAsset({
|
|
189
|
+
fields: {
|
|
190
|
+
title: { [locale]: alt || imageUrl },
|
|
191
|
+
description: { [locale]: imageUrl },
|
|
192
|
+
file: { [locale]: { contentType: "image/png", fileName: imageUrl.split("/").pop() || "image.png", upload: \`https://placehold.co/800x600/e4e4e7/52525b.png?text=\${encodeURIComponent(alt || "Image")}\` } },
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
const processed = await asset.processForAllLocales()
|
|
196
|
+
const published = await processed.publish()
|
|
197
|
+
return published.sys.id
|
|
198
|
+
} catch { return null }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const existing = await env.getAssets({ "fields.description": imageUrl, limit: 1 })
|
|
203
|
+
if (existing.items.length > 0) return existing.items[0].sys.id
|
|
204
|
+
} catch { /* continue */ }
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const ext = imageUrl.toLowerCase().split(".").pop()?.split("?")[0] ?? ""
|
|
208
|
+
const ct = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : ext === "svg" ? "image/svg+xml" : "image/jpeg"
|
|
209
|
+
const asset = await env.createAsset({
|
|
210
|
+
fields: {
|
|
211
|
+
title: { [locale]: alt || imageUrl },
|
|
212
|
+
description: { [locale]: imageUrl },
|
|
213
|
+
file: { [locale]: { contentType: ct, fileName: imageUrl.split("/").pop()?.split("?")[0] || "image.jpg", upload: imageUrl } },
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
const processed = await asset.processForAllLocales()
|
|
217
|
+
const published = await processed.publish()
|
|
218
|
+
return published.sys.id
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.warn(\`[contentful-publish] asset upload failed for \${imageUrl}: \${err instanceof Error ? err.message : err}\`)
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assetLink(assetId: string) {
|
|
226
|
+
return { [locale]: { sys: { type: "Link", linkType: "Asset", id: assetId } } }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function entryLink(entryId: string) {
|
|
230
|
+
return { sys: { type: "Link" as const, linkType: "Entry" as const, id: entryId } }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function existingAssetId(fieldValue: unknown): string | null {
|
|
234
|
+
if (!fieldValue || typeof fieldValue !== "object") return null
|
|
235
|
+
const localeVal = (fieldValue as Record<string, unknown>)[locale]
|
|
236
|
+
if (!localeVal || typeof localeVal !== "object") return null
|
|
237
|
+
const sys = (localeVal as Record<string, unknown>).sys as { id?: string; linkType?: string } | undefined
|
|
238
|
+
return sys?.linkType === "Asset" && sys.id ? sys.id : null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function buildBlockFields(
|
|
242
|
+
env: CfEnv, blockType: string, props: Record<string, unknown>,
|
|
243
|
+
resolveAsset: (url: string, alt: string) => Promise<string | null>,
|
|
244
|
+
existingFields?: Record<string, unknown>,
|
|
245
|
+
): Promise<Record<string, unknown>> {
|
|
246
|
+
const imgFields = imageFields.get(blockType) ?? new Set<string>()
|
|
247
|
+
const listFieldsForType = listFieldNames.get(blockType) ?? new Set<string>()
|
|
248
|
+
const fields: Record<string, unknown> = {}
|
|
249
|
+
|
|
250
|
+
for (const [key, value] of Object.entries(props)) {
|
|
251
|
+
if (key === "headingLevel") continue
|
|
252
|
+
if (imgFields.has(key) && typeof value === "string" && value) {
|
|
253
|
+
const altKey = key.replace(/Url$/, "Alt").replace(/^image$/, "imageAlt")
|
|
254
|
+
const alt = typeof props[altKey] === "string" ? (props[altKey] as string) : ""
|
|
255
|
+
const assetId = await resolveAsset(value, alt)
|
|
256
|
+
if (assetId) { fields[key] = assetLink(assetId) }
|
|
257
|
+
else {
|
|
258
|
+
const prevAssetId = existingAssetId(existingFields?.[key])
|
|
259
|
+
if (prevAssetId) fields[key] = assetLink(prevAssetId)
|
|
260
|
+
}
|
|
261
|
+
} else if (listFieldsForType.has(key)) {
|
|
262
|
+
if (!isReferenceList(blockType, key)) { fields[key] = { [locale]: value } }
|
|
263
|
+
} else { fields[key] = { [locale]: value } }
|
|
264
|
+
}
|
|
265
|
+
return fields
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return async (pages, config, context) => {
|
|
269
|
+
const env = await getEnvironment()
|
|
270
|
+
const assetCache = new Map<string, Promise<string | null>>()
|
|
271
|
+
const cachedEnsureAsset = (imageUrl: string, alt: string) => {
|
|
272
|
+
const cached = assetCache.get(imageUrl)
|
|
273
|
+
if (cached) return cached
|
|
274
|
+
const promise = ensureAsset(env, imageUrl, alt, context?.assets)
|
|
275
|
+
assetCache.set(imageUrl, promise)
|
|
276
|
+
return promise
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const upsertResults = await Promise.allSettled(pages.map(async (page) => {
|
|
280
|
+
const blockEntries = await Promise.all(page.blocks.map(async (block) => {
|
|
281
|
+
const contentTypeId = \`block\${block.type}\`
|
|
282
|
+
const rawId = block.id.startsWith("block_") ? block.id : \`block_\${block.id}\`
|
|
283
|
+
const entryId = rawId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
284
|
+
|
|
285
|
+
let existingEntry: CfEntry | null = null
|
|
286
|
+
try { existingEntry = await env.getEntry(entryId) } catch { /* first publish */ }
|
|
287
|
+
const existingFields = existingEntry?.fields as Record<string, unknown> | undefined
|
|
288
|
+
|
|
289
|
+
const blockFields = await buildBlockFields(env, block.type, block.props, cachedEnsureAsset, existingFields)
|
|
290
|
+
|
|
291
|
+
const blockListFields = listFieldNames.get(block.type) ?? new Set<string>()
|
|
292
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
293
|
+
const refTarget = isReferenceList(block.type, key)
|
|
294
|
+
if (refTarget && Array.isArray(value) && blockListFields.has(key)) {
|
|
295
|
+
const items = value as Record<string, unknown>[]
|
|
296
|
+
const cardRefs = await Promise.all(
|
|
297
|
+
items.map(async (item, i) => {
|
|
298
|
+
const cardEntryId = \`card_\${entryId}_\${i}\`.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
299
|
+
let existingCard: CfEntry | null = null
|
|
300
|
+
try { existingCard = await env.getEntry(cardEntryId) } catch { /* first publish */ }
|
|
301
|
+
const childFields = await buildBlockFields(env, "Card", item, cachedEnsureAsset,
|
|
302
|
+
existingCard?.fields as Record<string, unknown> | undefined)
|
|
303
|
+
if (existingCard) {
|
|
304
|
+
existingCard.fields = childFields
|
|
305
|
+
const updated = await existingCard.update()
|
|
306
|
+
await updated.publish()
|
|
307
|
+
} else {
|
|
308
|
+
const created = await env.createEntryWithId(refTarget, cardEntryId, { fields: childFields })
|
|
309
|
+
await created.publish()
|
|
310
|
+
}
|
|
311
|
+
return entryLink(cardEntryId)
|
|
312
|
+
})
|
|
313
|
+
)
|
|
314
|
+
blockFields[key] = { [locale]: cardRefs }
|
|
315
|
+
for (let i = items.length; i < items.length + 20; i++) {
|
|
316
|
+
const staleCardId = \`card_\${entryId}_\${i}\`.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
317
|
+
try {
|
|
318
|
+
const staleCard = await env.getEntry(staleCardId)
|
|
319
|
+
try { await staleCard.unpublish() } catch {}
|
|
320
|
+
await staleCard.delete()
|
|
321
|
+
} catch { break }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (existingEntry) {
|
|
327
|
+
existingEntry.fields = blockFields
|
|
328
|
+
const updated = await existingEntry.update()
|
|
329
|
+
await updated.publish()
|
|
330
|
+
return entryLink(updated.sys.id)
|
|
331
|
+
} else {
|
|
332
|
+
const entry = await env.createEntryWithId(contentTypeId, entryId, { fields: blockFields })
|
|
333
|
+
await entry.publish()
|
|
334
|
+
return entryLink(entry.sys.id)
|
|
335
|
+
}
|
|
336
|
+
}))
|
|
337
|
+
|
|
338
|
+
const pageFields = {
|
|
339
|
+
slug: { [locale]: page.slug }, title: { [locale]: page.title },
|
|
340
|
+
pageId: { [locale]: page.id }, blocks: { [locale]: blockEntries },
|
|
341
|
+
meta: { [locale]: page.meta ?? null }, updatedAt: { [locale]: page.updatedAt },
|
|
342
|
+
}
|
|
343
|
+
const existingPages = await env.getEntries({ content_type: "page", "fields.slug": page.slug, limit: 1 })
|
|
344
|
+
if (existingPages.items.length > 0) {
|
|
345
|
+
const existing = existingPages.items[0]
|
|
346
|
+
existing.fields = pageFields
|
|
347
|
+
const updated = await existing.update()
|
|
348
|
+
await updated.publish()
|
|
349
|
+
} else {
|
|
350
|
+
const created = await env.createEntry("page", { fields: pageFields })
|
|
351
|
+
await created.publish()
|
|
352
|
+
}
|
|
353
|
+
return page.slug
|
|
354
|
+
}))
|
|
355
|
+
|
|
356
|
+
const failed = upsertResults
|
|
357
|
+
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
358
|
+
.map((r, i) => \`\${pages[i]?.slug ?? "?"}: \${r.reason instanceof Error ? r.reason.message : String(r.reason)}\`)
|
|
359
|
+
|
|
360
|
+
if (config.name || config.logo || config.navLabels) {
|
|
361
|
+
const configFields = {
|
|
362
|
+
configKey: { [locale]: "default" }, name: { [locale]: config.name ?? "" },
|
|
363
|
+
logo: { [locale]: config.logo ?? "" }, navLabels: { [locale]: config.navLabels ?? {} },
|
|
364
|
+
}
|
|
365
|
+
const existingConfig = await env.getEntries({ content_type: "siteConfig", "fields.configKey": "default", limit: 1 })
|
|
366
|
+
let configEntry
|
|
367
|
+
if (existingConfig.items.length > 0) {
|
|
368
|
+
configEntry = existingConfig.items[0]
|
|
369
|
+
configEntry.fields = configFields
|
|
370
|
+
configEntry = await configEntry.update()
|
|
371
|
+
} else { configEntry = await env.createEntry("siteConfig", { fields: configFields }) }
|
|
372
|
+
await configEntry.publish()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (failed.length > 0) return { ok: false, error: \`Failed to publish: \${failed.join("; ")}\` }
|
|
376
|
+
return { ok: true }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
`;
|