diff --git a/apps/backend/apps/backend/reports/migration-2026-02-01.json b/apps/backend/apps/backend/reports/migration-2026-02-01.json new file mode 100644 index 0000000..7406732 --- /dev/null +++ b/apps/backend/apps/backend/reports/migration-2026-02-01.json @@ -0,0 +1,212 @@ +{ + "timestamp": "2026-02-01T07:36:51.596Z", + "dryRun": false, + "summary": { + "total": 37, + "created": 0, + "skipped": 0, + "failed": 37 + }, + "byCollection": { + "posts": { + "created": 0, + "skipped": 0, + "failed": 37 + } + }, + "details": { + "posts": { + "collection": "posts", + "created": 0, + "skipped": 0, + "failed": 37, + "results": [ + { + "slug": "2-zhao-yao-kong-xiao-fei-zhe-de-xin", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "2022-jie-qing-xing-xiao-quan-gong-lue", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "2022zuixin-google", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "2024googleshang", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "2025huan", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "2025xingxiaozhishi", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "bu-cang-si-da-gong-kai", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "bu-guo", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "da-sheng-ji", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "en-qun-shu-wei-x-google-xiao-xue-tang", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "en-qun-shu-wei-x-google-xiao-xue-tang-5", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "en-qun-shu-wei-x-lian-shu-xiao-xue-tang", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "en-qun-shu-wei-x-metaverse", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "en-qun-shu-wei-x-metaverse-bai-hua-wen", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "facebook-mjing-yin", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "facebookfen", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "faceookshequz", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "fu-ping-ye-shi-yi-zhong-shang-ji", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "genzhu", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "google-comment-delete", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "googlecomment", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "googlemybusiness-optimization", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "googleping", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "hu-nian-ji-xiang-hua-5", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "issue-with-gmb-verification", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "ni-de-huang-jin-di-duan-zu-chuan-mi-fang-jiu-bu-xu-yao-wang-lu-xing-xiao-liao-ma-a", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "ni-xin-ma-ni-xian-zai-kan-de-zhe-pian-wen-jia-zhi-2500-wan-tai-bi-5", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "optimize-gmb-for-local-seo", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "paiming", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "shang-jia-guan-jian-zi", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "shang-jia-jing-ying-mi-ji", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "shequnxingxiao", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "shitidianjia", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "storytelling", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "xi-jing-biao-ti-de-5-ge-jue-qiao-7", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "xiugai-google", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + }, + { + "slug": "zheng-que-de-hashtag-dai-ni-shang-tian-tang", + "success": false, + "error": "ValidationError: The following field is invalid: Content > Content" + } + ] + } + } +} \ No newline at end of file diff --git a/apps/backend/apps/backend/reports/migration-2026-02-01.md b/apps/backend/apps/backend/reports/migration-2026-02-01.md new file mode 100644 index 0000000..f334f24 --- /dev/null +++ b/apps/backend/apps/backend/reports/migration-2026-02-01.md @@ -0,0 +1,69 @@ +# Migration Report + +**Generated:** 2026/2/1 下午3:36:51 +**Mode:** ✅ Live Migration + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| Total Items | 37 | +| ✅ Created | 0 | +| ⏭️ Skipped | 0 | +| ❌ Failed | 37 | + +## By Collection + +### Posts + +| Metric | Count | +|--------|-------| +| Created | 0 | +| Skipped | 0 | +| Failed | 37 | + +## Details + +### Posts + +#### ❌ Failed (37) + +- `2-zhao-yao-kong-xiao-fei-zhe-de-xin`: ValidationError: The following field is invalid: Content > Content +- `2022-jie-qing-xing-xiao-quan-gong-lue`: ValidationError: The following field is invalid: Content > Content +- `2022zuixin-google`: ValidationError: The following field is invalid: Content > Content +- `2024googleshang`: ValidationError: The following field is invalid: Content > Content +- `2025huan`: ValidationError: The following field is invalid: Content > Content +- `2025xingxiaozhishi`: ValidationError: The following field is invalid: Content > Content +- `bu-cang-si-da-gong-kai`: ValidationError: The following field is invalid: Content > Content +- `bu-guo`: ValidationError: The following field is invalid: Content > Content +- `da-sheng-ji`: ValidationError: The following field is invalid: Content > Content +- `en-qun-shu-wei-x-google-xiao-xue-tang`: ValidationError: The following field is invalid: Content > Content +- `en-qun-shu-wei-x-google-xiao-xue-tang-5`: ValidationError: The following field is invalid: Content > Content +- `en-qun-shu-wei-x-lian-shu-xiao-xue-tang`: ValidationError: The following field is invalid: Content > Content +- `en-qun-shu-wei-x-metaverse`: ValidationError: The following field is invalid: Content > Content +- `en-qun-shu-wei-x-metaverse-bai-hua-wen`: ValidationError: The following field is invalid: Content > Content +- `facebook-mjing-yin`: ValidationError: The following field is invalid: Content > Content +- `facebookfen`: ValidationError: The following field is invalid: Content > Content +- `faceookshequz`: ValidationError: The following field is invalid: Content > Content +- `fu-ping-ye-shi-yi-zhong-shang-ji`: ValidationError: The following field is invalid: Content > Content +- `genzhu`: ValidationError: The following field is invalid: Content > Content +- `google-comment-delete`: ValidationError: The following field is invalid: Content > Content +- `googlecomment`: ValidationError: The following field is invalid: Content > Content +- `googlemybusiness-optimization`: ValidationError: The following field is invalid: Content > Content +- `googleping`: ValidationError: The following field is invalid: Content > Content +- `hu-nian-ji-xiang-hua-5`: ValidationError: The following field is invalid: Content > Content +- `issue-with-gmb-verification`: ValidationError: The following field is invalid: Content > Content +- `ni-de-huang-jin-di-duan-zu-chuan-mi-fang-jiu-bu-xu-yao-wang-lu-xing-xiao-liao-ma-a`: ValidationError: The following field is invalid: Content > Content +- `ni-xin-ma-ni-xian-zai-kan-de-zhe-pian-wen-jia-zhi-2500-wan-tai-bi-5`: ValidationError: The following field is invalid: Content > Content +- `optimize-gmb-for-local-seo`: ValidationError: The following field is invalid: Content > Content +- `paiming`: ValidationError: The following field is invalid: Content > Content +- `shang-jia-guan-jian-zi`: ValidationError: The following field is invalid: Content > Content +- `shang-jia-jing-ying-mi-ji`: ValidationError: The following field is invalid: Content > Content +- `shequnxingxiao`: ValidationError: The following field is invalid: Content > Content +- `shitidianjia`: ValidationError: The following field is invalid: Content > Content +- `storytelling`: ValidationError: The following field is invalid: Content > Content +- `xi-jing-biao-ti-de-5-ge-jue-qiao-7`: ValidationError: The following field is invalid: Content > Content +- `xiugai-google`: ValidationError: The following field is invalid: Content > Content +- `zheng-que-de-hashtag-dai-ni-shang-tian-tang`: ValidationError: The following field is invalid: Content > Content diff --git a/apps/backend/apps/backend/reports/migration-2026-02-05.json b/apps/backend/apps/backend/reports/migration-2026-02-05.json new file mode 100644 index 0000000..6ec0129 --- /dev/null +++ b/apps/backend/apps/backend/reports/migration-2026-02-05.json @@ -0,0 +1,212 @@ +{ + "timestamp": "2026-02-05T02:55:43.614Z", + "dryRun": false, + "summary": { + "total": 37, + "created": 37, + "skipped": 0, + "failed": 0 + }, + "byCollection": { + "posts": { + "created": 37, + "skipped": 0, + "failed": 0 + } + }, + "details": { + "posts": { + "collection": "posts", + "created": 37, + "skipped": 0, + "failed": 0, + "results": [ + { + "slug": "2-zhao-yao-kong-xiao-fei-zhe-de-xin", + "success": true, + "id": "698406d6b591b1d027f4ebce" + }, + { + "slug": "2022-jie-qing-xing-xiao-quan-gong-lue", + "success": true, + "id": "698406d6b591b1d027f4ebd4" + }, + { + "slug": "2022zuixin-google", + "success": true, + "id": "698406d7b591b1d027f4ebda" + }, + { + "slug": "2024googleshang", + "success": true, + "id": "698406d7b591b1d027f4ebe0" + }, + { + "slug": "2025huan", + "success": true, + "id": "698406d7b591b1d027f4ebe6" + }, + { + "slug": "2025xingxiaozhishi", + "success": true, + "id": "698406d8b591b1d027f4ebec" + }, + { + "slug": "bu-cang-si-da-gong-kai", + "success": true, + "id": "698406d8b591b1d027f4ebf2" + }, + { + "slug": "bu-guo", + "success": true, + "id": "698406d8b591b1d027f4ebf8" + }, + { + "slug": "da-sheng-ji", + "success": true, + "id": "698406d9b591b1d027f4ebfe" + }, + { + "slug": "en-qun-shu-wei-x-google-xiao-xue-tang", + "success": true, + "id": "698406d9b591b1d027f4ec04" + }, + { + "slug": "en-qun-shu-wei-x-google-xiao-xue-tang-5", + "success": true, + "id": "698406d9b591b1d027f4ec0a" + }, + { + "slug": "en-qun-shu-wei-x-lian-shu-xiao-xue-tang", + "success": true, + "id": "698406d9b591b1d027f4ec10" + }, + { + "slug": "en-qun-shu-wei-x-metaverse", + "success": true, + "id": "698406dab591b1d027f4ec16" + }, + { + "slug": "en-qun-shu-wei-x-metaverse-bai-hua-wen", + "success": true, + "id": "698406dab591b1d027f4ec1c" + }, + { + "slug": "facebook-mjing-yin", + "success": true, + "id": "698406dab591b1d027f4ec22" + }, + { + "slug": "facebookfen", + "success": true, + "id": "698406dbb591b1d027f4ec28" + }, + { + "slug": "faceookshequz", + "success": true, + "id": "698406dbb591b1d027f4ec2e" + }, + { + "slug": "fu-ping-ye-shi-yi-zhong-shang-ji", + "success": true, + "id": "698406dbb591b1d027f4ec34" + }, + { + "slug": "genzhu", + "success": true, + "id": "698406dcb591b1d027f4ec3a" + }, + { + "slug": "google-comment-delete", + "success": true, + "id": "698406dcb591b1d027f4ec40" + }, + { + "slug": "googlecomment", + "success": true, + "id": "698406dcb591b1d027f4ec46" + }, + { + "slug": "googlemybusiness-optimization", + "success": true, + "id": "698406ddb591b1d027f4ec4c" + }, + { + "slug": "googleping", + "success": true, + "id": "698406ddb591b1d027f4ec52" + }, + { + "slug": "hu-nian-ji-xiang-hua-5", + "success": true, + "id": "698406ddb591b1d027f4ec58" + }, + { + "slug": "issue-with-gmb-verification", + "success": true, + "id": "698406ddb591b1d027f4ec5e" + }, + { + "slug": "ni-de-huang-jin-di-duan-zu-chuan-mi-fang-jiu-bu-xu-yao-wang-lu-xing-xiao-liao-ma-a", + "success": true, + "id": "698406deb591b1d027f4ec64" + }, + { + "slug": "ni-xin-ma-ni-xian-zai-kan-de-zhe-pian-wen-jia-zhi-2500-wan-tai-bi-5", + "success": true, + "id": "698406deb591b1d027f4ec6a" + }, + { + "slug": "optimize-gmb-for-local-seo", + "success": true, + "id": "698406deb591b1d027f4ec70" + }, + { + "slug": "paiming", + "success": true, + "id": "698406dfb591b1d027f4ec76" + }, + { + "slug": "shang-jia-guan-jian-zi", + "success": true, + "id": "698406dfb591b1d027f4ec7c" + }, + { + "slug": "shang-jia-jing-ying-mi-ji", + "success": true, + "id": "698406dfb591b1d027f4ec82" + }, + { + "slug": "shequnxingxiao", + "success": true, + "id": "698406e0b591b1d027f4ec88" + }, + { + "slug": "shitidianjia", + "success": true, + "id": "698406e0b591b1d027f4ec8e" + }, + { + "slug": "storytelling", + "success": true, + "id": "698406e0b591b1d027f4ec94" + }, + { + "slug": "xi-jing-biao-ti-de-5-ge-jue-qiao-7", + "success": true, + "id": "698406e0b591b1d027f4ec9a" + }, + { + "slug": "xiugai-google", + "success": true, + "id": "698406e1b591b1d027f4eca0" + }, + { + "slug": "zheng-que-de-hashtag-dai-ni-shang-tian-tang", + "success": true, + "id": "698406e1b591b1d027f4eca6" + } + ] + } + } +} \ No newline at end of file diff --git a/apps/backend/apps/backend/reports/migration-2026-02-05.md b/apps/backend/apps/backend/reports/migration-2026-02-05.md new file mode 100644 index 0000000..efbb7d3 --- /dev/null +++ b/apps/backend/apps/backend/reports/migration-2026-02-05.md @@ -0,0 +1,69 @@ +# Migration Report + +**Generated:** 2026/2/5 上午10:55:43 +**Mode:** ✅ Live Migration + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| Total Items | 37 | +| ✅ Created | 37 | +| ⏭️ Skipped | 0 | +| ❌ Failed | 0 | + +## By Collection + +### Posts + +| Metric | Count | +|--------|-------| +| Created | 37 | +| Skipped | 0 | +| Failed | 0 | + +## Details + +### Posts + +#### ✅ Created (37) + +- `2-zhao-yao-kong-xiao-fei-zhe-de-xin` (ID: 698406d6b591b1d027f4ebce) +- `2022-jie-qing-xing-xiao-quan-gong-lue` (ID: 698406d6b591b1d027f4ebd4) +- `2022zuixin-google` (ID: 698406d7b591b1d027f4ebda) +- `2024googleshang` (ID: 698406d7b591b1d027f4ebe0) +- `2025huan` (ID: 698406d7b591b1d027f4ebe6) +- `2025xingxiaozhishi` (ID: 698406d8b591b1d027f4ebec) +- `bu-cang-si-da-gong-kai` (ID: 698406d8b591b1d027f4ebf2) +- `bu-guo` (ID: 698406d8b591b1d027f4ebf8) +- `da-sheng-ji` (ID: 698406d9b591b1d027f4ebfe) +- `en-qun-shu-wei-x-google-xiao-xue-tang` (ID: 698406d9b591b1d027f4ec04) +- `en-qun-shu-wei-x-google-xiao-xue-tang-5` (ID: 698406d9b591b1d027f4ec0a) +- `en-qun-shu-wei-x-lian-shu-xiao-xue-tang` (ID: 698406d9b591b1d027f4ec10) +- `en-qun-shu-wei-x-metaverse` (ID: 698406dab591b1d027f4ec16) +- `en-qun-shu-wei-x-metaverse-bai-hua-wen` (ID: 698406dab591b1d027f4ec1c) +- `facebook-mjing-yin` (ID: 698406dab591b1d027f4ec22) +- `facebookfen` (ID: 698406dbb591b1d027f4ec28) +- `faceookshequz` (ID: 698406dbb591b1d027f4ec2e) +- `fu-ping-ye-shi-yi-zhong-shang-ji` (ID: 698406dbb591b1d027f4ec34) +- `genzhu` (ID: 698406dcb591b1d027f4ec3a) +- `google-comment-delete` (ID: 698406dcb591b1d027f4ec40) +- `googlecomment` (ID: 698406dcb591b1d027f4ec46) +- `googlemybusiness-optimization` (ID: 698406ddb591b1d027f4ec4c) +- `googleping` (ID: 698406ddb591b1d027f4ec52) +- `hu-nian-ji-xiang-hua-5` (ID: 698406ddb591b1d027f4ec58) +- `issue-with-gmb-verification` (ID: 698406ddb591b1d027f4ec5e) +- `ni-de-huang-jin-di-duan-zu-chuan-mi-fang-jiu-bu-xu-yao-wang-lu-xing-xiao-liao-ma-a` (ID: 698406deb591b1d027f4ec64) +- `ni-xin-ma-ni-xian-zai-kan-de-zhe-pian-wen-jia-zhi-2500-wan-tai-bi-5` (ID: 698406deb591b1d027f4ec6a) +- `optimize-gmb-for-local-seo` (ID: 698406deb591b1d027f4ec70) +- `paiming` (ID: 698406dfb591b1d027f4ec76) +- `shang-jia-guan-jian-zi` (ID: 698406dfb591b1d027f4ec7c) +- `shang-jia-jing-ying-mi-ji` (ID: 698406dfb591b1d027f4ec82) +- `shequnxingxiao` (ID: 698406e0b591b1d027f4ec88) +- `shitidianjia` (ID: 698406e0b591b1d027f4ec8e) +- `storytelling` (ID: 698406e0b591b1d027f4ec94) +- `xi-jing-biao-ti-de-5-ge-jue-qiao-7` (ID: 698406e0b591b1d027f4ec9a) +- `xiugai-google` (ID: 698406e1b591b1d027f4eca0) +- `zheng-que-de-hashtag-dai-ni-shang-tian-tang` (ID: 698406e1b591b1d027f4eca6) diff --git a/apps/backend/clean-package.js b/apps/backend/clean-package.js new file mode 100644 index 0000000..256ae83 --- /dev/null +++ b/apps/backend/clean-package.js @@ -0,0 +1,9 @@ +const fs = require('fs'); +const p = JSON.parse(fs.readFileSync('./package.json.orig', 'utf8')); +delete p.devDependencies; +delete p.engines; +if (p.dependencies && p.dependencies['@enchun/shared']) { + p.dependencies['@enchun/shared'] = 'file:./shared'; +} +fs.writeFileSync('./package.json', JSON.stringify(p, null, 2)); +fs.unlinkSync('./package.json.orig'); diff --git a/apps/backend/data/webflow-export-sample.json b/apps/backend/data/webflow-export-sample.json new file mode 100644 index 0000000..4f9f62a --- /dev/null +++ b/apps/backend/data/webflow-export-sample.json @@ -0,0 +1,50 @@ +{ + "_comment": "Sample Webflow export data for testing migration script", + "_instructions": "Copy this file to webflow-export.json and fill in your actual data from Webflow", + "categories": [ + { + "name": "Google小學堂", + "slug": "google-workshop", + "colorHex": "#4285f4" + }, + { + "name": "Meta小學堂", + "slug": "meta-workshop", + "colorHex": "#0668e1" + }, + { + "name": "行銷時事最前線", + "slug": "marketing-news", + "colorHex": "#34a853" + }, + { + "name": "恩群數位最新公告", + "slug": "enchun-announcements", + "colorHex": "#ea4335" + } + ], + "posts": [ + { + "title": "示例文章標題", + "slug": "sample-post", + "content": "
這是文章內容...
", + "publishedDate": "2024-01-15T10:00:00Z", + "postCategory": "google-workshop", + "featuredImage": "https://example.com/image.jpg", + "seoTitle": "SEO 標題", + "seoDescription": "SEO 描述", + "excerpt": "文章摘要..." + } + ], + "portfolio": [ + { + "name": "示例網站名稱", + "slug": "sample-portfolio", + "websiteLink": "https://example.com", + "previewImage": "https://example.com/preview.jpg", + "description": "專案描述...", + "websiteType": "corporate", + "tags": "電商, SEO, 網站設計" + } + ] +} diff --git a/apps/backend/next.config.js b/apps/backend/next.config.js index 0cb8d12..73b58a2 100644 --- a/apps/backend/next.config.js +++ b/apps/backend/next.config.js @@ -1,23 +1,40 @@ import { withPayload } from '@payloadcms/next/withPayload' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' import redirects from './redirects.js' +const __dirname = dirname(fileURLToPath(import.meta.url)) + const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` : undefined || process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000' /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', + // Required for monorepo: trace dependencies from the monorepo root + outputFileTracingRoot: join(__dirname, '../../'), + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, images: { remotePatterns: [ - ...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => { - const url = new URL(item) + ...[NEXT_PUBLIC_SERVER_URL, process.env.NEXT_PUBLIC_SERVER_URL] + .filter(Boolean) + .map((item) => { + const urlString = item.startsWith('http') ? item : `https://${item}` - return { - hostname: url.hostname, - protocol: url.protocol.replace(':', ''), - } - }), + try { + const url = new URL(urlString) + return { + hostname: url.hostname, + protocol: url.protocol.replace(':', ''), + } + } catch (_) { + return null + } + }) + .filter(Boolean), ], }, webpack: (webpackConfig) => { diff --git a/apps/backend/package.json b/apps/backend/package.json index 8099b7c..33ce8c3 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "cross-env NODE_OPTIONS=--no-deprecation next build", "postbuild": "next-sitemap --config next-sitemap.config.cjs", - "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", + "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --port 3000", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", @@ -25,7 +25,10 @@ "test:load:all": "k6 run tests/k6/public-browsing.js && k6 run tests/k6/api-performance.js", "test:load:admin": "k6 run tests/k6/admin-operations.js", "test:load:api": "k6 run tests/k6/api-performance.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "migrate": "tsx scripts/migration/migrate.ts", + "migrate:dry": "tsx scripts/migration/migrate.ts --dry-run --verbose", + "migrate:posts": "tsx scripts/migration/migrate.ts --collection posts" }, "dependencies": { "@enchun/shared": "workspace:*", @@ -74,15 +77,19 @@ "@types/react-dom": "19.1.6", "@vitejs/plugin-react": "4.5.2", "autoprefixer": "^10.4.19", + "cheerio": "^1.2.0", "copyfiles": "^2.4.1", + "csv-parse": "^6.1.0", "eslint": "^9.16.0", "eslint-config-next": "15.4.4", + "html-parse-stringify": "^3.0.1", "jsdom": "26.1.0", "playwright": "1.54.1", "playwright-core": "1.54.1", "postcss": "^8.4.38", "prettier": "^3.4.2", "tailwindcss": "^3.4.3", + "tsx": "^4.21.0", "typescript": "5.7.3", "vite-tsconfig-paths": "5.1.4", "vitest": "3.2.3" diff --git a/apps/backend/pnpm-lock.yaml b/apps/backend/pnpm-lock.yaml index 342ec79..5a24ec1 100644 --- a/apps/backend/pnpm-lock.yaml +++ b/apps/backend/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: eslint-config-next: specifier: 15.4.4 version: 15.4.4(eslint@9.37.0(jiti@1.21.7))(typescript@5.7.3) + html-parse-stringify: + specifier: ^3.0.1 + version: 3.0.1 jsdom: specifier: 26.1.0 version: 26.1.0 @@ -3387,6 +3390,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -5055,6 +5061,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -9065,6 +9075,10 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -11057,6 +11071,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/apps/backend/scripts/migration/README.md b/apps/backend/scripts/migration/README.md new file mode 100644 index 0000000..3ddbfce --- /dev/null +++ b/apps/backend/scripts/migration/README.md @@ -0,0 +1,190 @@ +# Webflow to Payload CMS Migration Script + +Story 1.3: Content Migration Script + +## Overview + +This script migrates content from Webflow CMS to Payload CMS. It supports: +- **JSON export** - If you have Webflow JSON export files +- **HTML parsing** - If you only have access to the public website HTML +- **Manual entry** - You can manually edit the JSON data file + +## Prerequisites + +1. **MongoDB must be running** - The script connects to Payload CMS which requires MongoDB +2. **Environment variables** - Ensure `.env` file has PAYLOAD_SECRET and DATABASE_URI +3. **Source data** - Prepare your webflow-export.json file + +## Quick Start + +```bash +# Navigate to backend directory +cd apps/backend + +# Ensure MongoDB is running (if using local) +# Or the Payload CMS dev server: +pnpm dev + +# In another terminal, run dry-run (preview mode, no changes) +pnpm migrate:dry + +# Run actual migration +pnpm migrate + +# Migrate specific collection +pnpm migrate:posts + +# Show help +tsx scripts/migration/migrate.ts --help +``` + +## Environment Setup + +The script loads environment variables from: +- `.env` (project root) +- `.env.enchun-cms-v2` (project root) +- `apps/backend/.env` + +Required variables: +```bash +PAYLOAD_SECRET=your-secret-key +DATABASE_URI=mongodb://localhost:27017/your-db +R2_ACCOUNT_ID=your-r2-account +R2_ACCESS_KEY_ID=your-access-key +R2_SECRET_ACCESS_KEY=your-secret-key +R2_BUCKET=your-bucket-name +``` + +## CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--dry-run` | `-n` | Run without making changes (preview mode) | +| `--verbose` | `-v` | Show detailed logging output | +| `--force` | `-f` | Overwrite existing items (skip deduplication) | +| `--collectionHTML content...
", + "publishedDate": "2024-01-15T10:00:00Z", + "postCategory": "category-slug", + "featuredImage": "https://example.com/image.jpg", + "seoTitle": "SEO Title", + "seoDescription": "SEO Description", + "excerpt": "Article excerpt..." + } + ], + "portfolio": [ + { + "name": "作品名稱", + "slug": "portfolio-slug", + "websiteLink": "https://example.com", + "previewImage": "https://example.com/preview.jpg", + "description": "作品描述", + "websiteType": "corporate", + "tags": "tag1, tag2, tag3" + } + ] +} +``` + +## Field Mappings + +### Categories +| Webflow Field | Payload Field | +|---------------|---------------| +| name | title | +| slug | slug (preserved) | +| color-hex | textColor + backgroundColor | + +### Posts +| Webflow Field | Payload Field | +|---------------|---------------| +| title | title | +| slug | slug (preserved for SEO) | +| body | content (HTML → Lexical) | +| published-date | publishedAt | +| post-category | categories (relationship) | +| featured-image | heroImage (R2 upload) | +| seo-title | meta.title | +| seo-description | meta.description | + +### Portfolio +| Webflow Field | Payload Field | +|---------------|---------------| +| Name | title | +| Slug | slug | +| website-link | url | +| preview-image | image (R2 upload) | +| description | description | +| website-type | websiteType | +| tags | tags (array) | + +## Migration Order + +1. **Categories** (first - no dependencies) +2. **Media** images (independent) +3. **Posts** (depends on Categories and Media) +4. **Portfolio** (depends on Media) + +## Reports + +After each migration, a report is generated in `apps/backend/reports/`: +- `migration-YYYY-MM-DD.json` - Machine-readable JSON +- `migration-YYYY-MM-DD.md` - Human-readable Markdown + +## Troubleshooting + +### Script fails to connect to Payload CMS +Ensure the Payload CMS server is running: +```bash +cd apps/backend +pnpm dev +``` + +### Images not uploading +Check environment variables in `.env`: +- `R2_ACCOUNT_ID` +- `R2_ACCESS_KEY_ID` +- `R2_SECRET_ACCESS_KEY` +- `R2_BUCKET_NAME` + +### Duplicate entries +By default, the script skips existing items. Use `--force` to overwrite: +```bash +pnpm migrate --force +``` + +## Module Structure + +``` +scripts/migration/ +├── migrate.ts # Main entry point +├── types.ts # TypeScript interfaces +├── utils.ts # Helper functions +├── transformers.ts # Data transformation +├── mediaHandler.ts # Image download/upload +├── deduplicator.ts # Duplicate checking +├── reporter.ts # Report generation +├── htmlParser.ts # HTML parsing (no JSON) +└── README.md # This file +``` diff --git a/apps/backend/scripts/migration/analyze-failures.ts b/apps/backend/scripts/migration/analyze-failures.ts new file mode 100644 index 0000000..0ddb57e --- /dev/null +++ b/apps/backend/scripts/migration/analyze-failures.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env tsx +import { config as dotenvConfig } from 'dotenv' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const envPath = resolve(__dirname, '../../.env') + +dotenvConfig({ path: envPath }) + +import { parseWebflowCSV } from './csvParser' +import { htmlToLexical } from './lexicalConverter' + +async function main() { + const data = await parseWebflowCSV('/Users/pukpuk/Dev/website-enchun-mgr/恩群數位行銷 - 行銷放大鏡集.csv') + + const successPost = data.posts.find((p: any) => p.title === '正確的 hashtag 帶你上天堂') + const failPost = data.posts.find((p: any) => p.title.includes('一點都不難')) + + console.log('=== SUCCESSFUL POST ===') + console.log('Title:', successPost.title) + console.log('HTML content length:', successPost.content?.length) + + const successLexical = htmlToLexical(successPost.content || '') + console.log('Lexical JSON length:', successLexical.length) + const successParsed = JSON.parse(successLexical) + console.log('Lexical children count:', successParsed.root?.children?.length) + + console.log('\n=== FAILED POST ===') + console.log('Title:', failPost.title) + console.log('HTML content length:', failPost.content?.length) + + const failLexical = htmlToLexical(failPost.content || '') + console.log('Lexical JSON length:', failLexical.length) + const failParsed = JSON.parse(failLexical) + console.log('Lexical children count:', failParsed.root?.children?.length) + + // Check for special characters in HTML + console.log('\n=== CHARACTER CHECK ===') + const specialChars = /["\n\r\t]/ + const failMatches = (failPost.content?.match(specialChars) || []).length + const successMatches = (successPost.content?.match(specialChars) || []).length + console.log('Special chars in fail post:', failMatches) + console.log('Special chars in success post:', successMatches) + + // Look for empty text nodes + let emptyTextCount = 0 + failParsed.root?.children?.forEach((child: any) => { + child.children?.forEach((grandchild: any) => { + if (grandchild.type === 'text' && grandchild.text === '') { + emptyTextCount++ + } + }) + }) + console.log('Empty text nodes in fail post:', emptyTextCount) +} + +main().catch(console.error) diff --git a/apps/backend/scripts/migration/analyze-post.ts b/apps/backend/scripts/migration/analyze-post.ts new file mode 100644 index 0000000..6abb36b --- /dev/null +++ b/apps/backend/scripts/migration/analyze-post.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env tsx +/** + * Analyze Post Data Structure + * Compares migrated posts vs manually created posts + */ + +import { config as dotenvConfig } from 'dotenv' +dotenvConfig({ path: '.env' }) + +import { getPayload } from 'payload' +import config from '../../src/payload.config' + +async function main() { + const payload = await getPayload({ config }) + + console.log('🔍 Fetching posts for analysis...\n') + + const posts = await payload.find({ + collection: 'posts', + limit: 5, + depth: 0, + }) + + if (posts.docs.length === 0) { + console.log('No posts found') + return + } + + // Analyze first post in detail + const post = posts.docs[0] + + console.log('═══════════════════════════════════════════════════════════') + console.log(`POST: "${post.title}"`) + console.log('═══════════════════════════════════════════════════════════\n') + + // Basic info + console.log('📋 BASIC INFO:') + console.log(` ID: ${post.id}`) + console.log(` Slug: ${post.slug}`) + console.log(` Status: ${post.status}`) + console.log(` Created: ${post.createdAt}`) + + // Content analysis + console.log('\n📝 CONTENT FIELD:') + console.log(` Type: ${typeof post.content}`) + console.log(` Is String: ${typeof post.content === 'string'}`) + console.log(` Is Object: ${typeof post.content === 'object'}`) + + if (typeof post.content === 'string') { + console.log(` String Length: ${post.content.length} chars`) + + try { + const parsed = JSON.parse(post.content) + console.log(` Parsed Type: ${parsed?.type}`) + console.log(` Parsed Version: ${parsed?.version}`) + console.log(` Children Count: ${parsed?.children?.length}`) + + // Show first child structure + if (parsed?.children?.[0]) { + console.log('\n First Child:') + const firstChild = parsed.children[0] + console.log(` Type: ${firstChild.type}`) + console.log(` Version: ${firstChild.version}`) + if (firstChild.children) { + console.log(` Has Children: true (${firstChild.children.length})`) + if (firstChild.children[0]) { + console.log(` First Grandchild: ${JSON.stringify(firstChild.children[0], null, 2).split('\n').join('\n ')}`) + } + } + } + + // Show full structure + console.log('\n FULL LEXICAL STRUCTURE:') + console.log(' ' + JSON.stringify(parsed, null, 2).split('\n').join('\n ')) + } catch (e) { + console.log(` Parse Error: ${e}`) + console.log(` Raw Content (first 500 chars): ${post.content.substring(0, 500)}...`) + } + } else if (typeof post.content === 'object') { + console.log(' OBJECT STRUCTURE:') + console.log(' ' + JSON.stringify(post.content, null, 2).split('\n').join('\n ')) + } + + // Other fields + console.log('\n🏷️ OTHER FIELDS:') + console.log(` Excerpt: ${post.excerpt?.substring(0, 100) || 'none'}...`) + console.log(` PublishedAt: ${post.publishedAt}`) + console.log(` Categories: ${post.categories?.length || 0} items`) + + if (post.heroImage) { + console.log(` HeroImage: ${typeof post.heroImage} = ${post.heroImage}`) + } + + console.log('\n═══════════════════════════════════════════════════════════') +} + +main().catch(console.error) diff --git a/apps/backend/scripts/migration/check-links.ts b/apps/backend/scripts/migration/check-links.ts new file mode 100644 index 0000000..39a5f70 --- /dev/null +++ b/apps/backend/scripts/migration/check-links.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env tsx +import { config as dotenvConfig } from 'dotenv' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const envPath = resolve(__dirname, '../../.env') + +dotenvConfig({ path: envPath }) + +import { parseWebflowCSV } from './csvParser' +import { htmlToLexical } from './lexicalConverter' + +async function main() { + const data = await parseWebflowCSV('/Users/pukpuk/Dev/website-enchun-mgr/恩群數位行銷 - 行銷放大鏡集.csv') + const post = data.posts.find((p: any) => p.title.includes('掌握故事行銷')) + + const lexical = htmlToLexical(post.content || '') + const parsed = JSON.parse(lexical) + + console.log('All link URLs:') + const findLinks = (nodes: any[], depth = 0) => { + if (depth > 10) return + nodes.forEach((node: any, i: number) => { + if (node.type === 'link') { + const url = node.url + const isValid = url && url !== '#' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) + console.log(` [${depth}.${i}] ${url} - Valid: ${isValid}`) + } + if (node.children) { + findLinks(node.children, depth + 1) + } + }) + } + findLinks(parsed.root?.children || []) + + // Check raw HTML for links + console.log('\nRaw HTML links:') + const linkRegex = /]+href=["']([^"']+)["'][^>]*>/gi + let match + const html = post.content || '' + while ((match = linkRegex.exec(html)) !== null) { + console.log(' ', match[1]) + } +} + +main().catch(console.error) diff --git a/apps/backend/scripts/migration/compare-posts.ts b/apps/backend/scripts/migration/compare-posts.ts new file mode 100644 index 0000000..0936caf --- /dev/null +++ b/apps/backend/scripts/migration/compare-posts.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env tsx +import { config as dotenvConfig } from 'dotenv' +dotenvConfig({ path: '.env' }) + +import { parseWebflowCSV } from './csvParser' +import { htmlToLexical } from './lexicalConverter' + +async function main() { + const data = await parseWebflowCSV('/Users/pukpuk/Dev/website-enchun-mgr/恩群數位行銷 - 行銷放大鏡集.csv') + + const successPost = data.posts.find((p: any) => p.title === '正確的 hashtag 帶你上天堂') + const failPost = data.posts.find((p: any) => p.title.includes('一點都不難')) + + console.log('=== SUCCESSFUL POST ===') + console.log('Title:', successPost.title) + console.log('Content length:', successPost.content?.length) + + const successLexical = htmlToLexical(successPost.content || '') + const successParsed = JSON.parse(successLexical) + console.log('Has root:', successParsed.root !== undefined) + console.log('Root type:', successParsed.root?.type) + console.log('Root children count:', successParsed.root?.children?.length) + + console.log('\n=== FAILED POST ===') + console.log('Title:', failPost.title) + console.log('Content length:', failPost.content?.length) + + const failLexical = htmlToLexical(failPost.content || '') + const failParsed = JSON.parse(failLexical) + console.log('Has root:', failParsed.root !== undefined) + console.log('Root type:', failParsed.root?.type) + console.log('Root children count:', failParsed.root?.children?.length) + + // Check for any differences in structure + console.log('\n=== STRUCTURE COMPARISON ===') + console.log('Success first child type:', successParsed.root?.children?.[0]?.type) + console.log('Fail first child type:', failParsed.root?.children?.[0]?.type) +} + +main().catch(console.error) diff --git a/apps/backend/scripts/migration/csvParser.ts b/apps/backend/scripts/migration/csvParser.ts new file mode 100644 index 0000000..e9a38e8 --- /dev/null +++ b/apps/backend/scripts/migration/csvParser.ts @@ -0,0 +1,307 @@ +/** + * CSV Parser for Webflow Exports + * Story 1.3: Content Migration Script + * + * Parses Webflow CSV export files and converts to WebflowExportData format + */ + +import type { WebflowExportData, WebflowPost, WebflowCategory } from './types' +import { readFile } from 'fs/promises' +import { parse } from 'csv-parse/sync' + +// ============================================================ +// CSV ROW INTERFACES +// ============================================================ + +interface WebflowPostCsvRow { + '文章標題': string + 'Slug': string + 'Collection ID': string + 'Item ID': string + 'Archived': string + 'Draft': string + 'Created On': string + 'Updated On': string + 'Published On': string + '強調圖片': string + 'Open Graph 顯示圖片': string + '文章簡述': string + '發文日期': string + '文章分類': string + '發文內容': string + '是否放在頁尾': string +} + +interface WebflowCategoryCsvRow { + name: string + slug: string + [key: string]: string +} + +interface WebflowPortfolioCsvRow { + Name: string + Slug: string + 'website-link': string + 'preview-image': string + description: string + 'website-type': string + tags: string + [key: string]: string +} + +// ============================================================ +// MAIN CSV PARSER +// ============================================================ + +/** + * Parse Webflow CSV file and convert to WebflowExportData + */ +export async function parseWebflowCSV(filePath: string): Promise