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,289 @@
|
|
|
1
|
+
export function sanityTemplates(_config) {
|
|
2
|
+
return [
|
|
3
|
+
{ path: "lib/sanity.client.ts", content: SANITY_CLIENT },
|
|
4
|
+
{ path: "lib/sanity.queries.ts", content: SANITY_QUERIES },
|
|
5
|
+
{ path: "lib/sanity.image.ts", content: SANITY_IMAGE },
|
|
6
|
+
{ path: "lib/sanity.utils.ts", content: SANITY_UTILS },
|
|
7
|
+
{ path: "lib/sanity.fetch.ts", content: SANITY_FETCH },
|
|
8
|
+
{ path: "lib/publish.ts", content: SANITY_PUBLISH },
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
const SANITY_CLIENT = `import { createClient } from "@sanity/client"
|
|
12
|
+
|
|
13
|
+
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
|
|
14
|
+
if (!projectId) throw new Error("NEXT_PUBLIC_SANITY_PROJECT_ID is required")
|
|
15
|
+
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production"
|
|
16
|
+
const apiVersion = "2024-01-01"
|
|
17
|
+
|
|
18
|
+
export const client = createClient({
|
|
19
|
+
projectId, dataset, apiVersion,
|
|
20
|
+
useCdn: process.env.NODE_ENV === "production",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const writeClient = createClient({
|
|
24
|
+
projectId, dataset, apiVersion,
|
|
25
|
+
useCdn: false,
|
|
26
|
+
token: process.env.SANITY_API_TOKEN,
|
|
27
|
+
})
|
|
28
|
+
`;
|
|
29
|
+
const SANITY_QUERIES = `import groq from "groq"
|
|
30
|
+
|
|
31
|
+
export const pageBySlugQuery = groq\`
|
|
32
|
+
*[_type == "page" && slug.current == $slug][0] {
|
|
33
|
+
_id, title, "slug": slug.current, pageId,
|
|
34
|
+
blocks[]-> { _id, _type, ..., "cards": cards[]-> { ... } },
|
|
35
|
+
meta, _updatedAt
|
|
36
|
+
}
|
|
37
|
+
\`
|
|
38
|
+
|
|
39
|
+
export const allSlugsQuery = groq\`
|
|
40
|
+
*[_type == "page"] { "slug": slug.current }
|
|
41
|
+
\`
|
|
42
|
+
|
|
43
|
+
export const allPagesQuery = groq\`
|
|
44
|
+
*[_type == "page"] {
|
|
45
|
+
_id, title, "slug": slug.current, pageId,
|
|
46
|
+
blocks[]-> { _id, _type, ..., "cards": cards[]-> { ... } },
|
|
47
|
+
meta, _updatedAt
|
|
48
|
+
}
|
|
49
|
+
\`
|
|
50
|
+
|
|
51
|
+
export const siteConfigQuery = groq\`
|
|
52
|
+
*[_type == "siteConfig"][0] { name, logo, navLabels }
|
|
53
|
+
\`
|
|
54
|
+
`;
|
|
55
|
+
const SANITY_IMAGE = `import imageUrlBuilder from "@sanity/image-url"
|
|
56
|
+
import { client } from "./sanity.client"
|
|
57
|
+
|
|
58
|
+
const builder = imageUrlBuilder(client)
|
|
59
|
+
|
|
60
|
+
export function sanityImageUrl(source: unknown): string {
|
|
61
|
+
if (!source || typeof source !== "object") return ""
|
|
62
|
+
const ref = source as { _type?: string; asset?: { _ref?: string } }
|
|
63
|
+
if (ref._type !== "image" || !ref.asset?._ref) return ""
|
|
64
|
+
return builder.image(source).auto("format").url()
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
const SANITY_UTILS = `export { blockTypeToCamel as toSanityName, camelToBlockType as sanityNameToBlockType } from "@avocadostudio-ai/shared"
|
|
68
|
+
|
|
69
|
+
export const REFERENCE_LISTS: Record<string, Set<string>> = {
|
|
70
|
+
CardGrid: new Set(["cards"]),
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
const SANITY_FETCH = `import { client } from "./sanity.client"
|
|
74
|
+
import { pageBySlugQuery, allSlugsQuery, allPagesQuery, siteConfigQuery } from "./sanity.queries"
|
|
75
|
+
import { sanityImageUrl } from "./sanity.image"
|
|
76
|
+
import { sanityNameToBlockType } from "./sanity.utils"
|
|
77
|
+
import { imageFields, listImageFields } from "./manifest"
|
|
78
|
+
import type { PageDoc, SiteConfig, BlockInstance } from "@avocadostudio-ai/shared"
|
|
79
|
+
|
|
80
|
+
function sanityDocToBlock(doc: Record<string, unknown>): BlockInstance | null {
|
|
81
|
+
const type = doc._type as string | undefined
|
|
82
|
+
if (!type) return null
|
|
83
|
+
const blockType = sanityNameToBlockType(type)
|
|
84
|
+
const imgFields = imageFields.get(blockType) ?? new Set<string>()
|
|
85
|
+
const listImgFields = listImageFields.get(blockType) ?? new Map<string, Set<string>>()
|
|
86
|
+
|
|
87
|
+
const props: Record<string, unknown> = {}
|
|
88
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
89
|
+
if (key.startsWith("_")) continue
|
|
90
|
+
if (value === null || value === undefined) continue
|
|
91
|
+
if (imgFields.has(key)) {
|
|
92
|
+
props[key] = sanityImageUrl(value)
|
|
93
|
+
} else if (listImgFields.has(key) && Array.isArray(value)) {
|
|
94
|
+
const itemImageKeys = listImgFields.get(key)!
|
|
95
|
+
props[key] = value.map((item: Record<string, unknown>) => {
|
|
96
|
+
const resolved: Record<string, unknown> = {}
|
|
97
|
+
for (const [ik, iv] of Object.entries(item)) {
|
|
98
|
+
if (ik.startsWith("_")) continue
|
|
99
|
+
resolved[ik] = itemImageKeys.has(ik) ? sanityImageUrl(iv) : iv
|
|
100
|
+
}
|
|
101
|
+
return resolved
|
|
102
|
+
})
|
|
103
|
+
} else {
|
|
104
|
+
props[key] = value
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { id: ((doc._id as string) ?? "").replace(/^block-/, ""), type: blockType, props }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanityDocToPageDoc(doc: Record<string, unknown>): PageDoc | null {
|
|
111
|
+
const slug = doc.slug as string | undefined
|
|
112
|
+
if (!slug) return null
|
|
113
|
+
const rawBlocks = doc.blocks as Record<string, unknown>[] | undefined
|
|
114
|
+
const blocks: BlockInstance[] = []
|
|
115
|
+
if (Array.isArray(rawBlocks)) {
|
|
116
|
+
for (const raw of rawBlocks) {
|
|
117
|
+
const block = sanityDocToBlock(raw)
|
|
118
|
+
if (block) blocks.push(block)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
id: (doc.pageId as string) ?? (doc._id as string) ?? "",
|
|
123
|
+
slug, title: (doc.title as string) ?? "", blocks,
|
|
124
|
+
meta: (doc.meta ?? undefined) as PageDoc["meta"],
|
|
125
|
+
updatedAt: (doc._updatedAt as string) ?? new Date().toISOString(),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getSanityPage(slug: string): Promise<PageDoc | null> {
|
|
130
|
+
const doc = await client.fetch<Record<string, unknown> | null>(pageBySlugQuery, { slug })
|
|
131
|
+
return doc ? sanityDocToPageDoc(doc) : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function getSanitySlugs(): Promise<string[]> {
|
|
135
|
+
const docs = await client.fetch<Array<{ slug: string }>>(allSlugsQuery)
|
|
136
|
+
return docs.map((d) => d.slug).filter(Boolean)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getSanityPages(): Promise<PageDoc[]> {
|
|
140
|
+
const docs = await client.fetch<Array<Record<string, unknown>>>(allPagesQuery)
|
|
141
|
+
return docs.map(sanityDocToPageDoc).filter((p): p is PageDoc => p !== null)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function getSanitySiteConfig(): Promise<SiteConfig> {
|
|
145
|
+
const doc = await client.fetch<Record<string, unknown> | null>(siteConfigQuery)
|
|
146
|
+
if (!doc) return {}
|
|
147
|
+
const rawLabels = doc.navLabels as Array<{ slug?: string; label?: string }> | undefined
|
|
148
|
+
const navLabels = Array.isArray(rawLabels) && rawLabels.length > 0
|
|
149
|
+
? Object.fromEntries(rawLabels.filter((l) => l.slug && l.label).map((l) => [l.slug!, l.label!]))
|
|
150
|
+
: undefined
|
|
151
|
+
return {
|
|
152
|
+
name: (doc.name as string) || undefined,
|
|
153
|
+
logo: (doc.logo as string) || undefined,
|
|
154
|
+
navLabels,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
const SANITY_PUBLISH = `import type { OnPublishFn } from "@avocadostudio-ai/site-sdk/routes"
|
|
159
|
+
import { createImageResolver } from "@avocadostudio-ai/site-sdk/routes"
|
|
160
|
+
import { writeClient } from "./sanity.client"
|
|
161
|
+
import { toSanityName, REFERENCE_LISTS } from "./sanity.utils"
|
|
162
|
+
import { imageFields, listImageFields } from "./manifest"
|
|
163
|
+
|
|
164
|
+
type SanityImageRef = { _type: "image"; asset: { _type: "reference"; _ref: string } }
|
|
165
|
+
|
|
166
|
+
async function resolveImageFields(
|
|
167
|
+
item: Record<string, unknown>,
|
|
168
|
+
imageKeys: Set<string>,
|
|
169
|
+
existing: Record<string, unknown> | undefined,
|
|
170
|
+
resolver: { resolve: (url: string) => Promise<SanityImageRef | null> },
|
|
171
|
+
): Promise<Record<string, unknown>> {
|
|
172
|
+
const out: Record<string, unknown> = {}
|
|
173
|
+
for (const [k, v] of Object.entries(item)) {
|
|
174
|
+
if (imageKeys.has(k) && typeof v === "string" && v) {
|
|
175
|
+
const ref = await resolver.resolve(v)
|
|
176
|
+
out[k] = ref ?? existing?.[k] ?? undefined
|
|
177
|
+
} else {
|
|
178
|
+
out[k] = v
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchExistingDocs(ids: string[]): Promise<Map<string, Record<string, unknown>>> {
|
|
185
|
+
const result = new Map<string, Record<string, unknown>>()
|
|
186
|
+
if (ids.length === 0) return result
|
|
187
|
+
try {
|
|
188
|
+
const docs = await writeClient.fetch<Array<Record<string, unknown>>>(\`*[_id in $ids]\`, { ids })
|
|
189
|
+
for (const doc of docs) result.set(doc._id as string, doc)
|
|
190
|
+
} catch { /* first publish */ }
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function createSanityPublishHandler(): OnPublishFn {
|
|
195
|
+
return async (pages, config, context) => {
|
|
196
|
+
const imageResolver = createImageResolver<SanityImageRef>(
|
|
197
|
+
async (bytes, fileName, mimeType) => {
|
|
198
|
+
const asset = await writeClient.assets.upload("image", bytes, { filename: fileName, contentType: mimeType })
|
|
199
|
+
return { _type: "image" as const, asset: { _type: "reference" as const, _ref: asset._id } }
|
|
200
|
+
},
|
|
201
|
+
context?.assets,
|
|
202
|
+
)
|
|
203
|
+
const tx = writeClient.transaction()
|
|
204
|
+
|
|
205
|
+
for (const page of pages) {
|
|
206
|
+
const docIds: string[] = []
|
|
207
|
+
for (const block of page.blocks) {
|
|
208
|
+
const blockId = \`block-\${block.id}\`.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 128)
|
|
209
|
+
docIds.push(blockId)
|
|
210
|
+
const refLists = REFERENCE_LISTS[block.type]
|
|
211
|
+
if (refLists) {
|
|
212
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
213
|
+
if (refLists.has(key) && Array.isArray(value)) {
|
|
214
|
+
for (let i = 0; i < value.length; i++) {
|
|
215
|
+
docIds.push(\`card-\${blockId}-\${i}\`.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 128))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const existingDocs = await fetchExistingDocs(docIds)
|
|
222
|
+
|
|
223
|
+
const blockRefs = await Promise.all(page.blocks.map(async (block) => {
|
|
224
|
+
const blockId = \`block-\${block.id}\`.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 128)
|
|
225
|
+
const blockType = toSanityName(block.type)
|
|
226
|
+
const imgFields = imageFields.get(block.type) ?? new Set<string>()
|
|
227
|
+
const listImgFields = listImageFields.get(block.type) ?? new Map<string, Set<string>>()
|
|
228
|
+
const refLists = REFERENCE_LISTS[block.type]
|
|
229
|
+
const existingDoc = existingDocs.get(blockId)
|
|
230
|
+
const doc: Record<string, unknown> = { _id: blockId, _type: blockType }
|
|
231
|
+
|
|
232
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
233
|
+
if (key === "headingLevel") continue
|
|
234
|
+
if (imgFields.has(key) && typeof value === "string" && value) {
|
|
235
|
+
const imageRef = await imageResolver.resolve(value)
|
|
236
|
+
doc[key] = imageRef ?? existingDoc?.[key] ?? undefined
|
|
237
|
+
} else if (refLists?.has(key) && Array.isArray(value)) {
|
|
238
|
+
const itemImageKeys = listImgFields.get(key) ?? new Set<string>()
|
|
239
|
+
const cardRefs = await Promise.all(
|
|
240
|
+
(value as Record<string, unknown>[]).map(async (item, idx) => {
|
|
241
|
+
const cardId = \`card-\${blockId}-\${idx}\`.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 128)
|
|
242
|
+
const fields = await resolveImageFields(item, itemImageKeys, existingDocs.get(cardId), imageResolver)
|
|
243
|
+
tx.createOrReplace({ _id: cardId, _type: "card", ...fields } as { _id: string; _type: string; [key: string]: unknown })
|
|
244
|
+
return { _type: "reference" as const, _ref: cardId, _key: \`card-\${idx}\` }
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
doc[key] = cardRefs
|
|
248
|
+
} else if (listImgFields.has(key) && Array.isArray(value)) {
|
|
249
|
+
const itemImageKeys = listImgFields.get(key)!
|
|
250
|
+
const existingItems = existingDoc?.[key] as Record<string, unknown>[] | undefined
|
|
251
|
+
doc[key] = await Promise.all(
|
|
252
|
+
(value as Record<string, unknown>[]).map(async (item, idx) => ({
|
|
253
|
+
_key: \`item-\${idx}\`,
|
|
254
|
+
...await resolveImageFields(item, itemImageKeys, existingItems?.[idx], imageResolver),
|
|
255
|
+
}))
|
|
256
|
+
)
|
|
257
|
+
} else {
|
|
258
|
+
doc[key] = value
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
tx.createOrReplace(doc as { _id: string; _type: string; [key: string]: unknown })
|
|
262
|
+
return { _type: "reference" as const, _ref: blockId, _key: block.id }
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
const pageId = \`page-\${page.slug}\`.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
266
|
+
tx.createOrReplace({
|
|
267
|
+
_id: pageId, _type: "page",
|
|
268
|
+
slug: { _type: "slug", current: page.slug },
|
|
269
|
+
title: page.title, pageId: page.id, blocks: blockRefs,
|
|
270
|
+
...(page.meta ? { meta: page.meta } : {}),
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (config.name || config.logo || config.navLabels) {
|
|
275
|
+
tx.createOrReplace({
|
|
276
|
+
_id: "siteConfig", _type: "siteConfig",
|
|
277
|
+
name: config.name ?? "", logo: config.logo ?? "", navLabels: config.navLabels ?? {},
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await tx.commit()
|
|
283
|
+
return { ok: true }
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return { ok: false, error: err instanceof Error ? err.message : "Sanity transaction failed" }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DEMO_PAGES_JSON, DEMO_SITE_CONFIG_JSON } from "./demo-content.js";
|
|
2
|
+
/*
|
|
3
|
+
* A scaffolded project opens on the Avocado Hub — nine pages, sixty blocks,
|
|
4
|
+
* every built-in renderer represented.
|
|
5
|
+
*
|
|
6
|
+
* It used to open on one Hero block reading "Welcome to your site", with
|
|
7
|
+
* `ctaHref: "#"` and `imageAlt: "Hero image"`. That is a defensible starter for
|
|
8
|
+
* somebody who already knows what they are building, and it is the wrong first
|
|
9
|
+
* thing to show anybody else: the product's whole claim is that you edit a real
|
|
10
|
+
* site by talking to it, and a single placeholder block gives a new user nothing
|
|
11
|
+
* to talk about. It also shipped two of the defects the demo seed test exists to
|
|
12
|
+
* catch — a link that goes nowhere and alt text that describes nothing.
|
|
13
|
+
*
|
|
14
|
+
* Both files are generated by scripts/sync-demo-seed.mjs from the same source as
|
|
15
|
+
* the editor's demo seed, so the three cannot drift apart.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* One module both the pages and the editor API read through.
|
|
19
|
+
*
|
|
20
|
+
* They used to be separate: the page inlined its own `readFile` and the editor
|
|
21
|
+
* route was scaffolded with `getPages: () => Promise.resolve([])` — a literal
|
|
22
|
+
* empty array. So `GET /api/editor/pages` answered with nothing while the site
|
|
23
|
+
* rendered nine pages, and the editor opened on a site with no content in it
|
|
24
|
+
* while the preview beside it showed the content. Two sources for one file is
|
|
25
|
+
* how that happens; this is the one source.
|
|
26
|
+
*/
|
|
27
|
+
function contentLib() {
|
|
28
|
+
return `import { readFile } from "node:fs/promises"
|
|
29
|
+
import { resolve } from "node:path"
|
|
30
|
+
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared"
|
|
31
|
+
|
|
32
|
+
const PAGES_PATH = resolve(process.cwd(), "content/pages.json")
|
|
33
|
+
const SITE_PATH = resolve(process.cwd(), "content/site.json")
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Read on every call rather than cached at module load. Publishing rewrites
|
|
37
|
+
* content/pages.json in place, and a module-level cache would keep serving the
|
|
38
|
+
* content from before the publish until the dev server happened to restart.
|
|
39
|
+
*/
|
|
40
|
+
export async function getPages(): Promise<PageDoc[]> {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(await readFile(PAGES_PATH, "utf-8")) as PageDoc[]
|
|
43
|
+
} catch {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function getPage(slug: string): Promise<PageDoc | null> {
|
|
49
|
+
const pages = await getPages()
|
|
50
|
+
return pages.find((page) => page.slug === slug) ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getSlugs(): Promise<string[]> {
|
|
54
|
+
return (await getPages()).map((page) => page.slug)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* The site name, logo and navigation — including which pages are grouped under
|
|
59
|
+
* which dropdown. Without it the header falls back to one flat link per page,
|
|
60
|
+
* which on a nine-page site is the wrong shape.
|
|
61
|
+
*/
|
|
62
|
+
export async function getSiteConfig(): Promise<SiteConfig> {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(await readFile(SITE_PATH, "utf-8")) as SiteConfig
|
|
65
|
+
} catch {
|
|
66
|
+
// Every field of SiteConfig is optional, so this is a real config that
|
|
67
|
+
// says nothing — not a null the caller has to handle. Returning
|
|
68
|
+
// \`undefined\` here typechecks under \`next dev\`, which does not run tsc,
|
|
69
|
+
// and fails \`next build\` with a wall of structural-mismatch output.
|
|
70
|
+
return {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
export function staticTemplates(_config) {
|
|
76
|
+
return [
|
|
77
|
+
{ path: "content/pages.json", content: DEMO_PAGES_JSON },
|
|
78
|
+
{ path: "content/site.json", content: DEMO_SITE_CONFIG_JSON },
|
|
79
|
+
{ path: "lib/content.ts", content: contentLib() },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export function strapiTemplates(_config) {
|
|
2
|
+
return [
|
|
3
|
+
{ path: "lib/strapi.client.ts", content: STRAPI_CLIENT },
|
|
4
|
+
{ path: "lib/strapi.fetch.ts", content: STRAPI_FETCH },
|
|
5
|
+
{ path: "lib/publish.ts", content: STRAPI_PUBLISH },
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
const STRAPI_CLIENT = `const STRAPI_URL = process.env.STRAPI_URL?.trim().replace(/\\/+$/, "")
|
|
9
|
+
if (!STRAPI_URL) throw new Error("STRAPI_URL is required")
|
|
10
|
+
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN?.trim()
|
|
11
|
+
|
|
12
|
+
export { STRAPI_URL }
|
|
13
|
+
|
|
14
|
+
export async function strapiFetch<T = unknown>(path: string, options?: RequestInit): Promise<T> {
|
|
15
|
+
const url = \`\${STRAPI_URL}/api\${path}\`
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
...options,
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
...(STRAPI_TOKEN ? { Authorization: \`Bearer \${STRAPI_TOKEN}\` } : {}),
|
|
21
|
+
...options?.headers,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const body = await res.text().catch(() => "")
|
|
26
|
+
throw new Error(\`Strapi \${res.status} \${res.statusText}: \${body.slice(0, 200)}\`)
|
|
27
|
+
}
|
|
28
|
+
const text = await res.text()
|
|
29
|
+
if (!text) return undefined as T
|
|
30
|
+
return JSON.parse(text) as T
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
const STRAPI_FETCH = `import { strapiFetch, STRAPI_URL } from "./strapi.client"
|
|
34
|
+
import { lowerToBlockType } from "@avocadostudio-ai/shared"
|
|
35
|
+
import { imageFields } from "./manifest"
|
|
36
|
+
import type { PageDoc, SiteConfig, BlockInstance } from "@avocadostudio-ai/shared"
|
|
37
|
+
|
|
38
|
+
type StrapiResponse<T> = { data: T; meta?: unknown }
|
|
39
|
+
type StrapiItem = { id: number; documentId: string; [key: string]: unknown }
|
|
40
|
+
|
|
41
|
+
function resolveMediaUrl(field: unknown): string {
|
|
42
|
+
if (!field || typeof field !== "object") return ""
|
|
43
|
+
const media = field as { url?: string; formats?: { large?: { url?: string } } }
|
|
44
|
+
const url = media.formats?.large?.url ?? media.url
|
|
45
|
+
if (!url) return ""
|
|
46
|
+
return url.startsWith("http") ? url : \`\${STRAPI_URL}\${url}\`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dzComponentToBlock(entry: Record<string, unknown>, index: number): BlockInstance | null {
|
|
50
|
+
const component = entry.__component as string | undefined
|
|
51
|
+
if (!component?.startsWith("blocks.")) return null
|
|
52
|
+
const rawName = component.replace("blocks.", "")
|
|
53
|
+
const blockType = lowerToBlockType(rawName)
|
|
54
|
+
const imgFields = imageFields.get(blockType) ?? new Set<string>()
|
|
55
|
+
const props: Record<string, unknown> = {}
|
|
56
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
57
|
+
if (key === "id" || key === "__component") continue
|
|
58
|
+
if (value === null || value === undefined) continue
|
|
59
|
+
props[key] = imgFields.has(key) ? resolveMediaUrl(value) : value
|
|
60
|
+
}
|
|
61
|
+
return { id: entry.id ? \`\${entry.id}-\${index}\` : String(index), type: blockType, props }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function strapiEntryToPageDoc(entry: StrapiItem): PageDoc | null {
|
|
65
|
+
const slug = entry.slug as string | undefined
|
|
66
|
+
if (!slug) return null
|
|
67
|
+
const rawBlocks = entry.blocks as Array<Record<string, unknown>> | undefined
|
|
68
|
+
const blocks: BlockInstance[] = []
|
|
69
|
+
if (Array.isArray(rawBlocks)) {
|
|
70
|
+
for (let i = 0; i < rawBlocks.length; i++) {
|
|
71
|
+
const block = dzComponentToBlock(rawBlocks[i], i)
|
|
72
|
+
if (block) blocks.push(block)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
id: (entry.pageId as string) ?? entry.documentId ?? String(entry.id),
|
|
77
|
+
slug, title: (entry.title as string) ?? "", blocks,
|
|
78
|
+
meta: (entry.pageMeta as PageDoc["meta"]) ?? undefined,
|
|
79
|
+
updatedAt: (entry.updatedAt as string) ?? new Date().toISOString(),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getStrapiPage(slug: string): Promise<PageDoc | null> {
|
|
84
|
+
try {
|
|
85
|
+
const res = await strapiFetch<StrapiResponse<StrapiItem[]>>(
|
|
86
|
+
\`/pages?filters[slug][$eq]=\${encodeURIComponent(slug)}&populate[blocks][populate]=*\`
|
|
87
|
+
)
|
|
88
|
+
if (!res.data || res.data.length === 0) return null
|
|
89
|
+
return strapiEntryToPageDoc(res.data[0])
|
|
90
|
+
} catch { return null }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function getStrapiSlugs(): Promise<string[]> {
|
|
94
|
+
try {
|
|
95
|
+
const res = await strapiFetch<StrapiResponse<StrapiItem[]>>("/pages?fields[0]=slug&pagination[pageSize]=100")
|
|
96
|
+
return res.data.map((item) => item.slug as string).filter(Boolean)
|
|
97
|
+
} catch { return [] }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function getStrapiPages(): Promise<PageDoc[]> {
|
|
101
|
+
try {
|
|
102
|
+
const res = await strapiFetch<StrapiResponse<StrapiItem[]>>(
|
|
103
|
+
"/pages?populate[blocks][populate]=*&pagination[pageSize]=100"
|
|
104
|
+
)
|
|
105
|
+
return res.data.map(strapiEntryToPageDoc).filter((p): p is PageDoc => p !== null)
|
|
106
|
+
} catch { return [] }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function getStrapiSiteConfig(): Promise<SiteConfig> {
|
|
110
|
+
try {
|
|
111
|
+
const res = await strapiFetch<StrapiResponse<StrapiItem>>("/site-config?populate=*")
|
|
112
|
+
if (!res.data) return {}
|
|
113
|
+
return {
|
|
114
|
+
name: (res.data.name as string) || undefined,
|
|
115
|
+
logo: (res.data.logo as string) || undefined,
|
|
116
|
+
navLabels: (res.data.navLabels as Record<string, string>) || undefined,
|
|
117
|
+
}
|
|
118
|
+
} catch { return {} }
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
const STRAPI_PUBLISH = `import type { OnPublishFn } from "@avocadostudio-ai/site-sdk/routes"
|
|
122
|
+
import { createImageResolver } from "@avocadostudio-ai/site-sdk/routes"
|
|
123
|
+
import { strapiFetch, STRAPI_URL } from "./strapi.client"
|
|
124
|
+
import { imageFields } from "./manifest"
|
|
125
|
+
|
|
126
|
+
function isStrapiUrl(url: string): boolean {
|
|
127
|
+
try { return new URL(url).origin === new URL(STRAPI_URL).origin } catch { return false }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function findStrapiMediaByUrl(imageUrl: string): Promise<number | null> {
|
|
131
|
+
try {
|
|
132
|
+
const urlPath = new URL(imageUrl).pathname
|
|
133
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
134
|
+
if (process.env.STRAPI_API_TOKEN) headers.Authorization = \`Bearer \${process.env.STRAPI_API_TOKEN}\`
|
|
135
|
+
const res = await fetch(
|
|
136
|
+
\`\${STRAPI_URL}/api/upload/files?filters[url][$eq]=\${encodeURIComponent(urlPath)}\`,
|
|
137
|
+
{ headers }
|
|
138
|
+
)
|
|
139
|
+
if (!res.ok) return null
|
|
140
|
+
const data = (await res.json()) as Array<{ id: number }>
|
|
141
|
+
return data[0]?.id ?? null
|
|
142
|
+
} catch { return null }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function uploadToStrapiMedia(blob: Blob, fileName: string): Promise<number | null> {
|
|
146
|
+
const form = new FormData()
|
|
147
|
+
form.append("files", blob, fileName)
|
|
148
|
+
const res = await fetch(\`\${STRAPI_URL}/api/upload\`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { ...(process.env.STRAPI_API_TOKEN ? { Authorization: \`Bearer \${process.env.STRAPI_API_TOKEN}\` } : {}) },
|
|
151
|
+
body: form,
|
|
152
|
+
})
|
|
153
|
+
if (!res.ok) return null
|
|
154
|
+
const uploaded = (await res.json()) as Array<{ id: number }>
|
|
155
|
+
return uploaded[0]?.id ?? null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createStrapiPublishHandler(): OnPublishFn {
|
|
159
|
+
return async (pages, config, context) => {
|
|
160
|
+
const imageResolver = createImageResolver<number>(
|
|
161
|
+
async (bytes, fileName) => uploadToStrapiMedia(new Blob([bytes]), fileName),
|
|
162
|
+
context?.assets,
|
|
163
|
+
)
|
|
164
|
+
const errors: string[] = []
|
|
165
|
+
|
|
166
|
+
async function resolveMediaId(imageUrl: string): Promise<number | null> {
|
|
167
|
+
if (!imageUrl.startsWith("http") && imageUrl.startsWith("/")) {
|
|
168
|
+
return findStrapiMediaByUrl(\`\${STRAPI_URL}\${imageUrl}\`)
|
|
169
|
+
}
|
|
170
|
+
if (isStrapiUrl(imageUrl)) return findStrapiMediaByUrl(imageUrl)
|
|
171
|
+
return imageResolver.resolve(imageUrl)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const page of pages) {
|
|
175
|
+
try {
|
|
176
|
+
const existing = await strapiFetch<{ data: Array<{ documentId: string; blocks?: Array<Record<string, unknown>> }> }>(
|
|
177
|
+
\`/pages?filters[slug][$eq]=\${encodeURIComponent(page.slug)}&populate[blocks][populate]=*\`
|
|
178
|
+
)
|
|
179
|
+
const existingBlocks = existing.data[0]?.blocks as Array<Record<string, unknown>> | undefined
|
|
180
|
+
|
|
181
|
+
const dzBlocks = await Promise.all(page.blocks.map(async (block, blockIndex) => {
|
|
182
|
+
const imgFields = imageFields.get(block.type) ?? new Set<string>()
|
|
183
|
+
const data: Record<string, unknown> = { __component: \`blocks.\${block.type.toLowerCase()}\` }
|
|
184
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
185
|
+
if (key === "headingLevel") continue
|
|
186
|
+
if (imgFields.has(key) && typeof value === "string" && value) {
|
|
187
|
+
const mediaId = await resolveMediaId(value)
|
|
188
|
+
if (mediaId) { data[key] = mediaId }
|
|
189
|
+
else {
|
|
190
|
+
const em = existingBlocks?.[blockIndex]?.[key]
|
|
191
|
+
const emId = em && typeof em === "object" && !Array.isArray(em)
|
|
192
|
+
? (em as Record<string, unknown>).id as number | undefined : undefined
|
|
193
|
+
if (emId) data[key] = emId
|
|
194
|
+
}
|
|
195
|
+
} else { data[key] = value }
|
|
196
|
+
}
|
|
197
|
+
return data
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
const pageData = {
|
|
201
|
+
slug: page.slug, title: page.title, pageId: page.id, blocks: dzBlocks,
|
|
202
|
+
...(page.meta ? { pageMeta: page.meta } : {}),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (existing.data.length > 0) {
|
|
206
|
+
await strapiFetch(\`/pages/\${existing.data[0].documentId}\`, { method: "PUT", body: JSON.stringify({ data: pageData }) })
|
|
207
|
+
} else {
|
|
208
|
+
await strapiFetch("/pages", { method: "POST", body: JSON.stringify({ data: pageData }) })
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
errors.push(\`\${page.slug}: \${err instanceof Error ? err.message : String(err)}\`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (config.name || config.logo || config.navLabels) {
|
|
216
|
+
try {
|
|
217
|
+
await strapiFetch("/site-config", {
|
|
218
|
+
method: "PUT",
|
|
219
|
+
body: JSON.stringify({ data: { name: config.name ?? "", logo: config.logo ?? "", navLabels: config.navLabels ?? {} } }),
|
|
220
|
+
})
|
|
221
|
+
} catch {
|
|
222
|
+
try {
|
|
223
|
+
await strapiFetch("/site-config", {
|
|
224
|
+
method: "POST",
|
|
225
|
+
body: JSON.stringify({ data: { name: config.name ?? "", logo: config.logo ?? "", navLabels: config.navLabels ?? {} } }),
|
|
226
|
+
})
|
|
227
|
+
} catch { /* optional */ }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (errors.length > 0) return { ok: false, error: \`Failed: \${errors.join("; ")}\` }
|
|
232
|
+
return { ok: true }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type CmsChoice = "sanity" | "contentful" | "strapi" | "none";
|
|
2
|
+
export type BlocksMode = "default" | "custom";
|
|
3
|
+
/**
|
|
4
|
+
* What the CLI was asked to do.
|
|
5
|
+
*
|
|
6
|
+
* `demo` creates a whole runnable project — package.json, Next config, layout,
|
|
7
|
+
* an orchestrator mounted in-process — and installs it. `integrate` writes
|
|
8
|
+
* wiring into a Next.js app that already exists, which is what this scaffolder
|
|
9
|
+
* did exclusively before, and leaves that app's own package.json, config and
|
|
10
|
+
* layout alone.
|
|
11
|
+
*/
|
|
12
|
+
export type ScaffoldMode = "demo" | "integrate";
|
|
13
|
+
export type ScaffoldConfig = {
|
|
14
|
+
mode: ScaffoldMode;
|
|
15
|
+
cms: CmsChoice;
|
|
16
|
+
siteId: string;
|
|
17
|
+
blocksMode: BlocksMode;
|
|
18
|
+
/**
|
|
19
|
+
* Next.js major version of the target project. Decides whether the
|
|
20
|
+
* request rewrite is scaffolded as `proxy.ts` (16+) or `middleware.ts`.
|
|
21
|
+
* `undefined` when detection failed — treated as pre-16.
|
|
22
|
+
*
|
|
23
|
+
* Unused in `demo` mode, which pins its own Next and needs neither file:
|
|
24
|
+
* the orchestrator is mounted in the app, so there is nothing to rewrite to.
|
|
25
|
+
*/
|
|
26
|
+
nextMajor?: number;
|
|
27
|
+
/** Port the Next app listens on. Demo mode only. */
|
|
28
|
+
sitePort: number;
|
|
29
|
+
/** Port the editor UI is served on. Demo mode only. */
|
|
30
|
+
editorPort: number;
|
|
31
|
+
/**
|
|
32
|
+
* Value written as `DRAFT_MODE_SECRET`. Generated per scaffold rather than
|
|
33
|
+
* fixed, so two demos on one machine do not share a draft cookie secret.
|
|
34
|
+
*/
|
|
35
|
+
draftSecret: string;
|
|
36
|
+
};
|
|
37
|
+
export type GeneratedFile = {
|
|
38
|
+
path: string;
|
|
39
|
+
/** Text to write. Omit when `source` is set. */
|
|
40
|
+
content?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Absolute path to copy bytes from, for the files that are not text: the
|
|
43
|
+
* demo's logo and its eleven images. They travel with the package rather
|
|
44
|
+
* than being generated, and a scaffold without them renders a broken logo on
|
|
45
|
+
* every page.
|
|
46
|
+
*/
|
|
47
|
+
source?: string;
|
|
48
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|