@see-ms/converter 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,2330 @@
1
+ // src/converter.ts
2
+ import pc3 from "picocolors";
3
+ import path12 from "path";
4
+ import fs10 from "fs-extra";
5
+
6
+ // src/filesystem.ts
7
+ import fs from "fs-extra";
8
+ import path from "path";
9
+ import { glob } from "glob";
10
+ import { execSync } from "child_process";
11
+ import pc from "picocolors";
12
+ async function scanAssets(webflowDir) {
13
+ const assets = {
14
+ css: [],
15
+ images: [],
16
+ fonts: [],
17
+ js: []
18
+ };
19
+ const cssFiles = await glob("css/**/*.css", { cwd: webflowDir });
20
+ assets.css = cssFiles;
21
+ const imageFiles = await glob("images/**/*", { cwd: webflowDir });
22
+ assets.images = imageFiles;
23
+ const fontFiles = await glob("fonts/**/*", { cwd: webflowDir });
24
+ assets.fonts = fontFiles;
25
+ const jsFiles = await glob("js/**/*.js", { cwd: webflowDir });
26
+ assets.js = jsFiles;
27
+ return assets;
28
+ }
29
+ async function copyCSSFiles(webflowDir, outputDir, cssFiles) {
30
+ const targetDir = path.join(outputDir, "assets", "css");
31
+ await fs.ensureDir(targetDir);
32
+ for (const file of cssFiles) {
33
+ const source = path.join(webflowDir, file);
34
+ const target = path.join(targetDir, path.basename(file));
35
+ await fs.copy(source, target);
36
+ }
37
+ }
38
+ async function copyImages(webflowDir, outputDir, imageFiles) {
39
+ const targetDir = path.join(outputDir, "public", "assets", "images");
40
+ await fs.ensureDir(targetDir);
41
+ for (const file of imageFiles) {
42
+ const source = path.join(webflowDir, file);
43
+ const target = path.join(targetDir, path.basename(file));
44
+ await fs.copy(source, target);
45
+ }
46
+ }
47
+ async function copyFonts(webflowDir, outputDir, fontFiles) {
48
+ const targetDir = path.join(outputDir, "public", "assets", "fonts");
49
+ await fs.ensureDir(targetDir);
50
+ for (const file of fontFiles) {
51
+ const source = path.join(webflowDir, file);
52
+ const target = path.join(targetDir, path.basename(file));
53
+ await fs.copy(source, target);
54
+ }
55
+ }
56
+ async function copyJSFiles(webflowDir, outputDir, jsFiles) {
57
+ const targetDir = path.join(outputDir, "public", "assets", "js");
58
+ await fs.ensureDir(targetDir);
59
+ for (const file of jsFiles) {
60
+ const source = path.join(webflowDir, file);
61
+ const target = path.join(targetDir, path.basename(file));
62
+ await fs.copy(source, target);
63
+ }
64
+ }
65
+ async function copyAllAssets(webflowDir, outputDir, assets) {
66
+ await copyCSSFiles(webflowDir, outputDir, assets.css);
67
+ await copyImages(webflowDir, outputDir, assets.images);
68
+ await copyFonts(webflowDir, outputDir, assets.fonts);
69
+ await copyJSFiles(webflowDir, outputDir, assets.js);
70
+ }
71
+ async function findHTMLFiles(webflowDir) {
72
+ const htmlFiles = await glob("**/*.html", { cwd: webflowDir });
73
+ return htmlFiles;
74
+ }
75
+ async function readHTMLFile(webflowDir, fileName) {
76
+ const filePath = path.join(webflowDir, fileName);
77
+ return await fs.readFile(filePath, "utf-8");
78
+ }
79
+ async function writeVueComponent(outputDir, fileName, content) {
80
+ const pagesDir = path.join(outputDir, "pages");
81
+ const vueName = fileName.replace(".html", ".vue");
82
+ const targetPath = path.join(pagesDir, vueName);
83
+ await fs.ensureDir(path.dirname(targetPath));
84
+ await fs.writeFile(targetPath, content, "utf-8");
85
+ }
86
+ async function formatVueFiles(outputDir) {
87
+ const pagesDir = path.join(outputDir, "pages");
88
+ try {
89
+ console.log(pc.blue("\n\u2728 Formatting Vue files with Prettier..."));
90
+ execSync("npx prettier --version", { stdio: "ignore" });
91
+ execSync(`npx prettier --write "${pagesDir}/**/*.vue"`, {
92
+ cwd: outputDir,
93
+ stdio: "inherit"
94
+ });
95
+ console.log(pc.green(" \u2713 Vue files formatted"));
96
+ } catch (error) {
97
+ console.log(pc.yellow(" \u26A0 Prettier not available, skipping formatting"));
98
+ }
99
+ }
100
+
101
+ // src/parser.ts
102
+ import * as cheerio from "cheerio";
103
+ import path2 from "path";
104
+ function normalizeRoute(href) {
105
+ let route = href.replace(".html", "");
106
+ if (route === "index" || route === "/index" || route.endsWith("/index")) {
107
+ return "/";
108
+ }
109
+ if (route === ".." || route === "../" || route === "/.." || route === "../index") {
110
+ return "/";
111
+ }
112
+ route = route.replace(/\.\.\//g, "").replace(/\.\//g, "");
113
+ const normalized = path2.posix.normalize(route);
114
+ if (!normalized.startsWith("/")) {
115
+ return "/" + normalized;
116
+ }
117
+ if (normalized === "." || normalized === "") {
118
+ return "/";
119
+ }
120
+ return normalized;
121
+ }
122
+ function normalizeAssetPath(src) {
123
+ if (!src || src.startsWith("http") || src.startsWith("https")) {
124
+ return src;
125
+ }
126
+ let normalized = src.replace(/^(\.\.\/)+/, "").replace(/^\.\//, "");
127
+ if (normalized.startsWith("/assets/")) {
128
+ normalized = normalized.replace(/\/\.\.\//g, "/");
129
+ return normalized;
130
+ }
131
+ return `/assets/${normalized}`;
132
+ }
133
+ function parseHTML(html, fileName) {
134
+ const $ = cheerio.load(html);
135
+ const title = $("title").text() || fileName.replace(".html", "");
136
+ const cssFiles = [];
137
+ $('link[rel="stylesheet"]').each((_, el) => {
138
+ const href = $(el).attr("href");
139
+ if (href) {
140
+ cssFiles.push(href);
141
+ }
142
+ });
143
+ let embeddedStyles = "";
144
+ $(".global-embed style").each((_, el) => {
145
+ embeddedStyles += $(el).html() + "\n";
146
+ });
147
+ $("body > style").each((_, el) => {
148
+ embeddedStyles += $(el).html() + "\n";
149
+ });
150
+ $(".global-embed").remove();
151
+ $("body > style").remove();
152
+ $("body script").remove();
153
+ const images = [];
154
+ $("img").each((_, el) => {
155
+ const src = $(el).attr("src");
156
+ if (src) {
157
+ images.push(src);
158
+ }
159
+ });
160
+ const links = [];
161
+ $("a").each((_, el) => {
162
+ const href = $(el).attr("href");
163
+ if (href) {
164
+ links.push(href);
165
+ }
166
+ });
167
+ const htmlContent = $("body").html() || "";
168
+ return {
169
+ fileName,
170
+ title,
171
+ htmlContent,
172
+ cssFiles,
173
+ embeddedStyles,
174
+ images,
175
+ links
176
+ };
177
+ }
178
+ function transformForNuxt(html) {
179
+ const $ = cheerio.load(html);
180
+ $("html, head, body").each((_, el) => {
181
+ const $el = $(el);
182
+ $el.replaceWith($el.html() || "");
183
+ });
184
+ $("script").remove();
185
+ $("a").each((_, el) => {
186
+ const $el = $(el);
187
+ const href = $el.attr("href");
188
+ if (!href) return;
189
+ const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("#");
190
+ if (!isExternal) {
191
+ const route = normalizeRoute(href);
192
+ $el.attr("to", route);
193
+ $el.removeAttr("href");
194
+ const content = $el.html();
195
+ const classes = $el.attr("class") || "";
196
+ $el.replaceWith(`<nuxt-link to="${route}" class="${classes}">${content}</nuxt-link>`);
197
+ }
198
+ });
199
+ $("img").each((_, el) => {
200
+ const $el = $(el);
201
+ const src = $el.attr("src");
202
+ if (src) {
203
+ const normalizedSrc = normalizeAssetPath(src);
204
+ $el.attr("src", normalizedSrc);
205
+ }
206
+ $el.removeAttr("srcset");
207
+ $el.removeAttr("sizes");
208
+ });
209
+ return $.html();
210
+ }
211
+ function htmlToVueComponent(html, pageName) {
212
+ return `
213
+ <script setup lang="ts">
214
+ // Page: ${pageName}
215
+ </script>
216
+
217
+ <template>
218
+ <div>
219
+ ${html}
220
+ </div>
221
+ </template>
222
+ `;
223
+ }
224
+ function deduplicateStyles(styles) {
225
+ if (!styles.trim()) return "";
226
+ const sections = styles.split(/\/\* From .+ \*\//);
227
+ const uniqueStyles = /* @__PURE__ */ new Set();
228
+ for (const section of sections) {
229
+ const trimmed = section.trim();
230
+ if (trimmed) {
231
+ uniqueStyles.add(trimmed);
232
+ }
233
+ }
234
+ return Array.from(uniqueStyles).join("\n\n");
235
+ }
236
+
237
+ // src/config-updater.ts
238
+ import fs2 from "fs-extra";
239
+ import path3 from "path";
240
+ function generateWebflowAssetPlugin(cssFiles) {
241
+ const webflowFiles = cssFiles.map((file) => `/assets/css/${path3.basename(file)}`);
242
+ return `import type { Plugin } from 'vite'
243
+
244
+ const webflowFiles = [${webflowFiles.map((f) => `'${f}'`).join(", ")}]
245
+ const replacements = [
246
+ ['../images/', '/assets/images/'],
247
+ ['../fonts/', '/assets/fonts/']
248
+ ]
249
+
250
+ const webflowURLReset = (): Plugin => ({
251
+ name: 'webflowURLReset',
252
+ config: () => ({
253
+ build: {
254
+ rollupOptions: {
255
+ external: [/\\.\\.\\/fonts\\//, /\\.\\.\\/images\\//]
256
+ }
257
+ }
258
+ }),
259
+ transform: (code, id) => {
260
+ if (webflowFiles.some((path) => id.includes(path))) {
261
+ replacements.forEach(([search, replace]) => {
262
+ code = code.replaceAll(search, replace)
263
+ })
264
+ }
265
+
266
+ return { code, id, map: null }
267
+ }
268
+ })
269
+
270
+ export default webflowURLReset
271
+ `;
272
+ }
273
+ async function writeWebflowAssetPlugin(outputDir, cssFiles) {
274
+ const utilsDir = path3.join(outputDir, "utils");
275
+ await fs2.ensureDir(utilsDir);
276
+ const content = generateWebflowAssetPlugin(cssFiles);
277
+ const targetPath = path3.join(utilsDir, "webflow-assets.ts");
278
+ await fs2.writeFile(targetPath, content, "utf-8");
279
+ }
280
+ async function updateNuxtConfig(outputDir, cssFiles) {
281
+ const configPath = path3.join(outputDir, "nuxt.config.ts");
282
+ const configExists = await fs2.pathExists(configPath);
283
+ if (!configExists) {
284
+ throw new Error("nuxt.config.ts not found in output directory");
285
+ }
286
+ let config = await fs2.readFile(configPath, "utf-8");
287
+ const cssEntries = cssFiles.map((file) => ` '~/assets/css/${path3.basename(file)}'`);
288
+ if (config.includes("css:")) {
289
+ config = config.replace(
290
+ /css:\s*\[/,
291
+ `css: [
292
+ ${cssEntries.join(",\n")},`
293
+ );
294
+ } else {
295
+ config = config.replace(
296
+ /export default defineNuxtConfig\(\{/,
297
+ `export default defineNuxtConfig({
298
+ css: [
299
+ ${cssEntries.join(",\n")}
300
+ ],`
301
+ );
302
+ }
303
+ await fs2.writeFile(configPath, config, "utf-8");
304
+ }
305
+ async function writeEmbeddedStyles(outputDir, styles) {
306
+ if (!styles.trim()) return;
307
+ const cssDir = path3.join(outputDir, "assets", "css");
308
+ await fs2.ensureDir(cssDir);
309
+ const mainCssPath = path3.join(cssDir, "main.css");
310
+ const exists = await fs2.pathExists(mainCssPath);
311
+ if (exists) {
312
+ const existing = await fs2.readFile(mainCssPath, "utf-8");
313
+ await fs2.writeFile(mainCssPath, `${existing}
314
+
315
+ /* Webflow Embedded Styles */
316
+ ${styles}`, "utf-8");
317
+ } else {
318
+ await fs2.writeFile(mainCssPath, `/* Webflow Embedded Styles */
319
+ ${styles}`, "utf-8");
320
+ }
321
+ }
322
+ async function addStrapiUrlToConfig(outputDir, strapiUrl = "http://localhost:1337") {
323
+ const configPath = path3.join(outputDir, "nuxt.config.ts");
324
+ const configExists = await fs2.pathExists(configPath);
325
+ if (!configExists) {
326
+ throw new Error("nuxt.config.ts not found in output directory");
327
+ }
328
+ let config = await fs2.readFile(configPath, "utf-8");
329
+ if (config.includes("runtimeConfig:")) {
330
+ if (config.includes("public:")) {
331
+ config = config.replace(
332
+ /public:\s*\{/,
333
+ `public: {
334
+ strapiUrl: process.env.STRAPI_URL || '${strapiUrl}',`
335
+ );
336
+ } else {
337
+ config = config.replace(
338
+ /runtimeConfig:\s*\{/,
339
+ `runtimeConfig: {
340
+ public: {
341
+ strapiUrl: process.env.STRAPI_URL || '${strapiUrl}'
342
+ },`
343
+ );
344
+ }
345
+ } else {
346
+ config = config.replace(
347
+ /export default defineNuxtConfig\(\{/,
348
+ `export default defineNuxtConfig({
349
+ runtimeConfig: {
350
+ public: {
351
+ strapiUrl: process.env.STRAPI_URL || '${strapiUrl}'
352
+ }
353
+ },`
354
+ );
355
+ }
356
+ await fs2.writeFile(configPath, config, "utf-8");
357
+ }
358
+
359
+ // src/editor-integration.ts
360
+ import fs3 from "fs-extra";
361
+ import path4 from "path";
362
+ async function createEditorContentComposable(outputDir) {
363
+ const composablesDir = path4.join(outputDir, "composables");
364
+ await fs3.ensureDir(composablesDir);
365
+ const composableContent = `/**
366
+ * Global state for editor content in preview mode
367
+ * This allows the editor overlay to update content reactively
368
+ */
369
+
370
+ // Global reactive state
371
+ const editorState = reactive<{
372
+ isPreviewMode: boolean;
373
+ currentPage: string | null;
374
+ content: Record<string, Record<string, any>>; // page -> field -> value
375
+ hasChanges: Record<string, boolean>; // page -> hasChanges
376
+ }>({
377
+ isPreviewMode: false,
378
+ currentPage: null,
379
+ content: {},
380
+ hasChanges: {},
381
+ });
382
+
383
+ export function useEditorContent(pageName?: string) {
384
+ const route = useRoute();
385
+
386
+ // Check if we're in preview mode
387
+ const isPreviewMode = computed(() => route.query.preview === 'true');
388
+
389
+ // Update global state
390
+ if (import.meta.client) {
391
+ editorState.isPreviewMode = isPreviewMode.value;
392
+ if (pageName) {
393
+ editorState.currentPage = pageName;
394
+ }
395
+ }
396
+
397
+ // Get content for specific page
398
+ const getPageContent = (page: string) => {
399
+ return editorState.content[page] || {};
400
+ };
401
+
402
+ // Update a field's value
403
+ const updateField = (page: string, fieldName: string, value: any) => {
404
+ if (!editorState.content[page]) {
405
+ editorState.content[page] = {};
406
+ }
407
+ editorState.content[page][fieldName] = value;
408
+ editorState.hasChanges[page] = true;
409
+ };
410
+
411
+ // Clear all changes for a page
412
+ const clearPageChanges = (page: string) => {
413
+ delete editorState.content[page];
414
+ editorState.hasChanges[page] = false;
415
+ };
416
+
417
+ // Initialize page content from Strapi data
418
+ const initializePageContent = (page: string, content: Record<string, any>) => {
419
+ if (!editorState.content[page]) {
420
+ editorState.content[page] = { ...content };
421
+ }
422
+ };
423
+
424
+ // Get content for current page (reactive)
425
+ const content = computed(() => {
426
+ const page = pageName || editorState.currentPage;
427
+ if (!page) return {};
428
+ return editorState.content[page] || {};
429
+ });
430
+
431
+ // Check if page has unsaved changes
432
+ const hasChanges = computed(() => {
433
+ const page = pageName || editorState.currentPage;
434
+ if (!page) return false;
435
+ return editorState.hasChanges[page] || false;
436
+ });
437
+
438
+ // Get all pages with changes
439
+ const pagesWithChanges = computed(() => {
440
+ return Object.keys(editorState.hasChanges).filter(
441
+ (page) => editorState.hasChanges[page]
442
+ );
443
+ });
444
+
445
+ // Expose state for window object (for editor overlay to access)
446
+ if (import.meta.client) {
447
+ (window as any).__editorState = editorState;
448
+ }
449
+
450
+ return {
451
+ isPreviewMode,
452
+ content,
453
+ hasChanges,
454
+ pagesWithChanges,
455
+ getPageContent,
456
+ updateField,
457
+ clearPageChanges,
458
+ initializePageContent,
459
+ };
460
+ }
461
+ `;
462
+ const composablePath = path4.join(composablesDir, "useEditorContent.ts");
463
+ await fs3.writeFile(composablePath, composableContent, "utf-8");
464
+ }
465
+ async function createStrapiContentComposable(outputDir) {
466
+ const composablesDir = path4.join(outputDir, "composables");
467
+ await fs3.ensureDir(composablesDir);
468
+ const composableContent = `/**
469
+ * Composable to fetch content from Strapi based on CMS manifest
470
+ * Integrates with editor state for preview mode
471
+ */
472
+
473
+ export function useStrapiContent(pageName: string) {
474
+ const config = useRuntimeConfig();
475
+ const strapiUrl = config.public.strapiUrl || 'http://localhost:1337';
476
+ const editorContent = useEditorContent(pageName);
477
+
478
+ // Helper to transform Strapi image objects to URL strings
479
+ const transformStrapiImages = (data: any, baseUrl: string): any => {
480
+ if (!data || typeof data !== 'object') return data;
481
+
482
+ const transformed: any = {};
483
+
484
+ for (const [key, value] of Object.entries(data)) {
485
+ if (value && typeof value === 'object') {
486
+ // Check if it's a Strapi media object
487
+ if ('url' in value && ('mime' in value || 'formats' in value)) {
488
+ // It's an image - extract the URL
489
+ transformed[key] = value.url.startsWith('http')
490
+ ? value.url
491
+ : \`\${baseUrl}\${value.url}\`;
492
+ } else if (Array.isArray(value)) {
493
+ // Handle arrays (collections of images)
494
+ transformed[key] = value.map((item) =>
495
+ item && typeof item === 'object' && 'url' in item
496
+ ? item.url.startsWith('http')
497
+ ? item.url
498
+ : \`\${baseUrl}\${item.url}\`
499
+ : item
500
+ );
501
+ } else {
502
+ // Recursively transform nested objects
503
+ transformed[key] = transformStrapiImages(value, baseUrl);
504
+ }
505
+ } else {
506
+ transformed[key] = value;
507
+ }
508
+ }
509
+
510
+ return transformed;
511
+ };
512
+
513
+ // Fetch content from Strapi with populated media fields
514
+ const { data: strapiData } = useFetch<any>(
515
+ \`\${strapiUrl}/api/\${pageName}\`,
516
+ {
517
+ key: \`strapi-\${pageName}\`,
518
+ query: {
519
+ populate: '*', // Strapi v5: Populate all fields including images
520
+ },
521
+ transform: (response) => {
522
+ // Strapi v5 returns data in response.data
523
+ const data = response?.data || response;
524
+
525
+ // Transform image fields from Strapi objects to URL strings
526
+ if (data && typeof data === 'object') {
527
+ return transformStrapiImages(data, strapiUrl);
528
+ }
529
+
530
+ return data;
531
+ },
532
+ }
533
+ );
534
+
535
+ // Initialize editor state with Strapi data when fetched
536
+ // This runs in both normal AND preview mode to ensure initial content is available
537
+ watch(
538
+ strapiData,
539
+ (newData) => {
540
+ if (newData) {
541
+ // Always initialize from Strapi on first load
542
+ // Drafts will override this when they load in the editor
543
+ editorContent.initializePageContent(pageName, newData);
544
+ }
545
+ },
546
+ { immediate: true }
547
+ );
548
+
549
+ // In preview mode: use editor state
550
+ // In normal mode: use Strapi data (and sync to editor state)
551
+ const content = computed(() => {
552
+ if (editorContent.isPreviewMode.value) {
553
+ // Use editor state in preview mode
554
+ return editorContent.getPageContent(pageName);
555
+ } else {
556
+ // Use Strapi data in normal mode
557
+ return strapiData.value || editorContent.getPageContent(pageName);
558
+ }
559
+ });
560
+
561
+ return {
562
+ content,
563
+ };
564
+ }
565
+ `;
566
+ const composablePath = path4.join(composablesDir, "useStrapiContent.ts");
567
+ await fs3.writeFile(composablePath, composableContent, "utf-8");
568
+ }
569
+ async function createEditorPlugin(outputDir) {
570
+ const pluginsDir = path4.join(outputDir, "plugins");
571
+ await fs3.ensureDir(pluginsDir);
572
+ const pluginContent = `/**
573
+ * CMS Editor Overlay Plugin
574
+ * Loads the inline editor when ?preview=true with full state management
575
+ */
576
+
577
+ /**
578
+ * Disable Lenis smooth scroll to allow native scrolling in edit mode
579
+ */
580
+ function disableLenisInEditMode() {
581
+ try {
582
+ // Check for Lenis in common locations
583
+ const lenisInstances = [
584
+ (window as any).lenis,
585
+ (window as any).__lenis,
586
+ document.querySelector('.lenis'),
587
+ ];
588
+
589
+ for (const lenis of lenisInstances) {
590
+ if (lenis && typeof lenis.destroy === 'function') {
591
+ lenis.destroy();
592
+ return;
593
+ }
594
+ }
595
+
596
+ // Check for Vue Lenis component instances
597
+ const lenisElements = document.querySelectorAll('[data-lenis], .lenis');
598
+ if (lenisElements.length > 0) {
599
+ // Try to find and destroy via data attributes or component instances
600
+ lenisElements.forEach((el: any) => {
601
+ if (el.__lenis && typeof el.__lenis.destroy === 'function') {
602
+ el.__lenis.destroy();
603
+ }
604
+ });
605
+ }
606
+ } catch (error) {
607
+ // Silently fail - Lenis may not be present
608
+ }
609
+ }
610
+
611
+ export default defineNuxtPlugin(async (nuxtApp) => {
612
+ // Only run on client side
613
+ if (process.server) return;
614
+
615
+ // Import editor overlay modules
616
+ const {
617
+ initEditor,
618
+ createAuthManager,
619
+ showLoginModal,
620
+ createDraftStorage,
621
+ createURLStateManager,
622
+ createManifestLoader,
623
+ createNavigationGuard,
624
+ getCurrentPageFromRoute,
625
+ } = await import('@see-ms/editor-overlay');
626
+
627
+ // Initialize URL state manager
628
+ const urlState = createURLStateManager();
629
+ const state = urlState.getState();
630
+
631
+ // Only proceed if in preview mode
632
+ if (!state.preview) return;
633
+
634
+ // Get Strapi URL from runtime config
635
+ const config = useRuntimeConfig();
636
+ const strapiUrl = config.public.strapiUrl || 'http://localhost:1337';
637
+
638
+ // Initialize components
639
+ const authManager = createAuthManager({
640
+ strapiUrl,
641
+ storageKey: 'cms_editor_token',
642
+ });
643
+
644
+ const draftStorage = createDraftStorage();
645
+ const manifestLoader = createManifestLoader();
646
+
647
+ // Load manifest
648
+ try {
649
+ await manifestLoader.load();
650
+ } catch (error) {
651
+ console.error('[CMS Editor] Failed to load manifest:', error);
652
+ return;
653
+ }
654
+
655
+ // Get current page from route
656
+ let currentPage = getCurrentPageFromRoute();
657
+ if (!currentPage) {
658
+ currentPage = manifestLoader.getPageFromRoute(window.location.pathname);
659
+ }
660
+
661
+ if (!currentPage) {
662
+ console.error('[CMS Editor] Could not determine current page');
663
+ return;
664
+ }
665
+
666
+ // URL state only manages preview mode (page is derived from route)
667
+ urlState.setState({ preview: true });
668
+
669
+ // Auth flow
670
+ let token = authManager.getToken();
671
+ if (!token || !await authManager.verifyToken(token)) {
672
+ try {
673
+ token = await showLoginModal(authManager);
674
+ } catch (error) {
675
+ // Login cancelled - exit preview mode
676
+ console.log('[CMS Editor] Login cancelled');
677
+ urlState.clearPreviewMode();
678
+ return;
679
+ }
680
+ }
681
+
682
+ // Disable Lenis smooth scroll in edit mode (allows native scrolling)
683
+ disableLenisInEditMode();
684
+
685
+ // Initialize navigation guard
686
+ const navigationGuard = createNavigationGuard({
687
+ showToast: true,
688
+ toastMessage: 'Navigation disabled in edit mode',
689
+ });
690
+ navigationGuard.enable();
691
+
692
+ // Initialize editor with full context
693
+ const editor = initEditor({
694
+ apiEndpoint: '/api/cms/save',
695
+ authToken: token,
696
+ richText: true,
697
+ manifestLoader,
698
+ draftStorage,
699
+ currentPage,
700
+ });
701
+
702
+ // Enable editor (will auto-load drafts)
703
+ await editor.enable();
704
+
705
+ // Create toolbar with navigation
706
+ const { createToolbar } = await import('@see-ms/editor-overlay');
707
+ const toolbar = await createToolbar(editor, {
708
+ draftStorage,
709
+ urlState,
710
+ navigationGuard,
711
+ manifestLoader,
712
+ currentPage,
713
+ });
714
+ document.body.appendChild(toolbar);
715
+
716
+ // Watch for route changes
717
+ const router = useRouter();
718
+ router.afterEach(async (to) => {
719
+ const newPage = manifestLoader.getPageFromRoute(to.path);
720
+ if (newPage && newPage !== currentPage) {
721
+ currentPage = newPage;
722
+ await editor.setPage(newPage);
723
+
724
+ // Update toolbar if it has an update method
725
+ if (typeof (toolbar as any).updateCurrentPage === 'function') {
726
+ await (toolbar as any).updateCurrentPage(newPage);
727
+ }
728
+ }
729
+ });
730
+
731
+ // Cleanup on navigation away from preview mode
732
+ nuxtApp.hook('page:finish', () => {
733
+ const currentState = urlState.getState();
734
+ if (!currentState.preview) {
735
+ navigationGuard.disable();
736
+ editor.destroy();
737
+ }
738
+ });
739
+ });
740
+ `;
741
+ const pluginPath = path4.join(pluginsDir, "cms-editor.client.ts");
742
+ await fs3.writeFile(pluginPath, pluginContent, "utf-8");
743
+ }
744
+ async function addEditorDependency(outputDir) {
745
+ const packageJsonPath = path4.join(outputDir, "package.json");
746
+ if (await fs3.pathExists(packageJsonPath)) {
747
+ const packageJson = await fs3.readJson(packageJsonPath);
748
+ if (!packageJson.dependencies) {
749
+ packageJson.dependencies = {};
750
+ }
751
+ packageJson.dependencies["@see-ms/editor-overlay"] = "^1.0.0";
752
+ await fs3.writeJson(packageJsonPath, packageJson, { spaces: 2 });
753
+ }
754
+ }
755
+ async function createSaveEndpoint(outputDir) {
756
+ const serverDir = path4.join(outputDir, "server", "api", "cms");
757
+ await fs3.ensureDir(serverDir);
758
+ const endpointContent = `/**
759
+ * API endpoint for saving CMS changes
760
+ * Handles draft and final saving to Strapi
761
+ */
762
+
763
+ import fs from 'fs';
764
+ import path from 'path';
765
+
766
+ export default defineEventHandler(async (event) => {
767
+ // Get Strapi URL from runtime config
768
+ const config = useRuntimeConfig();
769
+ const strapiUrl = config.public.strapiUrl || 'http://localhost:1337';
770
+
771
+ // Extract Authorization header
772
+ const authHeader = getHeader(event, 'authorization');
773
+
774
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
775
+ throw createError({
776
+ statusCode: 401,
777
+ statusMessage: 'Unauthorized: Missing or invalid authorization header',
778
+ });
779
+ }
780
+
781
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
782
+
783
+ // Verify token with Strapi and determine if it's an admin or user token
784
+ let userResponse: any;
785
+ let isAdminToken = false;
786
+
787
+ try {
788
+ // Try admin token verification first
789
+ try {
790
+ userResponse = await $fetch(\`\${strapiUrl}/admin/users/me\`, {
791
+ headers: {
792
+ Authorization: \`Bearer \${token}\`,
793
+ },
794
+ });
795
+ isAdminToken = true;
796
+ } catch (adminError) {
797
+ // Fallback to regular user token verification
798
+ userResponse = await $fetch(\`\${strapiUrl}/api/users/me\`, {
799
+ headers: {
800
+ Authorization: \`Bearer \${token}\`,
801
+ },
802
+ });
803
+ isAdminToken = false;
804
+ }
805
+
806
+ // Get the request body
807
+ const body = await readBody(event);
808
+ const { page, fields, isDraft = true } = body;
809
+
810
+ if (!page || !fields) {
811
+ throw createError({
812
+ statusCode: 400,
813
+ statusMessage: 'Bad Request: Missing page or fields',
814
+ });
815
+ }
816
+
817
+ // Load manifest to understand field mappings
818
+ const manifestPath = path.join(process.cwd(), 'cms-manifest.json');
819
+ let manifest;
820
+ try {
821
+ const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
822
+ manifest = JSON.parse(manifestContent);
823
+ } catch (error) {
824
+ console.error('Failed to load manifest:', error);
825
+ throw createError({
826
+ statusCode: 500,
827
+ statusMessage: 'Failed to load CMS manifest',
828
+ });
829
+ }
830
+
831
+ // Get page configuration from manifest
832
+ const pageConfig = manifest.pages[page];
833
+ if (!pageConfig) {
834
+ throw createError({
835
+ statusCode: 404,
836
+ statusMessage: \`Page "\${page}" not found in manifest\`,
837
+ });
838
+ }
839
+
840
+ // Transform fields to Strapi format
841
+ const strapiData: Record<string, any> = {};
842
+ for (const [fieldName, value] of Object.entries(fields)) {
843
+ const fieldConfig = pageConfig.fields[fieldName];
844
+ if (!fieldConfig) {
845
+ console.warn(\`Field "\${fieldName}" not found in manifest for page "\${page}"\`);
846
+ continue;
847
+ }
848
+
849
+ // Handle different field types
850
+ if (fieldConfig.type === 'image') {
851
+ // TODO: Handle image uploads - for now just store the value
852
+ strapiData[fieldName] = value;
853
+ } else {
854
+ strapiData[fieldName] = value;
855
+ }
856
+ }
857
+
858
+ // Update Strapi v5 content - use different endpoints for admin vs user tokens
859
+ if (isAdminToken) {
860
+ // Admin tokens use the content-manager API (Strapi v5)
861
+ const contentEndpoint = \`\${strapiUrl}/content-manager/single-types/api::\${page}.\${page}\`;
862
+
863
+ // Step 1: Update the content
864
+ await $fetch(contentEndpoint, {
865
+ method: 'PUT',
866
+ headers: {
867
+ 'Authorization': \`Bearer \${token}\`,
868
+ 'Content-Type': 'application/json',
869
+ },
870
+ body: strapiData,
871
+ });
872
+
873
+ // Step 2: Publish if not a draft (Strapi v5)
874
+ if (!isDraft) {
875
+ const publishEndpoint = \`\${strapiUrl}/content-manager/single-types/api::\${page}.\${page}/actions/publish\`;
876
+ await $fetch(publishEndpoint, {
877
+ method: 'POST',
878
+ headers: {
879
+ 'Authorization': \`Bearer \${token}\`,
880
+ 'Content-Type': 'application/json',
881
+ },
882
+ body: {},
883
+ });
884
+ }
885
+ } else {
886
+ // User tokens use the regular REST API
887
+ const strapiEndpoint = \`\${strapiUrl}/api/\${page}\`;
888
+
889
+ await $fetch(strapiEndpoint, {
890
+ method: 'PUT',
891
+ headers: {
892
+ 'Authorization': \`Bearer \${token}\`,
893
+ 'Content-Type': 'application/json',
894
+ },
895
+ body: {
896
+ data: strapiData,
897
+ },
898
+ });
899
+
900
+ // Publish if not a draft (Strapi v5)
901
+ if (!isDraft) {
902
+ const publishEndpoint = \`\${strapiUrl}/api/\${page}/publish\`;
903
+ await $fetch(publishEndpoint, {
904
+ method: 'POST',
905
+ headers: {
906
+ 'Authorization': \`Bearer \${token}\`,
907
+ 'Content-Type': 'application/json',
908
+ },
909
+ body: {},
910
+ });
911
+ }
912
+ }
913
+
914
+ console.log(\`[CMS Save] Updated "\${page}" in Strapi (draft: \${isDraft})\`);
915
+
916
+ return {
917
+ success: true,
918
+ message: 'Changes saved successfully',
919
+ page,
920
+ isDraft,
921
+ user: {
922
+ id: userResponse.id,
923
+ username: userResponse.username || userResponse.firstname || 'Unknown',
924
+ },
925
+ };
926
+ } catch (error: any) {
927
+ console.error('[CMS Save] Error:', error);
928
+
929
+ // Token verification failed
930
+ if (error.statusCode === 401 || error.status === 401) {
931
+ throw createError({
932
+ statusCode: 401,
933
+ statusMessage: 'Unauthorized: Invalid or expired token',
934
+ });
935
+ }
936
+
937
+ // Strapi error
938
+ if (error.statusCode || error.status) {
939
+ throw createError({
940
+ statusCode: error.statusCode || error.status,
941
+ statusMessage: error.statusMessage || error.message || 'Failed to save to Strapi',
942
+ });
943
+ }
944
+
945
+ // Generic error
946
+ throw createError({
947
+ statusCode: 500,
948
+ statusMessage: 'Internal server error while saving changes',
949
+ });
950
+ }
951
+ });
952
+ `;
953
+ const endpointPath = path4.join(serverDir, "save.post.ts");
954
+ await fs3.writeFile(endpointPath, endpointContent, "utf-8");
955
+ }
956
+ async function createStrapiBootstrap(outputDir) {
957
+ const strapiBootstrapDir = path4.join(outputDir, "strapi-bootstrap");
958
+ await fs3.ensureDir(strapiBootstrapDir);
959
+ const bootstrapContent = `/**
960
+ * Strapi Bootstrap File
961
+ * Auto-enables public read permissions for all single types
962
+ *
963
+ * Place this file in your Strapi project at: src/index.ts
964
+ */
965
+
966
+ export default {
967
+ /**
968
+ * Bootstrap function runs when Strapi starts
969
+ */
970
+ async bootstrap({ strapi }: { strapi: any }) {
971
+ try {
972
+ console.log('[Bootstrap] Configuring public permissions for CMS...');
973
+
974
+ // Get the public role
975
+ const publicRole = await strapi
976
+ .query('plugin::users-permissions.role')
977
+ .findOne({ where: { type: 'public' } });
978
+
979
+ if (!publicRole) {
980
+ console.error('[Bootstrap] Public role not found');
981
+ return;
982
+ }
983
+
984
+ // Get all content types
985
+ const contentTypes = Object.keys(strapi.contentTypes).filter(
986
+ (uid) => uid.startsWith('api::')
987
+ );
988
+
989
+ // Enable find and findOne for each content type
990
+ const permissions = await strapi
991
+ .query('plugin::users-permissions.permission')
992
+ .findMany({
993
+ where: {
994
+ role: publicRole.id,
995
+ },
996
+ });
997
+
998
+ let updatedCount = 0;
999
+
1000
+ for (const contentType of contentTypes) {
1001
+ const [, apiName] = contentType.split('::');
1002
+ const [controllerName] = apiName.split('.');
1003
+
1004
+ // Find or create find permission
1005
+ const findPermission = permissions.find(
1006
+ (p: any) =>
1007
+ p.action === \`api::\${apiName}.find\` ||
1008
+ p.action === 'find' && p.controller === controllerName
1009
+ );
1010
+
1011
+ const findOnePermission = permissions.find(
1012
+ (p: any) =>
1013
+ p.action === \`api::\${apiName}.findOne\` ||
1014
+ p.action === 'findOne' && p.controller === controllerName
1015
+ );
1016
+
1017
+ // Enable find
1018
+ if (findPermission && !findPermission.enabled) {
1019
+ await strapi
1020
+ .query('plugin::users-permissions.permission')
1021
+ .update({
1022
+ where: { id: findPermission.id },
1023
+ data: { enabled: true },
1024
+ });
1025
+ updatedCount++;
1026
+ }
1027
+
1028
+ // Enable findOne
1029
+ if (findOnePermission && !findOnePermission.enabled) {
1030
+ await strapi
1031
+ .query('plugin::users-permissions.permission')
1032
+ .update({
1033
+ where: { id: findOnePermission.id },
1034
+ data: { enabled: true },
1035
+ });
1036
+ updatedCount++;
1037
+ }
1038
+
1039
+ // If permissions don't exist, create them
1040
+ if (!findPermission) {
1041
+ await strapi.query('plugin::users-permissions.permission').create({
1042
+ data: {
1043
+ action: \`api::\${apiName}.find\`,
1044
+ role: publicRole.id,
1045
+ enabled: true,
1046
+ },
1047
+ });
1048
+ updatedCount++;
1049
+ }
1050
+
1051
+ if (!findOnePermission) {
1052
+ await strapi.query('plugin::users-permissions.permission').create({
1053
+ data: {
1054
+ action: \`api::\${apiName}.findOne\`,
1055
+ role: publicRole.id,
1056
+ enabled: true,
1057
+ },
1058
+ });
1059
+ updatedCount++;
1060
+ }
1061
+ }
1062
+
1063
+ console.log(
1064
+ \`[Bootstrap] \u2705 Enabled \${updatedCount} public permissions for \${contentTypes.length} content types\`
1065
+ );
1066
+ } catch (error) {
1067
+ console.error('[Bootstrap] Error enabling public permissions:', error);
1068
+ }
1069
+ },
1070
+ };
1071
+ `;
1072
+ const bootstrapPath = path4.join(strapiBootstrapDir, "index.ts");
1073
+ await fs3.writeFile(bootstrapPath, bootstrapContent, "utf-8");
1074
+ const readmeContent = `# Strapi Bootstrap File
1075
+
1076
+ This file automatically enables public read permissions for all CMS content types when Strapi starts.
1077
+
1078
+ ## Installation
1079
+
1080
+ 1. Copy the \`index.ts\` file to your Strapi project:
1081
+ \`\`\`bash
1082
+ cp strapi-bootstrap/index.ts <your-strapi-project>/src/index.ts
1083
+ \`\`\`
1084
+
1085
+ 2. Restart Strapi:
1086
+ \`\`\`bash
1087
+ cd <your-strapi-project>
1088
+ npm run develop
1089
+ \`\`\`
1090
+
1091
+ 3. Check the console logs - you should see:
1092
+ \`\`\`
1093
+ [Bootstrap] \u2705 Enabled X public permissions for Y content types
1094
+ \`\`\`
1095
+
1096
+ ## What It Does
1097
+
1098
+ - Runs automatically when Strapi starts
1099
+ - Finds the "Public" role
1100
+ - Enables \`find\` and \`findOne\` permissions for all API content types
1101
+ - Allows unauthenticated users to read published content
1102
+ - Fixes 403 Forbidden errors from \`useStrapiContent\`
1103
+
1104
+ ## Manual Alternative
1105
+
1106
+ If you prefer to set permissions manually:
1107
+
1108
+ 1. Open Strapi admin: http://localhost:1337/admin
1109
+ 2. Go to: Settings \u2192 Users & Permissions Plugin \u2192 Roles \u2192 Public
1110
+ 3. For each content type, check:
1111
+ - \u2705 find
1112
+ - \u2705 findOne
1113
+ 4. Click Save
1114
+
1115
+ ## Notes
1116
+
1117
+ - Only enables READ permissions (find, findOne)
1118
+ - Does NOT enable write permissions (create, update, delete)
1119
+ - Only affects the "Public" role (unauthenticated users)
1120
+ - Safe to run multiple times (idempotent)
1121
+ `;
1122
+ const readmePath = path4.join(strapiBootstrapDir, "README.md");
1123
+ await fs3.writeFile(readmePath, readmeContent, "utf-8");
1124
+ console.log(" \u2713 Generated Strapi bootstrap file");
1125
+ }
1126
+ async function createPublishEndpoint(outputDir) {
1127
+ const serverDir = path4.join(outputDir, "server", "api", "cms");
1128
+ await fs3.ensureDir(serverDir);
1129
+ const endpointContent = `/**
1130
+ * API endpoint for batch publishing CMS changes
1131
+ * Publishes all drafts at once
1132
+ */
1133
+
1134
+ import fs from 'fs';
1135
+ import path from 'path';
1136
+
1137
+ export default defineEventHandler(async (event) => {
1138
+ // Get Strapi URL from runtime config
1139
+ const config = useRuntimeConfig();
1140
+ const strapiUrl = config.public.strapiUrl || 'http://localhost:1337';
1141
+
1142
+ // Extract Authorization header
1143
+ const authHeader = getHeader(event, 'authorization');
1144
+
1145
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
1146
+ throw createError({
1147
+ statusCode: 401,
1148
+ statusMessage: 'Unauthorized: Missing or invalid authorization header',
1149
+ });
1150
+ }
1151
+
1152
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
1153
+
1154
+ // Verify token with Strapi and determine if it's an admin or user token
1155
+ let userResponse: any;
1156
+ let isAdminToken = false;
1157
+
1158
+ try {
1159
+ // Try admin token verification first
1160
+ try {
1161
+ userResponse = await $fetch(\`\${strapiUrl}/admin/users/me\`, {
1162
+ headers: {
1163
+ Authorization: \`Bearer \${token}\`,
1164
+ },
1165
+ });
1166
+ isAdminToken = true;
1167
+ } catch (adminError) {
1168
+ // Fallback to regular user token verification
1169
+ userResponse = await $fetch(\`\${strapiUrl}/api/users/me\`, {
1170
+ headers: {
1171
+ Authorization: \`Bearer \${token}\`,
1172
+ },
1173
+ });
1174
+ isAdminToken = false;
1175
+ }
1176
+
1177
+ // Get the request body
1178
+ const body = await readBody(event);
1179
+ const { pages } = body;
1180
+
1181
+ if (!pages || !Array.isArray(pages)) {
1182
+ throw createError({
1183
+ statusCode: 400,
1184
+ statusMessage: 'Bad Request: Missing or invalid pages array',
1185
+ });
1186
+ }
1187
+
1188
+ // Load manifest to understand field mappings
1189
+ const manifestPath = path.join(process.cwd(), 'cms-manifest.json');
1190
+ let manifest;
1191
+ try {
1192
+ const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
1193
+ manifest = JSON.parse(manifestContent);
1194
+ } catch (error) {
1195
+ console.error('Failed to load manifest:', error);
1196
+ throw createError({
1197
+ statusCode: 500,
1198
+ statusMessage: 'Failed to load CMS manifest',
1199
+ });
1200
+ }
1201
+
1202
+ // Process all pages - call Strapi directly
1203
+ const results = await Promise.allSettled(
1204
+ pages.map(async ({ page, fields }) => {
1205
+ try {
1206
+ // Get page configuration from manifest
1207
+ const pageConfig = manifest.pages[page];
1208
+ if (!pageConfig) {
1209
+ throw new Error(\`Page "\${page}" not found in manifest\`);
1210
+ }
1211
+
1212
+ // Transform fields to Strapi format
1213
+ const strapiData: Record<string, any> = {};
1214
+ for (const [fieldName, value] of Object.entries(fields)) {
1215
+ const fieldConfig = pageConfig.fields[fieldName];
1216
+ if (!fieldConfig) {
1217
+ console.warn(\`Field "\${fieldName}" not found in manifest for page "\${page}"\`);
1218
+ continue;
1219
+ }
1220
+
1221
+ // Handle different field types
1222
+ if (fieldConfig.type === 'image') {
1223
+ // TODO: Handle image uploads - for now just store the value
1224
+ strapiData[fieldName] = value;
1225
+ } else {
1226
+ strapiData[fieldName] = value;
1227
+ }
1228
+ }
1229
+
1230
+ // Update Strapi v5 content - use different endpoints for admin vs user tokens
1231
+ if (isAdminToken) {
1232
+ // Admin tokens use the content-manager API (Strapi v5)
1233
+ const contentEndpoint = \`\${strapiUrl}/content-manager/single-types/api::\${page}.\${page}\`;
1234
+
1235
+ // Step 1: Update the content
1236
+ await $fetch(contentEndpoint, {
1237
+ method: 'PUT',
1238
+ headers: {
1239
+ 'Authorization': \`Bearer \${token}\`,
1240
+ 'Content-Type': 'application/json',
1241
+ },
1242
+ body: strapiData,
1243
+ });
1244
+
1245
+ // Step 2: Publish the content (Strapi v5)
1246
+ const publishEndpoint = \`\${strapiUrl}/content-manager/single-types/api::\${page}.\${page}/actions/publish\`;
1247
+ await $fetch(publishEndpoint, {
1248
+ method: 'POST',
1249
+ headers: {
1250
+ 'Authorization': \`Bearer \${token}\`,
1251
+ 'Content-Type': 'application/json',
1252
+ },
1253
+ body: {},
1254
+ });
1255
+ } else {
1256
+ // User tokens use the regular REST API
1257
+ const strapiEndpoint = \`\${strapiUrl}/api/\${page}\`;
1258
+
1259
+ await $fetch(strapiEndpoint, {
1260
+ method: 'PUT',
1261
+ headers: {
1262
+ 'Authorization': \`Bearer \${token}\`,
1263
+ 'Content-Type': 'application/json',
1264
+ },
1265
+ body: {
1266
+ data: strapiData,
1267
+ },
1268
+ });
1269
+
1270
+ // Publish using the publish endpoint (Strapi v5)
1271
+ const publishEndpoint = \`\${strapiUrl}/api/\${page}/publish\`;
1272
+ await $fetch(publishEndpoint, {
1273
+ method: 'POST',
1274
+ headers: {
1275
+ 'Authorization': \`Bearer \${token}\`,
1276
+ 'Content-Type': 'application/json',
1277
+ },
1278
+ body: {},
1279
+ });
1280
+ }
1281
+
1282
+ console.log(\`[CMS Publish] Published "\${page}" to Strapi\`);
1283
+ return { page, success: true };
1284
+ } catch (error: any) {
1285
+ console.error(\`[CMS Publish] Failed to publish "\${page}":\`, error);
1286
+ return {
1287
+ page,
1288
+ success: false,
1289
+ error: error.message || 'Unknown error',
1290
+ };
1291
+ }
1292
+ })
1293
+ );
1294
+
1295
+ // Separate successful and failed publications
1296
+ const successful: string[] = [];
1297
+ const failed: Array<{ page: string; error: string }> = [];
1298
+
1299
+ results.forEach((result, index) => {
1300
+ if (result.status === 'fulfilled' && result.value.success) {
1301
+ successful.push(result.value.page);
1302
+ } else if (result.status === 'fulfilled' && !result.value.success) {
1303
+ failed.push({
1304
+ page: result.value.page,
1305
+ error: result.value.error || 'Unknown error',
1306
+ });
1307
+ } else if (result.status === 'rejected') {
1308
+ failed.push({
1309
+ page: pages[index].page,
1310
+ error: result.reason?.message || 'Unknown error',
1311
+ });
1312
+ }
1313
+ });
1314
+
1315
+ console.log(\`[CMS Publish] Published \${successful.length} pages, \${failed.length} failed\`);
1316
+
1317
+ return {
1318
+ success: failed.length === 0,
1319
+ message: \`Published \${successful.length} of \${pages.length} pages\`,
1320
+ successful,
1321
+ failed,
1322
+ user: {
1323
+ id: userResponse.id,
1324
+ username: userResponse.username || userResponse.firstname || 'Unknown',
1325
+ },
1326
+ };
1327
+ } catch (error: any) {
1328
+ console.error('[CMS Publish] Error:', error);
1329
+
1330
+ // Token verification failed
1331
+ if (error.statusCode === 401 || error.status === 401) {
1332
+ throw createError({
1333
+ statusCode: 401,
1334
+ statusMessage: 'Unauthorized: Invalid or expired token',
1335
+ });
1336
+ }
1337
+
1338
+ // Generic error
1339
+ throw createError({
1340
+ statusCode: 500,
1341
+ statusMessage: 'Internal server error while publishing changes',
1342
+ });
1343
+ }
1344
+ });
1345
+ `;
1346
+ const endpointPath = path4.join(serverDir, "publish.post.ts");
1347
+ await fs3.writeFile(endpointPath, endpointContent, "utf-8");
1348
+ }
1349
+
1350
+ // src/boilerplate.ts
1351
+ import fs4 from "fs-extra";
1352
+ import path5 from "path";
1353
+ import { execSync as execSync2 } from "child_process";
1354
+ import pc2 from "picocolors";
1355
+ function isGitHubURL(source) {
1356
+ return source.startsWith("https://github.com/") || source.startsWith("git@github.com:") || source.includes("github.com");
1357
+ }
1358
+ async function cloneFromGitHub(repoUrl, outputDir) {
1359
+ console.log(pc2.blue(" Cloning from GitHub..."));
1360
+ try {
1361
+ execSync2(`git clone ${repoUrl} ${outputDir}`, { stdio: "inherit" });
1362
+ const gitDir = path5.join(outputDir, ".git");
1363
+ await fs4.remove(gitDir);
1364
+ console.log(pc2.green(" \u2713 Boilerplate cloned successfully"));
1365
+ } catch (error) {
1366
+ throw new Error(`Failed to clone repository: ${error instanceof Error ? error.message : String(error)}`);
1367
+ }
1368
+ }
1369
+ async function copyFromLocal(sourcePath, outputDir) {
1370
+ console.log(pc2.blue(" Copying from local path..."));
1371
+ const sourceExists = await fs4.pathExists(sourcePath);
1372
+ if (!sourceExists) {
1373
+ throw new Error(`Local boilerplate not found: ${sourcePath}`);
1374
+ }
1375
+ await fs4.copy(sourcePath, outputDir, {
1376
+ filter: (src) => {
1377
+ const name = path5.basename(src);
1378
+ return !["node_modules", ".nuxt", ".output", ".git", "dist"].includes(name);
1379
+ }
1380
+ });
1381
+ console.log(pc2.green(" \u2713 Boilerplate copied successfully"));
1382
+ }
1383
+ async function setupBoilerplate(boilerplateSource, outputDir) {
1384
+ if (!boilerplateSource) {
1385
+ console.log(pc2.blue("\n\u{1F4E6} Creating minimal Nuxt structure..."));
1386
+ await fs4.ensureDir(outputDir);
1387
+ await fs4.ensureDir(path5.join(outputDir, "pages"));
1388
+ await fs4.ensureDir(path5.join(outputDir, "assets"));
1389
+ await fs4.ensureDir(path5.join(outputDir, "public"));
1390
+ await fs4.ensureDir(path5.join(outputDir, "utils"));
1391
+ const configPath = path5.join(outputDir, "nuxt.config.ts");
1392
+ const configExists = await fs4.pathExists(configPath);
1393
+ if (!configExists) {
1394
+ const basicConfig = `export default defineNuxtConfig({
1395
+ devtools: { enabled: true },
1396
+ css: [],
1397
+ })
1398
+ `;
1399
+ await fs4.writeFile(configPath, basicConfig, "utf-8");
1400
+ }
1401
+ console.log(pc2.green(" \u2713 Structure created"));
1402
+ return;
1403
+ }
1404
+ const outputExists = await fs4.pathExists(outputDir);
1405
+ if (outputExists) {
1406
+ throw new Error(`Output directory already exists: ${outputDir}. Please choose a different path or remove it first.`);
1407
+ }
1408
+ console.log(pc2.blue("\n\u{1F4E6} Setting up boilerplate..."));
1409
+ if (isGitHubURL(boilerplateSource)) {
1410
+ await cloneFromGitHub(boilerplateSource, outputDir);
1411
+ } else {
1412
+ await copyFromLocal(boilerplateSource, outputDir);
1413
+ }
1414
+ }
1415
+
1416
+ // src/manifest.ts
1417
+ import fs6 from "fs-extra";
1418
+ import path7 from "path";
1419
+
1420
+ // src/detector.ts
1421
+ import * as cheerio2 from "cheerio";
1422
+ import fs5 from "fs-extra";
1423
+ import path6 from "path";
1424
+ function cleanClassName(className) {
1425
+ return className.split(" ").filter((cls) => !cls.startsWith("c-") && !cls.startsWith("w-")).filter((cls) => cls.length > 0).join(" ");
1426
+ }
1427
+ function getPrimaryClass(classAttr) {
1428
+ if (!classAttr) return null;
1429
+ const cleaned = cleanClassName(classAttr);
1430
+ const classes = cleaned.split(" ").filter((c) => c.length > 0);
1431
+ if (classes.length === 0) return null;
1432
+ const original = classes[0];
1433
+ return {
1434
+ selector: original,
1435
+ // Keep original with dashes for CSS selector
1436
+ fieldName: original.replace(/-/g, "_")
1437
+ // Normalize for field name
1438
+ };
1439
+ }
1440
+ function getContextModifier(_$, $el) {
1441
+ let $current = $el.parent();
1442
+ let depth = 0;
1443
+ while ($current.length > 0 && depth < 5) {
1444
+ const classes = $current.attr("class");
1445
+ if (classes) {
1446
+ const ccClass = classes.split(" ").find((c) => c.startsWith("cc-"));
1447
+ if (ccClass) {
1448
+ return ccClass.replace("cc-", "").replace(/-/g, "_");
1449
+ }
1450
+ }
1451
+ $current = $current.parent();
1452
+ depth++;
1453
+ }
1454
+ return null;
1455
+ }
1456
+ function isDecorativeImage(_$, $img) {
1457
+ const $parent = $img.parent();
1458
+ const parentClass = $parent.attr("class") || "";
1459
+ const decorativePatterns = [
1460
+ "nav",
1461
+ "logo",
1462
+ "icon",
1463
+ "arrow",
1464
+ "button",
1465
+ "quote",
1466
+ "pagination",
1467
+ "footer",
1468
+ "link"
1469
+ ];
1470
+ return decorativePatterns.some(
1471
+ (pattern) => parentClass.includes(pattern) || parentClass.includes(`${pattern}_`)
1472
+ );
1473
+ }
1474
+ function isInsideButton($, el) {
1475
+ const $el = $(el);
1476
+ const $button = $el.closest("button, a, NuxtLink, .c_button, .c_icon_button");
1477
+ return $button.length > 0;
1478
+ }
1479
+ function extractTemplateFromVue(vueContent) {
1480
+ const templateMatch = vueContent.match(/<template>([\s\S]*?)<\/template>/);
1481
+ if (!templateMatch) {
1482
+ return "";
1483
+ }
1484
+ return templateMatch[1];
1485
+ }
1486
+ function detectEditableFields(templateHtml) {
1487
+ const $ = cheerio2.load(templateHtml);
1488
+ const detectedFields = {};
1489
+ const detectedCollections = {};
1490
+ const collectionElements = /* @__PURE__ */ new Set();
1491
+ const processedCollectionClasses = /* @__PURE__ */ new Set();
1492
+ const potentialCollections = /* @__PURE__ */ new Map();
1493
+ $("[class]").each((_, el) => {
1494
+ const primaryClass = getPrimaryClass($(el).attr("class"));
1495
+ if (primaryClass && (primaryClass.fieldName.includes("card") || primaryClass.fieldName.includes("item") || primaryClass.fieldName.includes("post") || primaryClass.fieldName.includes("feature")) && !primaryClass.fieldName.includes("image") && !primaryClass.fieldName.includes("inner")) {
1496
+ if (!potentialCollections.has(primaryClass.fieldName)) {
1497
+ potentialCollections.set(primaryClass.fieldName, []);
1498
+ }
1499
+ potentialCollections.get(primaryClass.fieldName)?.push(el);
1500
+ }
1501
+ });
1502
+ potentialCollections.forEach((elements, className) => {
1503
+ if (elements.length >= 2) {
1504
+ const $first = $(elements[0]);
1505
+ const collectionFields = {};
1506
+ processedCollectionClasses.add(className);
1507
+ elements.forEach((el) => {
1508
+ collectionElements.add(el);
1509
+ $(el).find("*").each((_, child) => {
1510
+ collectionElements.add(child);
1511
+ });
1512
+ });
1513
+ const collectionClassInfo = getPrimaryClass($(elements[0]).attr("class"));
1514
+ const collectionSelector = collectionClassInfo ? `.${collectionClassInfo.selector}` : `.${className}`;
1515
+ $first.find("img").each((_, img) => {
1516
+ if (isInsideButton($, img)) return;
1517
+ const $img = $(img);
1518
+ const $parent = $img.parent();
1519
+ const parentClassInfo = getPrimaryClass($parent.attr("class"));
1520
+ if (parentClassInfo && parentClassInfo.fieldName.includes("image")) {
1521
+ collectionFields.image = `.${parentClassInfo.selector}`;
1522
+ return false;
1523
+ }
1524
+ });
1525
+ $first.find("div").each((_, el) => {
1526
+ const classInfo = getPrimaryClass($(el).attr("class"));
1527
+ if (classInfo && classInfo.fieldName.includes("tag") && !classInfo.fieldName.includes("container")) {
1528
+ collectionFields.tag = `.${classInfo.selector}`;
1529
+ return false;
1530
+ }
1531
+ });
1532
+ $first.find("h1, h2, h3, h4, h5, h6").first().each((_, el) => {
1533
+ const classInfo = getPrimaryClass($(el).attr("class"));
1534
+ if (classInfo) {
1535
+ collectionFields.title = `.${classInfo.selector}`;
1536
+ }
1537
+ });
1538
+ $first.find("p").first().each((_, el) => {
1539
+ const classInfo = getPrimaryClass($(el).attr("class"));
1540
+ if (classInfo) {
1541
+ collectionFields.description = `.${classInfo.selector}`;
1542
+ }
1543
+ });
1544
+ $first.find("a, NuxtLink").not(".c_button, .c_icon_button").each((_, el) => {
1545
+ const $link = $(el);
1546
+ const linkText = $link.text().trim();
1547
+ if (linkText) {
1548
+ const classInfo = getPrimaryClass($link.attr("class"));
1549
+ collectionFields.link = classInfo ? `.${classInfo.selector}` : "a";
1550
+ return false;
1551
+ }
1552
+ });
1553
+ if (Object.keys(collectionFields).length > 0) {
1554
+ let collectionName = className;
1555
+ if (!collectionName.endsWith("s")) {
1556
+ collectionName += "s";
1557
+ }
1558
+ detectedCollections[collectionName] = {
1559
+ selector: collectionSelector,
1560
+ fields: collectionFields
1561
+ };
1562
+ }
1563
+ }
1564
+ });
1565
+ const $body = $("body");
1566
+ $body.find("h1, h2, h3, h4, h5, h6").each((index, el) => {
1567
+ if (collectionElements.has(el)) return;
1568
+ const $el = $(el);
1569
+ const text = $el.text().trim();
1570
+ const classInfo = getPrimaryClass($el.attr("class"));
1571
+ if (text) {
1572
+ let fieldName;
1573
+ let selector;
1574
+ if (classInfo && !classInfo.fieldName.startsWith("heading_")) {
1575
+ fieldName = classInfo.fieldName;
1576
+ selector = `.${classInfo.selector}`;
1577
+ } else {
1578
+ const $parent = $el.closest('[class*="header"], [class*="hero"], [class*="cta"]').first();
1579
+ const parentClassInfo = getPrimaryClass($parent.attr("class"));
1580
+ const modifier = getContextModifier($, $el);
1581
+ if (parentClassInfo) {
1582
+ fieldName = modifier ? `${modifier}_${parentClassInfo.fieldName}` : parentClassInfo.fieldName;
1583
+ selector = classInfo ? `.${classInfo.selector}` : `.${parentClassInfo.selector}`;
1584
+ } else if (modifier) {
1585
+ fieldName = `${modifier}_heading`;
1586
+ selector = classInfo ? `.${classInfo.selector}` : el.tagName.toLowerCase();
1587
+ } else {
1588
+ fieldName = `heading_${index}`;
1589
+ selector = classInfo ? `.${classInfo.selector}` : el.tagName.toLowerCase();
1590
+ }
1591
+ }
1592
+ detectedFields[fieldName] = {
1593
+ selector,
1594
+ type: "plain",
1595
+ editable: true
1596
+ };
1597
+ }
1598
+ });
1599
+ $body.find("p").each((_index, el) => {
1600
+ if (collectionElements.has(el)) return;
1601
+ const $el = $(el);
1602
+ const text = $el.text().trim();
1603
+ const classInfo = getPrimaryClass($el.attr("class"));
1604
+ if (text && text.length > 20 && classInfo) {
1605
+ const hasFormatting = $el.find("strong, em, b, i, a, NuxtLink").length > 0;
1606
+ detectedFields[classInfo.fieldName] = {
1607
+ selector: `.${classInfo.selector}`,
1608
+ type: hasFormatting ? "rich" : "plain",
1609
+ editable: true
1610
+ };
1611
+ }
1612
+ });
1613
+ $body.find("img").each((_index, el) => {
1614
+ if (collectionElements.has(el)) return;
1615
+ if (isInsideButton($, el)) return;
1616
+ const $el = $(el);
1617
+ if (isDecorativeImage($, $el)) return;
1618
+ const $parent = $el.parent();
1619
+ const parentClassInfo = getPrimaryClass($parent.attr("class"));
1620
+ if (parentClassInfo) {
1621
+ const fieldName = parentClassInfo.fieldName.includes("image") ? parentClassInfo.fieldName : `${parentClassInfo.fieldName}_image`;
1622
+ detectedFields[fieldName] = {
1623
+ selector: `.${parentClassInfo.selector}`,
1624
+ type: "image",
1625
+ editable: true
1626
+ };
1627
+ }
1628
+ });
1629
+ $body.find("NuxtLink.c_button, a.c_button, .c_button").each((_index, el) => {
1630
+ if (collectionElements.has(el)) return;
1631
+ const $el = $(el);
1632
+ const text = $el.contents().filter(function() {
1633
+ return this.type === "text" || this.type === "tag" && this.name === "div";
1634
+ }).first().text().trim();
1635
+ if (text && text.length > 2) {
1636
+ const $parent = $el.closest('[class*="cta"]').first();
1637
+ const parentClassInfo = getPrimaryClass($parent.attr("class"));
1638
+ const fieldName = parentClassInfo ? `${parentClassInfo.fieldName}_button_text` : "button_text";
1639
+ detectedFields[fieldName] = {
1640
+ selector: `.c_button`,
1641
+ type: "plain",
1642
+ editable: true
1643
+ };
1644
+ }
1645
+ });
1646
+ return {
1647
+ fields: detectedFields,
1648
+ collections: detectedCollections
1649
+ };
1650
+ }
1651
+ async function analyzeVuePages(pagesDir) {
1652
+ const results = {};
1653
+ const vueFiles = await fs5.readdir(pagesDir);
1654
+ for (const file of vueFiles) {
1655
+ if (file.endsWith(".vue")) {
1656
+ const filePath = path6.join(pagesDir, file);
1657
+ const content = await fs5.readFile(filePath, "utf-8");
1658
+ const template = extractTemplateFromVue(content);
1659
+ if (template) {
1660
+ const pageName = file.replace(".vue", "");
1661
+ results[pageName] = detectEditableFields(template);
1662
+ }
1663
+ }
1664
+ }
1665
+ return results;
1666
+ }
1667
+
1668
+ // src/manifest.ts
1669
+ async function generateManifest(pagesDir) {
1670
+ const analyzed = await analyzeVuePages(pagesDir);
1671
+ const pages = {};
1672
+ for (const [pageName, detection] of Object.entries(analyzed)) {
1673
+ pages[pageName] = {
1674
+ fields: detection.fields,
1675
+ collections: detection.collections,
1676
+ meta: {
1677
+ route: pageName === "index" ? "/" : `/${pageName}`
1678
+ }
1679
+ };
1680
+ }
1681
+ const manifest = {
1682
+ version: "1.0",
1683
+ pages
1684
+ };
1685
+ return manifest;
1686
+ }
1687
+ async function writeManifest(outputDir, manifest) {
1688
+ const manifestContent = JSON.stringify(manifest, null, 2);
1689
+ const manifestPath = path7.join(outputDir, "cms-manifest.json");
1690
+ await fs6.writeFile(manifestPath, manifestContent, "utf-8");
1691
+ const publicDir = path7.join(outputDir, "public");
1692
+ await fs6.ensureDir(publicDir);
1693
+ const publicManifestPath = path7.join(publicDir, "cms-manifest.json");
1694
+ await fs6.writeFile(publicManifestPath, manifestContent, "utf-8");
1695
+ }
1696
+ async function readManifest(outputDir) {
1697
+ const manifestPath = path7.join(outputDir, "cms-manifest.json");
1698
+ const content = await fs6.readFile(manifestPath, "utf-8");
1699
+ return JSON.parse(content);
1700
+ }
1701
+
1702
+ // src/vue-transformer.ts
1703
+ import * as cheerio3 from "cheerio";
1704
+ import fs7 from "fs-extra";
1705
+ import path8 from "path";
1706
+ function replaceWithBinding(_$, $el, fieldName, type) {
1707
+ if (type === "image") {
1708
+ const $img = $el.find("img").first();
1709
+ if ($img.length) {
1710
+ $img.attr(":src", `content.${fieldName}`);
1711
+ $img.removeAttr("src");
1712
+ }
1713
+ } else if (type === "rich") {
1714
+ $el.attr("v-html", `content.${fieldName}`);
1715
+ $el.empty();
1716
+ } else {
1717
+ $el.empty();
1718
+ $el.text(`{{ content.${fieldName} }}`);
1719
+ }
1720
+ }
1721
+ function transformCollection($, collectionName, collection) {
1722
+ const $items = $(collection.selector);
1723
+ if ($items.length === 0) return;
1724
+ const $first = $items.first();
1725
+ $first.attr("v-for", `(item, index) in content.${collectionName}`);
1726
+ $first.attr(":key", "index");
1727
+ Object.entries(collection.fields).forEach(([fieldName, selector]) => {
1728
+ const $fieldEl = $first.find(selector);
1729
+ if ($fieldEl.length) {
1730
+ if (fieldName === "image") {
1731
+ const $img = $fieldEl.find("img").first();
1732
+ if ($img.length) {
1733
+ $img.attr(":src", "item.image");
1734
+ $img.removeAttr("src");
1735
+ }
1736
+ } else if (fieldName === "link") {
1737
+ $fieldEl.attr(":to", "item.link");
1738
+ $fieldEl.removeAttr("to");
1739
+ $fieldEl.removeAttr("href");
1740
+ } else {
1741
+ $fieldEl.empty();
1742
+ $fieldEl.text(`{{ item.${fieldName} }}`);
1743
+ }
1744
+ }
1745
+ });
1746
+ $items.slice(1).remove();
1747
+ }
1748
+ async function transformVueToReactive(vueFilePath, pageName, manifest) {
1749
+ const pageManifest = manifest.pages[pageName];
1750
+ if (!pageManifest) return;
1751
+ const vueContent = await fs7.readFile(vueFilePath, "utf-8");
1752
+ if (vueContent.includes("useStrapiContent")) {
1753
+ console.log(` Skipping ${pageName} - already transformed`);
1754
+ return;
1755
+ }
1756
+ const templateMatch = vueContent.match(/<template>([\s\S]*?)<\/template>/);
1757
+ if (!templateMatch) return;
1758
+ const templateContent = templateMatch[1];
1759
+ const $ = cheerio3.load(templateContent, { xmlMode: false });
1760
+ if (pageManifest.collections) {
1761
+ Object.entries(pageManifest.collections).forEach(([collectionName, collection]) => {
1762
+ transformCollection($, collectionName, collection);
1763
+ });
1764
+ }
1765
+ if (pageManifest.fields) {
1766
+ Object.entries(pageManifest.fields).forEach(([fieldName, field]) => {
1767
+ const $elements = $(field.selector);
1768
+ $elements.each((_, el) => {
1769
+ const $el = $(el);
1770
+ replaceWithBinding($, $el, fieldName, field.type);
1771
+ });
1772
+ });
1773
+ }
1774
+ let transformedTemplate = $.html();
1775
+ const bodyMatch = transformedTemplate.match(/<body>([\s\S]*)<\/body>/);
1776
+ if (bodyMatch) {
1777
+ transformedTemplate = bodyMatch[1];
1778
+ }
1779
+ transformedTemplate = transformedTemplate.replace(/<\/?html[^>]*>/gi, "").replace(/<head><\/head>/gi, "").trim();
1780
+ const wrapperDivMatch = transformedTemplate.match(/^<div>\s*([\s\S]*?)\s*<\/div>$/);
1781
+ if (wrapperDivMatch) {
1782
+ transformedTemplate = wrapperDivMatch[1].trim();
1783
+ }
1784
+ const scriptSetup = `<script setup lang="ts">
1785
+ // Auto-generated reactive content from Strapi
1786
+ const { content } = useStrapiContent('${pageName}');
1787
+ </script>`;
1788
+ const finalTemplate = transformedTemplate.split("\n").map((line) => " " + line).join("\n");
1789
+ const newVueContent = `${scriptSetup}
1790
+
1791
+ <template>
1792
+ ${finalTemplate}
1793
+ </template>
1794
+ `;
1795
+ await fs7.writeFile(vueFilePath, newVueContent, "utf-8");
1796
+ }
1797
+ async function transformAllVuePages(pagesDir, manifest) {
1798
+ const vueFiles = await fs7.readdir(pagesDir);
1799
+ for (const file of vueFiles) {
1800
+ if (file.endsWith(".vue")) {
1801
+ const pageName = file.replace(".vue", "");
1802
+ const vueFilePath = path8.join(pagesDir, file);
1803
+ await transformVueToReactive(vueFilePath, pageName, manifest);
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ // src/transformer.ts
1809
+ function mapFieldTypeToStrapi(fieldType) {
1810
+ const typeMap = {
1811
+ plain: "string",
1812
+ rich: "richtext",
1813
+ html: "richtext",
1814
+ image: "media",
1815
+ link: "string",
1816
+ email: "email",
1817
+ phone: "string"
1818
+ };
1819
+ return typeMap[fieldType] || "string";
1820
+ }
1821
+ function pluralize(word) {
1822
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) {
1823
+ return word + "es";
1824
+ }
1825
+ if (word.endsWith("y") && word.length > 1) {
1826
+ const secondLast = word[word.length - 2];
1827
+ if (!"aeiou".includes(secondLast.toLowerCase())) {
1828
+ return word.slice(0, -1) + "ies";
1829
+ }
1830
+ }
1831
+ return word + "s";
1832
+ }
1833
+ function pageToStrapiSchema(pageName, fields) {
1834
+ const attributes = {};
1835
+ for (const [fieldName, field] of Object.entries(fields)) {
1836
+ attributes[fieldName] = {
1837
+ type: mapFieldTypeToStrapi(field.type),
1838
+ required: field.required || false
1839
+ };
1840
+ if (field.default) {
1841
+ attributes[fieldName].default = field.default;
1842
+ }
1843
+ }
1844
+ const displayName = pageName.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1845
+ const kebabCaseName = pageName;
1846
+ const pluralName = pluralize(kebabCaseName);
1847
+ return {
1848
+ kind: "singleType",
1849
+ collectionName: kebabCaseName,
1850
+ info: {
1851
+ singularName: kebabCaseName,
1852
+ pluralName,
1853
+ displayName
1854
+ },
1855
+ options: {
1856
+ draftAndPublish: true
1857
+ },
1858
+ attributes
1859
+ };
1860
+ }
1861
+ function collectionToStrapiSchema(collectionName, collection) {
1862
+ const attributes = {};
1863
+ for (const [fieldName, _selector] of Object.entries(collection.fields)) {
1864
+ let type = "string";
1865
+ if (fieldName === "image" || fieldName.includes("image")) {
1866
+ type = "media";
1867
+ } else if (fieldName === "description" || fieldName === "content") {
1868
+ type = "richtext";
1869
+ } else if (fieldName === "link" || fieldName === "url") {
1870
+ type = "string";
1871
+ } else if (fieldName === "title" || fieldName === "tag") {
1872
+ type = "string";
1873
+ }
1874
+ attributes[fieldName] = {
1875
+ type
1876
+ };
1877
+ }
1878
+ const displayName = collectionName.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1879
+ const kebabCaseName = collectionName.replace(/_/g, "-");
1880
+ const singularName = kebabCaseName.endsWith("s") ? kebabCaseName.slice(0, -1) : kebabCaseName;
1881
+ return {
1882
+ kind: "collectionType",
1883
+ collectionName: kebabCaseName,
1884
+ info: {
1885
+ singularName,
1886
+ pluralName: kebabCaseName,
1887
+ displayName
1888
+ },
1889
+ options: {
1890
+ draftAndPublish: true
1891
+ },
1892
+ attributes
1893
+ };
1894
+ }
1895
+ function manifestToSchemas(manifest) {
1896
+ const schemas = {};
1897
+ for (const [pageName, page] of Object.entries(manifest.pages)) {
1898
+ if (page.fields && Object.keys(page.fields).length > 0) {
1899
+ schemas[pageName] = pageToStrapiSchema(pageName, page.fields);
1900
+ }
1901
+ if (page.collections) {
1902
+ for (const [collectionName, collection] of Object.entries(page.collections)) {
1903
+ schemas[collectionName] = collectionToStrapiSchema(collectionName, collection);
1904
+ }
1905
+ }
1906
+ }
1907
+ return schemas;
1908
+ }
1909
+
1910
+ // src/schema-writer.ts
1911
+ import fs8 from "fs-extra";
1912
+ import path9 from "path";
1913
+ async function writeStrapiSchema(outputDir, name, schema) {
1914
+ const schemasDir = path9.join(outputDir, "cms-schemas");
1915
+ await fs8.ensureDir(schemasDir);
1916
+ const schemaPath = path9.join(schemasDir, `${name}.json`);
1917
+ await fs8.writeFile(schemaPath, JSON.stringify(schema, null, 2), "utf-8");
1918
+ }
1919
+ async function writeAllSchemas(outputDir, schemas) {
1920
+ for (const [name, schema] of Object.entries(schemas)) {
1921
+ await writeStrapiSchema(outputDir, name, schema);
1922
+ }
1923
+ }
1924
+ async function createStrapiReadme(outputDir) {
1925
+ const readmePath = path9.join(outputDir, "cms-schemas", "README.md");
1926
+ const content = `# CMS Schemas
1927
+
1928
+ Auto-generated Strapi content type schemas from your Webflow export.
1929
+
1930
+ ## What's in this folder?
1931
+
1932
+ Each \`.json\` file is a Strapi content type schema:
1933
+
1934
+ - **Pages** (single types) - Unique pages like \`index.json\`, \`about.json\`
1935
+ - **Collections** (collection types) - Repeating content like \`portfolio_cards.json\`
1936
+
1937
+ ## How to use with Strapi
1938
+
1939
+ ### Option 1: Manual Setup (Recommended for learning)
1940
+
1941
+ 1. Start your Strapi project
1942
+ 2. In Strapi admin, go to **Content-Type Builder**
1943
+ 3. Create each content type manually using these schemas as reference
1944
+ 4. Match the field names and types
1945
+
1946
+ ### Option 2: Automated Setup (Advanced)
1947
+
1948
+ Copy schemas to your Strapi project structure:
1949
+
1950
+ \`\`\`bash
1951
+ # For each schema file, create the Strapi directory structure
1952
+ # Example for index.json (single type):
1953
+ mkdir -p strapi/src/api/index/content-types/index
1954
+ cp cms-schemas/index.json strapi/src/api/index/content-types/index/schema.json
1955
+
1956
+ # Example for portfolio_cards.json (collection type):
1957
+ mkdir -p strapi/src/api/portfolio-cards/content-types/portfolio-card
1958
+ cp cms-schemas/portfolio_cards.json strapi/src/api/portfolio-cards/content-types/portfolio-card/schema.json
1959
+ \`\`\`
1960
+
1961
+ Then restart Strapi - it will auto-create the content types.
1962
+
1963
+ ## Schema Structure
1964
+
1965
+ Each schema defines:
1966
+ - \`kind\`: "singleType" (unique page) or "collectionType" (repeating)
1967
+ - \`attributes\`: Fields and their types (string, richtext, media, etc.)
1968
+ - \`displayName\`: How it appears in Strapi admin
1969
+
1970
+ ## Field Types
1971
+
1972
+ - \`string\` - Plain text
1973
+ - \`richtext\` - Formatted text with HTML
1974
+ - \`media\` - Image uploads
1975
+
1976
+ ## Next Steps
1977
+
1978
+ 1. Set up a Strapi project: \`npx create-strapi-app@latest my-strapi\`
1979
+ 2. Use these schemas to create content types
1980
+ 3. Populate content in Strapi admin
1981
+ 4. Connect your Nuxt app to Strapi API
1982
+
1983
+ ## API Usage in Nuxt
1984
+
1985
+ Once Strapi is running with these content types:
1986
+
1987
+ \`\`\`typescript
1988
+ // Fetch single type (e.g., home page)
1989
+ const { data } = await $fetch('http://localhost:1337/api/index')
1990
+
1991
+ // Fetch collection type (e.g., portfolio cards)
1992
+ const { data } = await $fetch('http://localhost:1337/api/portfolio-cards')
1993
+ \`\`\`
1994
+ `;
1995
+ await fs8.writeFile(readmePath, content, "utf-8");
1996
+ }
1997
+
1998
+ // src/content-extractor.ts
1999
+ import * as cheerio4 from "cheerio";
2000
+ import path10 from "path";
2001
+ function extractContentFromHTML(html, _pageName, pageManifest) {
2002
+ const $ = cheerio4.load(html);
2003
+ const content = {
2004
+ fields: {},
2005
+ collections: {}
2006
+ };
2007
+ if (pageManifest.fields) {
2008
+ for (const [fieldName, field] of Object.entries(pageManifest.fields)) {
2009
+ const selector = field.selector;
2010
+ const element = $(selector).first();
2011
+ if (element.length > 0) {
2012
+ if (field.type === "image") {
2013
+ const src = element.attr("src") || element.find("img").attr("src") || "";
2014
+ content.fields[fieldName] = src;
2015
+ } else {
2016
+ const text = element.text().trim();
2017
+ content.fields[fieldName] = text;
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ if (pageManifest.collections) {
2023
+ for (const [collectionName, collection] of Object.entries(pageManifest.collections)) {
2024
+ const items = [];
2025
+ const collectionElements = $(collection.selector);
2026
+ collectionElements.each((_, elem) => {
2027
+ const item = {};
2028
+ const $elem = $(elem);
2029
+ for (const [fieldName, fieldSelector] of Object.entries(collection.fields)) {
2030
+ const fieldElement = $elem.find(fieldSelector).first();
2031
+ if (fieldElement.length > 0) {
2032
+ if (fieldName === "image" || fieldName.includes("image")) {
2033
+ const src = fieldElement.attr("src") || fieldElement.find("img").attr("src") || "";
2034
+ item[fieldName] = src;
2035
+ } else if (fieldName === "link" || fieldName === "url") {
2036
+ const href = fieldElement.attr("href") || "";
2037
+ item[fieldName] = href;
2038
+ } else {
2039
+ const text = fieldElement.text().trim();
2040
+ item[fieldName] = text;
2041
+ }
2042
+ }
2043
+ }
2044
+ if (Object.keys(item).length > 0) {
2045
+ items.push(item);
2046
+ }
2047
+ });
2048
+ if (items.length > 0) {
2049
+ content.collections[collectionName] = items;
2050
+ }
2051
+ }
2052
+ }
2053
+ return content;
2054
+ }
2055
+ function extractAllContent(htmlFiles, manifest) {
2056
+ const extractedContent = {
2057
+ pages: {}
2058
+ };
2059
+ for (const [pageName, pageManifest] of Object.entries(manifest.pages)) {
2060
+ const html = htmlFiles.get(pageName);
2061
+ if (html) {
2062
+ const content = extractContentFromHTML(html, pageName, pageManifest);
2063
+ extractedContent.pages[pageName] = content;
2064
+ }
2065
+ }
2066
+ return extractedContent;
2067
+ }
2068
+ function normalizeImagePath(imageSrc) {
2069
+ if (!imageSrc) return "";
2070
+ if (imageSrc.startsWith("/")) return imageSrc;
2071
+ const filename = path10.basename(imageSrc);
2072
+ if (imageSrc.includes("images/")) {
2073
+ return `/images/${filename}`;
2074
+ }
2075
+ return `/${filename}`;
2076
+ }
2077
+ function formatForStrapi(extracted) {
2078
+ const seedData = {};
2079
+ for (const [pageName, content] of Object.entries(extracted.pages)) {
2080
+ if (Object.keys(content.fields).length > 0) {
2081
+ const formattedFields = {};
2082
+ for (const [fieldName, value] of Object.entries(content.fields)) {
2083
+ if (fieldName.includes("image") || fieldName.includes("bg")) {
2084
+ formattedFields[fieldName] = normalizeImagePath(value);
2085
+ } else {
2086
+ formattedFields[fieldName] = value;
2087
+ }
2088
+ }
2089
+ seedData[pageName] = formattedFields;
2090
+ }
2091
+ for (const [collectionName, items] of Object.entries(content.collections)) {
2092
+ const formattedItems = items.map((item) => {
2093
+ const formattedItem = {};
2094
+ for (const [fieldName, value] of Object.entries(item)) {
2095
+ if (fieldName === "image" || fieldName.includes("image")) {
2096
+ formattedItem[fieldName] = normalizeImagePath(value);
2097
+ } else {
2098
+ formattedItem[fieldName] = value;
2099
+ }
2100
+ }
2101
+ return formattedItem;
2102
+ });
2103
+ seedData[collectionName] = formattedItems;
2104
+ }
2105
+ }
2106
+ return seedData;
2107
+ }
2108
+
2109
+ // src/seed-writer.ts
2110
+ import fs9 from "fs-extra";
2111
+ import path11 from "path";
2112
+ async function writeSeedData(outputDir, seedData) {
2113
+ const seedDir = path11.join(outputDir, "cms-seed");
2114
+ await fs9.ensureDir(seedDir);
2115
+ const seedPath = path11.join(seedDir, "seed-data.json");
2116
+ await fs9.writeJson(seedPath, seedData, { spaces: 2 });
2117
+ }
2118
+ async function createSeedReadme(outputDir) {
2119
+ const readmePath = path11.join(outputDir, "cms-seed", "README.md");
2120
+ const content = `# CMS Seed Data
2121
+
2122
+ Auto-extracted content from your Webflow export, ready to seed into Strapi.
2123
+
2124
+ ## What's in this folder?
2125
+
2126
+ \`seed-data.json\` contains the actual content extracted from your HTML:
2127
+ - **Single types** - Page-specific content (homepage, about page, etc.)
2128
+ - **Collection types** - Repeating items (portfolio cards, team members, etc.)
2129
+
2130
+ ## Structure
2131
+
2132
+ \`\`\`json
2133
+ {
2134
+ "index": {
2135
+ "hero_heading_container": "Actual heading from HTML",
2136
+ "hero_bg_image": "/images/hero.jpg",
2137
+ ...
2138
+ },
2139
+ "portfolio_cards": [
2140
+ {
2141
+ "image": "/images/card1.jpg",
2142
+ "tag": "Technology",
2143
+ "description": "Card description"
2144
+ }
2145
+ ]
2146
+ }
2147
+ \`\`\`
2148
+
2149
+ ## How to Seed Strapi
2150
+
2151
+ ### Option 1: Manual Entry
2152
+ 1. Open Strapi admin panel
2153
+ 2. Go to Content Manager
2154
+ 3. Create entries using the data from \`seed-data.json\`
2155
+
2156
+ ### Option 2: Automated Seeding (Coming Soon)
2157
+ We'll provide a seeding script that:
2158
+ 1. Uploads images to Strapi media library
2159
+ 2. Creates content entries via Strapi API
2160
+ 3. Handles relationships between content types
2161
+
2162
+ ## Image Paths
2163
+
2164
+ Image paths in the seed data reference files in your Nuxt \`public/\` directory:
2165
+ - \`/images/hero.jpg\` \u2192 \`public/images/hero.jpg\`
2166
+
2167
+ When seeding Strapi, these images will be uploaded to Strapi's media library.
2168
+
2169
+ ## Next Steps
2170
+
2171
+ 1. Review the extracted data for accuracy
2172
+ 2. Set up your Strapi instance with the schemas from \`cms-schemas/\`
2173
+ 3. Use this seed data to populate your CMS
2174
+ `;
2175
+ await fs9.writeFile(readmePath, content, "utf-8");
2176
+ }
2177
+
2178
+ // src/converter.ts
2179
+ async function convertWebflowExport(options) {
2180
+ const { inputDir, outputDir, boilerplate } = options;
2181
+ console.log(pc3.cyan("\u{1F680} Starting Webflow to Nuxt conversion..."));
2182
+ console.log(pc3.dim(`Input: ${inputDir}`));
2183
+ console.log(pc3.dim(`Output: ${outputDir}`));
2184
+ try {
2185
+ await setupBoilerplate(boilerplate, outputDir);
2186
+ const inputExists = await fs10.pathExists(inputDir);
2187
+ if (!inputExists) {
2188
+ throw new Error(`Input directory not found: ${inputDir}`);
2189
+ }
2190
+ console.log(pc3.blue("\n\u{1F4C2} Scanning assets..."));
2191
+ const assets = await scanAssets(inputDir);
2192
+ console.log(pc3.green(` \u2713 Found ${assets.css.length} CSS files`));
2193
+ console.log(pc3.green(` \u2713 Found ${assets.images.length} images`));
2194
+ console.log(pc3.green(` \u2713 Found ${assets.fonts.length} fonts`));
2195
+ console.log(pc3.green(` \u2713 Found ${assets.js.length} JS files`));
2196
+ console.log(pc3.blue("\n\u{1F4E6} Copying assets..."));
2197
+ await copyAllAssets(inputDir, outputDir, assets);
2198
+ console.log(pc3.green(" \u2713 Assets copied successfully"));
2199
+ console.log(pc3.blue("\n\u{1F50D} Finding HTML files..."));
2200
+ const htmlFiles = await findHTMLFiles(inputDir);
2201
+ console.log(pc3.green(` \u2713 Found ${htmlFiles.length} HTML files`));
2202
+ const htmlContentMap = /* @__PURE__ */ new Map();
2203
+ for (const htmlFile of htmlFiles) {
2204
+ const html = await readHTMLFile(inputDir, htmlFile);
2205
+ const pageName = htmlFile.replace(".html", "").replace(/\//g, "-");
2206
+ htmlContentMap.set(pageName, html);
2207
+ console.log(pc3.dim(` Stored: ${pageName} from ${htmlFile}`));
2208
+ }
2209
+ console.log(pc3.blue("\n\u2699\uFE0F Converting HTML to Vue components..."));
2210
+ let allEmbeddedStyles = "";
2211
+ for (const htmlFile of htmlFiles) {
2212
+ const html = htmlContentMap.get(htmlFile.replace(".html", "").replace(/\//g, "-"));
2213
+ const parsed = parseHTML(html, htmlFile);
2214
+ if (parsed.embeddedStyles) {
2215
+ allEmbeddedStyles += `
2216
+ /* From ${htmlFile} */
2217
+ ${parsed.embeddedStyles}
2218
+ `;
2219
+ }
2220
+ const transformed = transformForNuxt(parsed.htmlContent);
2221
+ const pageName = htmlFile.replace(".html", "").replace(/\//g, "-");
2222
+ const vueComponent = htmlToVueComponent(transformed, pageName);
2223
+ await writeVueComponent(outputDir, htmlFile, vueComponent);
2224
+ console.log(pc3.green(` \u2713 Created ${htmlFile.replace(".html", ".vue")}`));
2225
+ }
2226
+ await formatVueFiles(outputDir);
2227
+ console.log(pc3.blue("\n\u{1F50D} Analyzing pages for CMS fields..."));
2228
+ const pagesDir = path12.join(outputDir, "pages");
2229
+ const manifest = await generateManifest(pagesDir);
2230
+ await writeManifest(outputDir, manifest);
2231
+ const totalFields = Object.values(manifest.pages).reduce(
2232
+ (sum, page) => sum + Object.keys(page.fields || {}).length,
2233
+ 0
2234
+ );
2235
+ const totalCollections = Object.values(manifest.pages).reduce(
2236
+ (sum, page) => sum + Object.keys(page.collections || {}).length,
2237
+ 0
2238
+ );
2239
+ console.log(pc3.green(` \u2713 Detected ${totalFields} fields across ${Object.keys(manifest.pages).length} pages`));
2240
+ console.log(pc3.green(` \u2713 Detected ${totalCollections} collections`));
2241
+ console.log(pc3.green(" \u2713 Generated cms-manifest.json"));
2242
+ console.log(pc3.blue("\n\u26A1 Transforming Vue files to reactive templates..."));
2243
+ await transformAllVuePages(pagesDir, manifest);
2244
+ console.log(pc3.green(` \u2713 Transformed ${Object.keys(manifest.pages).length} pages to use Vue template syntax`));
2245
+ console.log(pc3.blue("\n\u{1F4DD} Extracting content from HTML..."));
2246
+ console.log(pc3.dim(` HTML map has ${htmlContentMap.size} entries`));
2247
+ console.log(pc3.dim(` Manifest has ${Object.keys(manifest.pages).length} pages`));
2248
+ const extractedContent = extractAllContent(htmlContentMap, manifest);
2249
+ const seedData = formatForStrapi(extractedContent);
2250
+ await writeSeedData(outputDir, seedData);
2251
+ await createSeedReadme(outputDir);
2252
+ const pagesWithContent = Object.keys(seedData).filter((key) => {
2253
+ const data = seedData[key];
2254
+ if (Array.isArray(data)) return data.length > 0;
2255
+ return Object.keys(data).length > 0;
2256
+ }).length;
2257
+ console.log(pc3.green(` \u2713 Extracted content from ${pagesWithContent} pages`));
2258
+ console.log(pc3.green(` \u2713 Generated cms-seed/seed-data.json`));
2259
+ console.log(pc3.blue("\n\u{1F4CB} Generating Strapi schemas..."));
2260
+ const schemas = manifestToSchemas(manifest);
2261
+ await writeAllSchemas(outputDir, schemas);
2262
+ await createStrapiReadme(outputDir);
2263
+ console.log(pc3.green(` \u2713 Generated ${Object.keys(schemas).length} Strapi content types`));
2264
+ console.log(pc3.dim(" View schemas in: cms-schemas/"));
2265
+ if (allEmbeddedStyles.trim()) {
2266
+ console.log(pc3.blue("\n\u2728 Writing embedded styles..."));
2267
+ const dedupedStyles = deduplicateStyles(allEmbeddedStyles);
2268
+ await writeEmbeddedStyles(outputDir, dedupedStyles);
2269
+ console.log(pc3.green(" \u2713 Embedded styles added to main.css"));
2270
+ }
2271
+ console.log(pc3.blue("\n\u{1F527} Generating webflow-assets.ts plugin..."));
2272
+ await writeWebflowAssetPlugin(outputDir, assets.css);
2273
+ console.log(pc3.green(" \u2713 Plugin generated (existing file overwritten)"));
2274
+ console.log(pc3.blue("\n\u2699\uFE0F Updating nuxt.config.ts..."));
2275
+ try {
2276
+ await updateNuxtConfig(outputDir, assets.css);
2277
+ console.log(pc3.green(" \u2713 Config updated"));
2278
+ } catch (error) {
2279
+ console.log(pc3.yellow(" \u26A0 Could not update nuxt.config.ts automatically"));
2280
+ console.log(pc3.dim(" Please add CSS files manually"));
2281
+ }
2282
+ console.log(pc3.blue("\n\u{1F3A8} Setting up editor overlay..."));
2283
+ await createEditorPlugin(outputDir);
2284
+ await createEditorContentComposable(outputDir);
2285
+ await createStrapiContentComposable(outputDir);
2286
+ await addEditorDependency(outputDir);
2287
+ await createSaveEndpoint(outputDir);
2288
+ await createPublishEndpoint(outputDir);
2289
+ await createStrapiBootstrap(outputDir);
2290
+ await addStrapiUrlToConfig(outputDir);
2291
+ console.log(pc3.green(" \u2713 Editor plugin created"));
2292
+ console.log(pc3.green(" \u2713 Editor content composable created"));
2293
+ console.log(pc3.green(" \u2713 Strapi content composable created"));
2294
+ console.log(pc3.green(" \u2713 Editor dependency added"));
2295
+ console.log(pc3.green(" \u2713 Save endpoint created"));
2296
+ console.log(pc3.green(" \u2713 Publish endpoint created"));
2297
+ console.log(pc3.green(" \u2713 Strapi bootstrap file generated"));
2298
+ console.log(pc3.green(" \u2713 Strapi config added"));
2299
+ console.log(pc3.green("\n\u2705 Conversion completed successfully!"));
2300
+ console.log(pc3.cyan("\n\u{1F4CB} Next steps:"));
2301
+ console.log(pc3.dim(` 1. cd ${outputDir}`));
2302
+ console.log(pc3.dim(" 2. Review cms-manifest.json and cms-seed/seed-data.json"));
2303
+ console.log(pc3.dim(" 3. Set up Strapi and install schemas from cms-schemas/"));
2304
+ console.log(pc3.dim(" 4. Copy strapi-bootstrap/index.ts to your Strapi project at src/index.ts"));
2305
+ console.log(pc3.dim(" (This auto-enables public read permissions on Strapi startup)"));
2306
+ console.log(pc3.dim(" 5. Seed Strapi with data from cms-seed/"));
2307
+ console.log(pc3.dim(" 6. pnpm install && pnpm dev"));
2308
+ console.log(pc3.dim(" 7. Visit http://localhost:3000?preview=true to edit inline!"));
2309
+ } catch (error) {
2310
+ console.error(pc3.red("\n\u274C Conversion failed:"));
2311
+ console.error(pc3.red(error instanceof Error ? error.message : String(error)));
2312
+ throw error;
2313
+ }
2314
+ }
2315
+
2316
+ // src/generator.ts
2317
+ async function generateSchemas(_manifestPath, _cmsType) {
2318
+ throw new Error("Not yet implemented");
2319
+ }
2320
+ export {
2321
+ convertWebflowExport,
2322
+ detectEditableFields,
2323
+ generateManifest,
2324
+ generateSchemas,
2325
+ manifestToSchemas,
2326
+ readManifest,
2327
+ setupBoilerplate,
2328
+ transformAllVuePages
2329
+ };
2330
+ //# sourceMappingURL=index.mjs.map