From f51b91f16e8169f420f63b05bb675a876d6d036f Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 16:30:52 +0100 Subject: [PATCH 1/7] Add Wix migrate tool --- packages/mg-wix-csv/.eslintrc.cjs | 15 ++ packages/mg-wix-csv/.gitignore | 4 + packages/mg-wix-csv/README.md | 51 ++++ packages/mg-wix-csv/package.json | 49 ++++ packages/mg-wix-csv/src/index.ts | 10 + packages/mg-wix-csv/src/lib/mapper.ts | 215 +++++++++++++++ packages/mg-wix-csv/src/lib/rich-content.ts | 249 ++++++++++++++++++ packages/mg-wix-csv/src/lib/wix-image.ts | 63 +++++ .../mg-wix-csv/src/test/fixtures/empty.csv | 1 + .../mg-wix-csv/src/test/fixtures/posts.csv | 3 + packages/mg-wix-csv/src/test/mapper.test.ts | 170 ++++++++++++ .../mg-wix-csv/src/test/rich-content.test.ts | 113 ++++++++ .../mg-wix-csv/src/test/wix-image.test.ts | 41 +++ packages/mg-wix-csv/src/types.d.ts | 64 +++++ packages/mg-wix-csv/tsconfig.json | 18 ++ packages/migrate/bin/cli.js | 2 + packages/migrate/commands/wix-csv.js | 144 ++++++++++ packages/migrate/package.json | 1 + packages/migrate/sources/wix-csv.js | 171 ++++++++++++ packages/migrate/test/wix-csv-command.test.js | 41 +++ pnpm-lock.yaml | 37 +++ 21 files changed, 1462 insertions(+) create mode 100644 packages/mg-wix-csv/.eslintrc.cjs create mode 100644 packages/mg-wix-csv/.gitignore create mode 100644 packages/mg-wix-csv/README.md create mode 100644 packages/mg-wix-csv/package.json create mode 100644 packages/mg-wix-csv/src/index.ts create mode 100644 packages/mg-wix-csv/src/lib/mapper.ts create mode 100644 packages/mg-wix-csv/src/lib/rich-content.ts create mode 100644 packages/mg-wix-csv/src/lib/wix-image.ts create mode 100644 packages/mg-wix-csv/src/test/fixtures/empty.csv create mode 100644 packages/mg-wix-csv/src/test/fixtures/posts.csv create mode 100644 packages/mg-wix-csv/src/test/mapper.test.ts create mode 100644 packages/mg-wix-csv/src/test/rich-content.test.ts create mode 100644 packages/mg-wix-csv/src/test/wix-image.test.ts create mode 100644 packages/mg-wix-csv/src/types.d.ts create mode 100644 packages/mg-wix-csv/tsconfig.json create mode 100644 packages/migrate/commands/wix-csv.js create mode 100644 packages/migrate/sources/wix-csv.js create mode 100644 packages/migrate/test/wix-csv-command.test.js diff --git a/packages/mg-wix-csv/.eslintrc.cjs b/packages/mg-wix-csv/.eslintrc.cjs new file mode 100644 index 000000000..bdd8bafc8 --- /dev/null +++ b/packages/mg-wix-csv/.eslintrc.cjs @@ -0,0 +1,15 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ], + rules: { + 'no-unused-vars': 'off', + 'no-undef': 'off', + 'ghost/ghost-custom/no-native-errors': 'off', + 'ghost/ghost-custom/no-native-error': 'off', + 'ghost/ghost-custom/ghost-error-usage': 'off', + 'ghost/filenames/match-regex': 'off' + } +}; diff --git a/packages/mg-wix-csv/.gitignore b/packages/mg-wix-csv/.gitignore new file mode 100644 index 000000000..bd7611769 --- /dev/null +++ b/packages/mg-wix-csv/.gitignore @@ -0,0 +1,4 @@ +/build +/tsconfig.tsbuildinfo +/tmp +.env diff --git a/packages/mg-wix-csv/README.md b/packages/mg-wix-csv/README.md new file mode 100644 index 000000000..0606841b4 --- /dev/null +++ b/packages/mg-wix-csv/README.md @@ -0,0 +1,51 @@ +# Wix CSV Migration + +Migrate Wix blog posts from a posts CSV export into a Ghost import file. + +## Usage + +```bash +migrate wix-csv --posts /path/to/posts.csv --url https://example.com +``` + +By default, the command reads posts, converts Wix rich content to Ghost-compatible HTML/Lexical, downloads Wix-hosted assets, and writes a Ghost import zip. + +## Options + +| Option | Type | Default | Description | +|-------------------------|---------|----------|---------------------------------------------------------------------------------------------------------------------| +| `--posts` | string | `null` | Path to the Wix posts CSV file. Required. | +| `--url` | string | `null` | URL to the live Wix site, used for source URLs and link fixing. Required. | +| `--defaultAuthorName` | string | `null` | Fallback author name when the CSV row has no author. | +| `--scrape` | array | `assets` | Asset scraping mode. Use `assets`, `img`, `media`, or `files` to download assets; use `none` to skip. | +| `--includeMainCategory` | boolean | `true` | Include the `Main Category` column as Ghost tags. Note: option name intentionally matches the current CLI spelling. | +| `--includeCategories` | boolean | `true` | Include the `Categories` column as Ghost tags. Supports plain category names and legacy JSON arrays. | +| `--includeTags` | boolean | `true` | Include the `Tags` column as Ghost tags. | +| `--tmpPath` | string | `null` | Full path for temporary migration files. Defaults to the migrator cache location. | +| `--outputPath` | string | `null` | Full path where the final zip should be saved. Defaults to the current working directory. | +| `--cacheName` | string | `null` | Custom cache name. Defaults to a name derived from the site URL. | +| `--cache` | boolean | `true` | Keep the local cache after migration completes. Only applies when `--zip` is enabled. | +| `--zip` | boolean | `true` | Create a Ghost import zip. Set to `false` to write only the import JSON/cache output. | +| `-V`, `--verbose` | boolean | `false` | Show verbose output. Defaults to `true` when `DEBUG` is set. | +| `--veryVerbose` | boolean | `false` | Show very verbose output. Implies `--verbose`. | +| `--ghostApiUrl` | string | `null` | Ghost site URL used to fetch existing users for author matching. | +| `--ghostAdminKey` | string | `null` | Ghost Admin API key used with `--ghostApiUrl`. | + +## Taxonomy + +The migrator always adds the internal source tag `#wix`. + +CSV taxonomy fields are controlled independently: + +```bash +migrate wix-csv --posts /path/to/posts.csv --url https://example.com --includeMainCategory false --includeCategories true --includeTags false +``` + +For updated Wix CSV exports, `Categories` may contain readable names such as `Tax Planning`. Older exports may contain JSON arrays of IDs. The migrator accepts both formats. + +## Content Notes + +- `Rich Content` is used as the source of post body HTML. +- `Plain Content` is used as fallback content when `Rich Content` is empty or invalid. +- `Internal ID` is mapped to Ghost `comment_id`. +- Wix image URLs are converted to original static media URLs, for example `https://static.wixstatic.com/media/`. diff --git a/packages/mg-wix-csv/package.json b/packages/mg-wix-csv/package.json new file mode 100644 index 000000000..8a563f583 --- /dev/null +++ b/packages/mg-wix-csv/package.json @@ -0,0 +1,49 @@ +{ + "name": "@tryghost/mg-wix-csv", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "git+https://github.com/TryGhost/migrate.git", + "directory": "packages/mg-wix-csv" + }, + "author": "Ghost Foundation", + "license": "MIT", + "type": "module", + "main": "build/index.js", + "types": "build/types.d.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "default": "./build/index.js" + } + }, + "scripts": { + "dev": "echo \"Implement me!\"", + "build:watch": "tsc --watch --preserveWatchOutput --sourceMap", + "build": "tsc --build --sourceMap", + "lint": "eslint src/ --ext .ts --cache", + "test": "c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura node --test 'build/test/**/*.test.js'", + "test:local": "pnpm build && pnpm test", + "posttest": "pnpm lint" + }, + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "24.12.4", + "@typescript-eslint/parser": "8.59.4", + "c8": "11.0.0", + "eslint": "8.57.1", + "typescript": "6.0.3" + }, + "dependencies": { + "@tryghost/errors": "3.0.3", + "@tryghost/kg-default-cards": "10.2.10", + "@tryghost/mg-fs-utils": "workspace:*", + "@tryghost/string": "0.3.1", + "simple-dom": "1.4.0" + } +} diff --git a/packages/mg-wix-csv/src/index.ts b/packages/mg-wix-csv/src/index.ts new file mode 100644 index 000000000..43b155804 --- /dev/null +++ b/packages/mg-wix-csv/src/index.ts @@ -0,0 +1,10 @@ +import {mapContent} from './lib/mapper.js'; + +export default async (args: {options: any}) => { + const result = await mapContent(args); + return result; +}; + +export { + mapContent +}; diff --git a/packages/mg-wix-csv/src/lib/mapper.ts b/packages/mg-wix-csv/src/lib/mapper.ts new file mode 100644 index 000000000..c7c31880f --- /dev/null +++ b/packages/mg-wix-csv/src/lib/mapper.ts @@ -0,0 +1,215 @@ +import errors from '@tryghost/errors'; +import fsUtils from '@tryghost/mg-fs-utils'; +import {slugify} from '@tryghost/string'; +import {richContentToHtml} from './rich-content.js'; +import {wixImageUriToUrl} from './wix-image.js'; + +const parsePostsCSV = async ({pathToFile}: {pathToFile: string}) => { + const parseCSV = fsUtils.csv.parseCSV; + const parsed = await parseCSV(pathToFile); + + return parsed as wixCSVPostDataObject[]; +}; + +const parseDate = (value?: string) => { + if (!value) { + return null; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const parseBoolean = (value?: string) => { + return typeof value === 'string' && value.toLowerCase() === 'true'; +}; + +const parseIdArray = (value?: string) => { + if (!value) { + return []; + } + + const trimmed = value.trim(); + + if (trimmed.length === 0) { + return []; + } + + try { + const parsed = JSON.parse(trimmed); + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0); + } catch (error) { + return [trimmed]; + } +}; + +const uniqueIds = (ids: string[]) => { + return [...new Set(ids.filter(Boolean))]; +}; + +const looksLikeWixId = (value: string) => { + return /^[a-f0-9]{24}$/i.test(value); +}; + +const createTag = (value: string) => { + const slug = looksLikeWixId(value) ? value : slugify(value); + + return { + url: `migrator-added-tag-${slug}`, + data: { + slug, + name: value + } + }; +}; + +const createAuthor = ({name, defaultAuthorName}: {name?: string, defaultAuthorName?: string}) => { + const authorName = name?.trim() || defaultAuthorName?.trim() || 'Author'; + const authorSlug = slugify(authorName); + + return { + url: `migrator-added-author-${authorSlug}`, + data: { + slug: authorSlug, + name: authorName, + email: `${authorSlug}@example.com` + } + }; +}; + +const buildPostUrl = ({url, path}: {url?: string, path?: string}) => { + if (!url || !path) { + return path || ''; + } + + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + return `${url}${path.startsWith('/') ? '' : '/'}${path}`; +}; + +const createSlug = ({slug, title}: {slug?: string, title?: string}) => { + if (slug && slug.trim().length > 0) { + return slugify(slug); + } + + return slugify(title || 'untitled'); +}; + +const shouldInclude = (value: boolean | undefined) => { + return value !== false; +}; + +const mapTags = (postData: wixCSVPostDataObject, options?: any) => { + const categories = parseIdArray(postData.Categories); + const tags = parseIdArray(postData.Tags); + const mainCategory = postData['Main Category']; + const includeMainCategory = shouldInclude(options?.includeMainCategory); + const includeCategories = shouldInclude(options?.includeCategories); + const includeTags = shouldInclude(options?.includeTags); + const shouldDropLegacyMainCategoryId = mainCategory && !looksLikeWixId(mainCategory) && categories[0] && looksLikeWixId(categories[0]); + const secondaryCategories = shouldDropLegacyMainCategoryId ? categories.slice(1) : categories; + const orderedCategories = uniqueIds([ + ...(includeMainCategory && mainCategory ? [mainCategory] : []), + ...(includeCategories ? secondaryCategories : []) + ]); + + return [ + ...uniqueIds([ + ...orderedCategories, + ...(includeTags ? tags : []) + ]).map(createTag), + { + url: 'migrator-added-tag-hash-wix', + data: { + slug: 'hash-wix', + name: '#wix' + } + } + ]; +}; + +const mapPost = ({postData, options}: {postData: wixCSVPostDataObject, options?: any}) => { + const publishedAt = parseDate(postData['Published Date']); + const updatedAt = parseDate(postData['Last Published Date']) || publishedAt; + const createdAt = publishedAt || updatedAt || new Date(); + const postSlug = createSlug({slug: postData.Slug, title: postData.Title}); + const featureImage = wixImageUriToUrl(postData['Cover Image']); + + const mappedData: mappedDataObject = { + url: buildPostUrl({url: options?.url, path: postData['Post Page URL']}), + data: { + slug: postSlug, + comment_id: postData['Internal ID'] || null, + published_at: publishedAt, + updated_at: updatedAt, + created_at: createdAt, + title: postData.Title || postSlug, + type: 'post', + html: richContentToHtml({ + richContent: postData['Rich Content'], + plainContent: postData['Plain Content'] + }), + plaintext: postData['Plain Content'] || null, + status: publishedAt ? 'published' : 'draft', + custom_excerpt: postData.Excerpt || null, + visibility: 'public', + featured: parseBoolean(postData.Featured), + tags: mapTags(postData, options), + author: createAuthor({ + name: postData.Author, + defaultAuthorName: options?.defaultAuthorName + }) + } + }; + + if (featureImage) { + mappedData.data.feature_image = featureImage; + } + + return mappedData; +}; + +const mapPosts = async ({pathToFile, options}: {pathToFile: string, options: any}) => { + const parsed = await parsePostsCSV({pathToFile}); + + return parsed.map((postData: wixCSVPostDataObject) => { + return mapPost({postData, options}); + }); +}; + +const mapContent = async (args: {options: any}) => { + const output = { + posts: [] as mappedDataObject[] + }; + + const mappedPosts = await mapPosts({pathToFile: args.options.posts, options: args.options}); + + if (mappedPosts.length < 1) { + return new errors.NoContentError({message: 'Input file is empty'}); + } + + output.posts = mappedPosts; + + return output; +}; + +export { + buildPostUrl, + createAuthor, + createSlug, + mapContent, + mapPost, + mapTags, + parseBoolean, + parseDate, + parseIdArray, + parsePostsCSV, + uniqueIds +}; diff --git a/packages/mg-wix-csv/src/lib/rich-content.ts b/packages/mg-wix-csv/src/lib/rich-content.ts new file mode 100644 index 000000000..1d2c55aaf --- /dev/null +++ b/packages/mg-wix-csv/src/lib/rich-content.ts @@ -0,0 +1,249 @@ +import SimpleDom from 'simple-dom'; +import imageCard from '@tryghost/kg-default-cards/lib/cards/image.js'; +import {wixMediaIdToUrl} from './wix-image.js'; + +const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap); + +type WixNode = { + type?: string; + nodes?: WixNode[]; + textData?: { + text?: string; + decorations?: Array<{ + type?: string; + linkData?: { + link?: { + url?: string; + target?: string; + rel?: { + noreferrer?: boolean; + }; + }; + }; + }>; + }; + headingData?: { + level?: number; + }; + imageData?: { + image?: { + src?: { + id?: string; + }; + width?: number; + height?: number; + }; + altText?: string; + }; + buttonData?: { + text?: string; + link?: { + url?: string; + }; + containerData?: { + alignment?: string; + }; + }; +}; + +const escapeHtml = (value: string) => { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +const htmlCard = (html: string) => { + return [ + '', + '
', + html, + '
', + '' + ].join('\n'); +}; + +const paragraphsFromPlainText = (plainText?: string) => { + return (plainText || '') + .split(/\n{2,}/) + .map(part => part.trim()) + .filter(Boolean) + .map(part => `

${escapeHtml(part).replace(/\n/g, '
')}

`) + .join('\n'); +}; + +const renderChildren = (node: WixNode) => { + return (node.nodes || []).map(renderNode).join(''); +}; + +const renderText = (node: WixNode, options: {stripBold?: boolean} = {}) => { + const rawText = node.textData?.text || ''; + const decorations = node.textData?.decorations || []; + const textParts = rawText.match(/^(\s*)(.*?)(\s*)$/s); + const leadingWhitespace = textParts?.[1] || ''; + const linkText = textParts?.[2] || ''; + const trailingWhitespace = textParts?.[3] || ''; + let text = escapeHtml(linkText); + const linkUrl = decorations.find(decoration => decoration.type === 'LINK')?.linkData?.link?.url; + + for (const decoration of decorations) { + if (decoration.type === 'BOLD' && !options.stripBold) { + text = `${text}`; + } else if (decoration.type === 'ITALIC') { + text = `${text}`; + } else if (decoration.type === 'UNDERLINE') { + text = `${text}`; + } else if (decoration.type === 'SUPERSCRIPT') { + text = `${text}`; + } + } + + if (linkUrl && linkText.length > 0) { + text = `${text}`; + } + + return `${escapeHtml(leadingWhitespace)}${text}${escapeHtml(trailingWhitespace)}`; +}; + +const renderHeadingChildren = (node: WixNode): string => { + return (node.nodes || []).map((child) => { + if (child.type === 'TEXT') { + return renderText(child, {stripBold: true}); + } + + return renderNode(child); + }).join(''); +}; + +const renderImage = (node: WixNode) => { + const image = node.imageData?.image; + const src = wixMediaIdToUrl({ + id: image?.src?.id, + width: image?.width, + height: image?.height + }); + + if (!src) { + return htmlCard(`
${escapeHtml(JSON.stringify(node, null, 2))}
`); + } + + const cardOpts = { + env: {dom: new SimpleDom.Document()}, + payload: { + src, + alt: node.imageData?.altText || null + } + }; + + return serializer.serialize(imageCard.render(cardOpts)); +}; + +const renderButton = (node: WixNode) => { + const text = escapeHtml(node.buttonData?.text || ''); + const url = node.buttonData?.link?.url; + const alignment = (node.buttonData?.containerData?.alignment || 'CENTER').toLowerCase(); + const alignmentClass = ['left', 'center', 'right'].includes(alignment) ? `kg-align-${alignment}` : 'kg-align-center'; + + if (!text) { + return ''; + } + + if (!url) { + return `

${text}

`; + } + + return ``; +}; + +const renderTableCell = (node: WixNode) => { + return `${renderChildren(node)}`; +}; + +const renderTableRow = (node: WixNode) => { + return `${renderChildren(node)}`; +}; + +const renderTable = (node: WixNode) => { + return `${renderChildren(node)}
`; +}; + +const renderListItem = (node: WixNode) => { + return `
  • ${renderChildren(node)}
  • `; +}; + +const renderUnknownNode = (node: WixNode) => { + if (node.nodes && node.nodes.length > 0) { + return htmlCard(renderChildren(node)); + } + + return htmlCard(`
    ${escapeHtml(JSON.stringify(node, null, 2))}
    `); +}; + +const renderNode = (node: WixNode): string => { + switch (node.type) { + case 'TEXT': + return renderText(node); + case 'PARAGRAPH': { + const children = renderChildren(node); + + if (children.trim().length === 0) { + return ''; + } + + return `

    ${children}

    `; + } + case 'HEADING': { + const level = Math.min(Math.max(node.headingData?.level || 2, 1), 6); + return `${renderHeadingChildren(node)}`; + } + case 'BULLETED_LIST': + return ``; + case 'ORDERED_LIST': + return `
      ${renderChildren(node)}
    `; + case 'LIST_ITEM': + return renderListItem(node); + case 'DIVIDER': + return '
    '; + case 'IMAGE': + return renderImage(node); + case 'BUTTON': + return renderButton(node); + case 'TABLE': + return renderTable(node); + case 'TABLE_ROW': + return renderTableRow(node); + case 'TABLE_CELL': + return renderTableCell(node); + default: + return renderUnknownNode(node); + } +}; + +const richContentToHtml = ({richContent, plainContent}: {richContent?: string, plainContent?: string}) => { + if (!richContent || richContent.trim().length === 0) { + return htmlCard(paragraphsFromPlainText(plainContent)); + } + + try { + const parsed = JSON.parse(richContent); + const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes : []; + + if (nodes.length === 0) { + return htmlCard(paragraphsFromPlainText(plainContent)); + } + + return nodes.map(renderNode).join(''); + } catch (error) { + return htmlCard(paragraphsFromPlainText(plainContent)); + } +}; + +export { + escapeHtml, + htmlCard, + paragraphsFromPlainText, + renderNode, + richContentToHtml +}; diff --git a/packages/mg-wix-csv/src/lib/wix-image.ts b/packages/mg-wix-csv/src/lib/wix-image.ts new file mode 100644 index 000000000..040293f04 --- /dev/null +++ b/packages/mg-wix-csv/src/lib/wix-image.ts @@ -0,0 +1,63 @@ +const WIX_STATIC_MEDIA_URL = 'https://static.wixstatic.com/media'; + +type WixImageParts = { + id: string; + filename: string; +}; + +const buildWixStaticUrl = ({id}: WixImageParts) => { + const encodedId = encodeURI(id); + + return `${WIX_STATIC_MEDIA_URL}/${encodedId}`; +}; + +const parseWixImageUri = (value: string) => { + const match = value.match(/^wix:image:\/\/v1\/([^/]+)\/([^#]+)(?:#(.*))?$/); + + if (!match) { + return null; + } + + const params = new URLSearchParams(match[3] || ''); + + return { + id: decodeURIComponent(match[1]), + filename: decodeURIComponent(match[2]) + }; +}; + +const wixMediaIdToUrl = ({id}: {id?: string, width?: number, height?: number}) => { + if (!id) { + return null; + } + + return buildWixStaticUrl({ + id, + filename: id + }); +}; + +const wixImageUriToUrl = (value?: string) => { + if (!value) { + return null; + } + + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + + const parts = parseWixImageUri(value); + + if (!parts) { + return null; + } + + return buildWixStaticUrl(parts); +}; + +export { + buildWixStaticUrl, + parseWixImageUri, + wixImageUriToUrl, + wixMediaIdToUrl +}; diff --git a/packages/mg-wix-csv/src/test/fixtures/empty.csv b/packages/mg-wix-csv/src/test/fixtures/empty.csv new file mode 100644 index 000000000..1c9cd105a --- /dev/null +++ b/packages/mg-wix-csv/src/test/fixtures/empty.csv @@ -0,0 +1 @@ +Author,Main Category,Title,Excerpt,ID,Tags,Featured,Slug,Plain Content,Cover Image,Published Date,Categories,Rich Content,Post Page URL,Last Published Date,Internal ID diff --git a/packages/mg-wix-csv/src/test/fixtures/posts.csv b/packages/mg-wix-csv/src/test/fixtures/posts.csv new file mode 100644 index 000000000..413ceb0f8 --- /dev/null +++ b/packages/mg-wix-csv/src/test/fixtures/posts.csv @@ -0,0 +1,3 @@ +Author,Main Category,Title,Excerpt,ID,Tags,Featured,Slug,Plain Content,Cover Image,Published Date,Categories,Rich Content,Post Page URL,Last Published Date,Internal ID +Jane Writer,Market News,Hello Wix,Custom excerpt,post-1,"[""tag-id""]",TRUE,hello-wix,"Plain text fallback",wix:image://v1/example_media_image~mv2.jpg/example_media_image~mv2.jpg#originWidth=6016&originHeight=4016,2025-10-01T10:00:00Z,Tax Planning,"{""nodes"":[{""type"":""PARAGRAPH"",""nodes"":[{""type"":""TEXT"",""textData"":{""text"":""Hello "",""decorations"":[]}},{""type"":""TEXT"",""textData"":{""text"":""world"",""decorations"":[{""type"":""BOLD""}]}}]}]}",/post/hello-wix,2025-10-02T10:00:00Z,internal-1 +,,Draft Wix,,post-2,[],FALSE,,Draft plain,,,"" ,"not-json",/post/draft-wix,,internal-2 diff --git a/packages/mg-wix-csv/src/test/mapper.test.ts b/packages/mg-wix-csv/src/test/mapper.test.ts new file mode 100644 index 000000000..945150dce --- /dev/null +++ b/packages/mg-wix-csv/src/test/mapper.test.ts @@ -0,0 +1,170 @@ +import {URL} from 'node:url'; +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import {join} from 'node:path'; +import { + buildPostUrl, + createAuthor, + createSlug, + mapContent, + mapPost, + mapTags, + parseBoolean, + parseDate, + parseIdArray, + parsePostsCSV, + uniqueIds +} from '../lib/mapper.js'; +import wixCSVIngest from '../index.js'; + +const __dirname = new URL('.', import.meta.url).pathname; +const fixturesPath = join(__dirname, '../../src/test/fixtures'); + +describe('Wix CSV mapper', () => { + it('parses CSV files and maps posts', async () => { + const parsed = await parsePostsCSV({pathToFile: join(fixturesPath, 'posts.csv')}); + assert.equal(parsed.length, 2); + + const result = await mapContent({ + options: { + posts: join(fixturesPath, 'posts.csv'), + url: 'https://example.com' + } + }); + + if (!('posts' in result)) { + throw result; + } + + assert.equal(result.posts.length, 2); + assert.equal(result.posts[0].url, 'https://example.com/post/hello-wix'); + assert.equal(result.posts[0].data.slug, 'hello-wix'); + assert.equal(result.posts[0].data.comment_id, 'internal-1'); + assert.equal(result.posts[0].data.published_at?.toISOString(), '2025-10-01T10:00:00.000Z'); + assert.equal(result.posts[0].data.updated_at?.toISOString(), '2025-10-02T10:00:00.000Z'); + assert.equal(result.posts[0].data.created_at.toISOString(), '2025-10-01T10:00:00.000Z'); + assert.equal(result.posts[0].data.title, 'Hello Wix'); + assert.equal(result.posts[0].data.status, 'published'); + assert.equal(result.posts[0].data.custom_excerpt, 'Custom excerpt'); + assert.equal(result.posts[0].data.plaintext, 'Plain text fallback'); + assert.equal(result.posts[0].data.featured, true); + assert.match(result.posts[0].data.feature_image || '', /static\.wixstatic\.com/); + assert.equal(result.posts[0].data.author.data.email, 'jane-writer@example.com'); + assert.deepEqual(result.posts[0].data.tags.map((tag: tagsObject) => tag.data), [ + {slug: 'market-news', name: 'Market News'}, + {slug: 'tax-planning', name: 'Tax Planning'}, + {slug: 'tag-id', name: 'tag-id'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.equal(result.posts[0].data.html, '

    Hello world

    '); + + assert.equal(result.posts[1].url, 'https://example.com/post/draft-wix'); + assert.equal(result.posts[1].data.slug, 'draft-wix'); + assert.equal(result.posts[1].data.status, 'draft'); + assert.equal(result.posts[1].data.author.data.name, 'Author'); + assert.equal(result.posts[1].data.feature_image, undefined); + assert.match(result.posts[1].data.html, /Draft plain/); + + const indexResult = await wixCSVIngest({ + options: { + posts: join(fixturesPath, 'posts.csv'), + url: 'https://example.com' + } + }); + assert.equal('posts' in indexResult, true); + }); + + it('maps helper edge cases', () => { + assert.equal(parseDate('nope'), null); + assert.equal(parseDate(), null); + assert.equal(parseBoolean('TRUE'), true); + assert.equal(parseBoolean('false'), false); + assert.deepEqual(parseIdArray('["a",1,"b"]'), ['a', 'b']); + assert.deepEqual(parseIdArray('{"x":1}'), []); + assert.deepEqual(parseIdArray('bad'), ['bad']); + assert.deepEqual(parseIdArray(' Plain Category '), ['Plain Category']); + assert.deepEqual(parseIdArray(), []); + assert.deepEqual(parseIdArray(' '), []); + assert.deepEqual(uniqueIds(['a', 'a', '', 'b']), ['a', 'b']); + assert.equal(buildPostUrl({path: '/post/one'}), '/post/one'); + assert.equal(buildPostUrl({url: 'https://example.com', path: 'post/one'}), 'https://example.com/post/one'); + assert.equal(buildPostUrl({url: 'https://example.com', path: 'https://other.com/post'}), 'https://other.com/post'); + assert.equal(createSlug({title: 'Hello World'}), 'hello-world'); + assert.equal(createSlug({slug: 'Custom Slug'}), 'custom-slug'); + assert.equal(createAuthor({defaultAuthorName: 'Default Person'}).data.email, 'default-person@example.com'); + assert.deepEqual(mapTags({'Main Category': 'Tax Planning', Categories: '["68504d7f15587afbbe9179de","id"]', Tags: '["id"]'}).map((tag: tagsObject) => tag.data), [ + {slug: 'tax-planning', name: 'Tax Planning'}, + {slug: 'id', name: 'id'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.deepEqual(mapTags({'Main Category': 'Tax Planning', Categories: 'Another Category', Tags: 'Tag Name'}).map((tag: tagsObject) => tag.data), [ + {slug: 'tax-planning', name: 'Tax Planning'}, + {slug: 'another-category', name: 'Another Category'}, + {slug: 'tag-name', name: 'Tag Name'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.deepEqual(mapTags({'Main Category': '68504d7f15587afbbe9179de', Categories: '["68504d7f15587afbbe9179de","id"]', Tags: '[]'}).map((tag: tagsObject) => tag.data), [ + {slug: '68504d7f15587afbbe9179de', name: '68504d7f15587afbbe9179de'}, + {slug: 'id', name: 'id'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.deepEqual(mapTags({'Main Category': 'Tax Planning', Categories: 'Category Name', Tags: 'Tag Name'}, { + includeMainCategory: false + }).map((tag: tagsObject) => tag.data), [ + {slug: 'category-name', name: 'Category Name'}, + {slug: 'tag-name', name: 'Tag Name'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.deepEqual(mapTags({'Main Category': 'Tax Planning', Categories: 'Category Name', Tags: 'Tag Name'}, { + includeCategories: false, + includeTags: false + }).map((tag: tagsObject) => tag.data), [ + {slug: 'tax-planning', name: 'Tax Planning'}, + {slug: 'hash-wix', name: '#wix'} + ]); + assert.deepEqual(mapTags({'Main Category': 'Tax Planning', Categories: 'Category Name', Tags: 'Tag Name'}, { + includeMainCategory: false, + includeCategories: false, + includeTags: false + }).map((tag: tagsObject) => tag.data), [ + {slug: 'hash-wix', name: '#wix'} + ]); + }); + + it('maps a minimal draft post', () => { + const mapped = mapPost({ + postData: { + Title: '', + Slug: '', + 'Post Page URL': '', + 'Internal ID': '', + 'Rich Content': '{"nodes":[]}' + }, + options: { + defaultAuthorName: 'Fallback Author' + } + }); + + assert.equal(mapped.url, ''); + assert.equal(mapped.data.slug, 'untitled'); + assert.equal(mapped.data.title, 'untitled'); + assert.equal(mapped.data.comment_id, null); + assert.equal(mapped.data.published_at, null); + assert.equal(mapped.data.updated_at, null); + assert.equal(mapped.data.status, 'draft'); + assert.equal(mapped.data.custom_excerpt, null); + assert.equal(mapped.data.plaintext, null); + assert.equal(mapped.data.author.data.name, 'Fallback Author'); + }); + + it('returns a NoContentError for empty CSV files', async () => { + const result = await mapContent({ + options: { + posts: join(fixturesPath, 'empty.csv') + } + }); + + assert.ok(result instanceof Error); + assert.equal(result.message, 'Input file is empty'); + }); +}); diff --git a/packages/mg-wix-csv/src/test/rich-content.test.ts b/packages/mg-wix-csv/src/test/rich-content.test.ts new file mode 100644 index 000000000..e6e2d6a2b --- /dev/null +++ b/packages/mg-wix-csv/src/test/rich-content.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import { + escapeHtml, + htmlCard, + paragraphsFromPlainText, + renderNode, + richContentToHtml +} from '../lib/rich-content.js'; + +describe('Wix rich content converter', () => { + it('escapes HTML and wraps HTML cards', () => { + assert.equal(escapeHtml('It\'s'), '<a href="x">It's</a>'); + assert.equal(htmlCard('

    Body

    '), '\n
    \n

    Body

    \n
    \n'); + assert.equal(paragraphsFromPlainText('One\nline\n\nTwo'), '

    One
    line

    \n

    Two

    '); + }); + + it('converts common block and inline nodes', () => { + const richContent = JSON.stringify({ + nodes: [ + { + type: 'HEADING', + headingData: {level: 2}, + nodes: [{type: 'TEXT', textData: {text: 'Heading', decorations: [{type: 'BOLD'}, {type: 'ITALIC'}]}}] + }, + { + type: 'PARAGRAPH', + nodes: [ + {type: 'TEXT', textData: {text: 'Bold', decorations: [{type: 'BOLD'}]}}, + {type: 'TEXT', textData: {text: ' italic', decorations: [{type: 'ITALIC'}]}}, + {type: 'TEXT', textData: {text: ' underline', decorations: [{type: 'UNDERLINE'}]}}, + {type: 'TEXT', textData: {text: ' super', decorations: [{type: 'SUPERSCRIPT'}]}}, + {type: 'TEXT', textData: {text: ' link', decorations: [{type: 'LINK', linkData: {link: {url: 'https://example.com?a=1&b=2'}}}]}}, + {type: 'TEXT', textData: {text: ' color', decorations: [{type: 'COLOR'}]}} + ] + }, + { + type: 'BULLETED_LIST', + nodes: [{type: 'LIST_ITEM', nodes: [{type: 'PARAGRAPH', nodes: [{type: 'TEXT', textData: {text: 'Bullet', decorations: []}}]}]}] + }, + { + type: 'ORDERED_LIST', + nodes: [{type: 'LIST_ITEM', nodes: [{type: 'PARAGRAPH', nodes: [{type: 'TEXT', textData: {text: 'One', decorations: []}}]}]}] + }, + {type: 'DIVIDER', nodes: []} + ] + }); + + assert.equal(richContentToHtml({richContent}), '

    Heading

    Bold italic underline super link color

    1. One


    '); + assert.equal(renderNode({type: 'HEADING', headingData: {level: 2}}), '

    '); + assert.equal(renderNode({type: 'HEADING', headingData: {level: 2}, nodes: [{type: 'UNKNOWN'}]}), '

    \n
    \n
    {\n  "type": "UNKNOWN"\n}
    \n
    \n

    '); + assert.equal(renderNode({type: 'HEADING', headingData: {level: 10}, nodes: []}), '
    '); + assert.equal(renderNode({type: 'HEADING', headingData: {level: 0}, nodes: []}), '

    '); + assert.equal(renderNode({type: 'PARAGRAPH'}), ''); + assert.equal(renderNode({type: 'PARAGRAPH', nodes: [{type: 'TEXT', textData: {text: ' ', decorations: []}}]}), ''); + assert.equal(renderNode({type: 'TEXT'}), ''); + assert.equal(renderNode({type: 'TEXT', textData: {text: 'No URL', decorations: [{type: 'LINK'}]}}), 'No URL'); + assert.equal(renderNode({type: 'TEXT', textData: {text: ' It ', decorations: [{type: 'LINK', linkData: {link: {url: 'https://example.com'}}}]}}), ' It '); + }); + + it('converts images, buttons, and tables', () => { + const imageHtml = renderNode({ + type: 'IMAGE', + imageData: { + image: { + src: {id: 'image.jpg'}, + width: 100, + height: 50 + }, + altText: 'Alt' + } + }); + + assert.match(imageHtml, /kg-image-card/); + assert.match(imageHtml, /image\.jpg/); + assert.match(imageHtml, /Alt/); + assert.match(renderNode({ + type: 'IMAGE', + imageData: { + image: { + src: {id: 'image-no-alt.jpg'}, + width: 100, + height: 50 + } + } + }), /image-no-alt\.jpg/); + + assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Go', link: {url: 'https://example.com'}, containerData: {alignment: 'CENTER'}}}), ''); + assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Left', link: {url: 'https://example.com'}, containerData: {alignment: 'LEFT'}}}), ''); + assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Bad alignment', link: {url: 'https://example.com'}, containerData: {alignment: 'AUTO'}}}), ''); + assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'No link'}}), '

    No link

    '); + assert.equal(renderNode({type: 'BUTTON', buttonData: {}}), ''); + + const table = renderNode({ + type: 'TABLE', + nodes: [{ + type: 'TABLE_ROW', + nodes: [{type: 'TABLE_CELL', nodes: [{type: 'PARAGRAPH', nodes: [{type: 'TEXT', textData: {text: 'Cell', decorations: []}}]}]}] + }] + }); + assert.equal(table, '

    Cell

    '); + }); + + it('uses HTML cards for unknown or invalid content', () => { + assert.equal(renderNode({type: 'IMAGE'}), '\n
    \n
    {\n  "type": "IMAGE"\n}
    \n
    \n'); + assert.equal(renderNode({type: 'UNKNOWN', nodes: [{type: 'TEXT', textData: {text: 'child', decorations: []}}]}), '\n
    \nchild\n
    \n'); + assert.match(renderNode({type: 'UNKNOWN'}), /"UNKNOWN"/); + assert.equal(richContentToHtml({richContent: '', plainContent: 'Fallback'}), '\n
    \n

    Fallback

    \n
    \n'); + assert.equal(richContentToHtml({richContent: '{"nodes":[]}', plainContent: 'Fallback'}), '\n
    \n

    Fallback

    \n
    \n'); + assert.equal(richContentToHtml({richContent: '{"nodes":{}}', plainContent: 'Fallback'}), '\n
    \n

    Fallback

    \n
    \n'); + assert.equal(richContentToHtml({richContent: 'bad json', plainContent: 'Fallback'}), '\n
    \n

    Fallback

    \n
    \n'); + }); +}); diff --git a/packages/mg-wix-csv/src/test/wix-image.test.ts b/packages/mg-wix-csv/src/test/wix-image.test.ts new file mode 100644 index 000000000..a86d3ac35 --- /dev/null +++ b/packages/mg-wix-csv/src/test/wix-image.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import { + buildWixStaticUrl, + parseWixImageUri, + wixImageUriToUrl, + wixMediaIdToUrl +} from '../lib/wix-image.js'; + +describe('Wix image URL helpers', () => { + it('transforms Wix image URIs into static media URLs', () => { + const input = 'wix:image://v1/example_media_image~mv2.jpg/example_media_image~mv2.jpg#originWidth=6016&originHeight=4016'; + const output = wixImageUriToUrl(input); + + assert.equal(output, 'https://static.wixstatic.com/media/example_media_image~mv2.jpg'); + }); + + it('keeps existing URLs and rejects invalid values', () => { + assert.equal(wixImageUriToUrl('https://example.com/image.jpg'), 'https://example.com/image.jpg'); + assert.equal(wixImageUriToUrl('not-wix'), null); + assert.equal(wixImageUriToUrl(), null); + }); + + it('parses URI parts', () => { + assert.deepEqual(parseWixImageUri('wix:image://v1/id/file.jpg#originWidth=x&originHeight='), { + id: 'id', + filename: 'file.jpg' + }); + assert.deepEqual(parseWixImageUri('wix:image://v1/id/file.jpg'), { + id: 'id', + filename: 'file.jpg' + }); + assert.equal(parseWixImageUri('bad'), null); + }); + + it('builds URLs from rich-content media IDs', () => { + assert.equal(wixMediaIdToUrl({id: 'image one.jpg', width: 500, height: 250}), 'https://static.wixstatic.com/media/image%20one.jpg'); + assert.equal(wixMediaIdToUrl({}), null); + assert.equal(buildWixStaticUrl({id: 'id.jpg', filename: 'file.jpg'}), 'https://static.wixstatic.com/media/id.jpg'); + }); +}); diff --git a/packages/mg-wix-csv/src/types.d.ts b/packages/mg-wix-csv/src/types.d.ts new file mode 100644 index 000000000..8938e9fb5 --- /dev/null +++ b/packages/mg-wix-csv/src/types.d.ts @@ -0,0 +1,64 @@ +declare module '@tryghost/mg-fs-utils'; +declare module '@tryghost/errors'; +declare module '@tryghost/string'; +declare module '@tryghost/kg-default-cards/lib/cards/image.js'; +declare module 'simple-dom'; + +type wixCSVPostDataObject = { + Author?: string; + 'Main Category'?: string; + ID?: string; + Tags?: string; + Featured?: string; + Slug?: string; + 'Cover Image'?: string; + 'Plain Content'?: string; + 'Published Date'?: string; + Pinned?: string; + Categories?: string; + 'Rich Content'?: string; + 'Post Page URL'?: string; + Title?: string; + Excerpt?: string; + 'Last Published Date'?: string; + 'Internal ID'?: string; +}; + +type tagsObject = { + url: string; + data: { + slug: string; + name: string; + }; +}; + +type authorsObject = { + url: string; + data: { + slug: string; + name: string; + email: string; + }; +}; + +type mappedDataObject = { + url: string; + data: { + slug: string; + comment_id: string | null; + published_at: Date | null; + updated_at: Date | null; + created_at: Date; + title: string; + type: 'post'; + html: string; + plaintext: string | null; + status: 'published' | 'draft'; + custom_excerpt: string | null; + visibility: 'public'; + featured: boolean; + tags: tagsObject[]; + author: authorsObject; + feature_image?: string; + }; +}; diff --git a/packages/mg-wix-csv/tsconfig.json b/packages/mg-wix-csv/tsconfig.json new file mode 100644 index 000000000..643f999af --- /dev/null +++ b/packages/mg-wix-csv/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "es2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "build", + "types": ["node"], + "strict": true, + "noImplicitAny": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "src/index.js"] +} diff --git a/packages/migrate/bin/cli.js b/packages/migrate/bin/cli.js index 659a2a872..1d5139d76 100755 --- a/packages/migrate/bin/cli.js +++ b/packages/migrate/bin/cli.js @@ -35,6 +35,7 @@ import substackMembersCommands from '../commands/substack-members.js'; import substackCommands from '../commands/substack.js'; import tinynewsCommands from '../commands/tinynews.js'; import tinynewsMembersCommands from '../commands/tinynews-members.js'; +import wixCSVCommands from '../commands/wix-csv.js'; import wpApiCommands from '../commands/wp-api.js'; import wpXMLCommands from '../commands/wp-xml.js'; @@ -77,6 +78,7 @@ prettyCLI.command(substackMembersCommands); prettyCLI.command(substackCommands); prettyCLI.command(tinynewsCommands); prettyCLI.command(tinynewsMembersCommands); +prettyCLI.command(wixCSVCommands); prettyCLI.command(wpApiCommands); prettyCLI.command(wpXMLCommands); diff --git a/packages/migrate/commands/wix-csv.js b/packages/migrate/commands/wix-csv.js new file mode 100644 index 000000000..1df78719c --- /dev/null +++ b/packages/migrate/commands/wix-csv.js @@ -0,0 +1,144 @@ +import {inspect} from 'node:util'; +import {ui} from '@tryghost/pretty-cli'; +import wixCSV from '../sources/wix-csv.js'; +import {convertOptionsToSywac, convertOptionsToDefaults} from '../lib/utilties/options-to-sywac.js'; +import {ghostAuthOptions} from '@tryghost/mg-ghost-authors'; + +const id = 'wix-csv'; +const group = 'Sources:'; +const flags = 'wix-csv'; +const desc = 'Migrate from Wix using a posts CSV file'; + +const options = [ + { + type: 'string', + flags: '--posts', + defaultValue: null, + desc: 'Path to posts CSV file', + required: true + }, + { + type: 'string', + flags: '--url', + defaultValue: null, + desc: 'URL to live site', + required: true + }, + { + type: 'string', + flags: '--defaultAuthorName', + defaultValue: null, + desc: 'The full name of the default author to assign to posts, if one cannot be found' + }, + { + type: 'array', + flags: '--scrape', + choices: ['assets', 'none', 'img', 'media', 'files'], + defaultValue: ['assets'], + desc: 'Configure scraping tasks (assets = download assets, none = skip asset download). Legacy aliases for assets: img, media, files' + }, + { + type: 'boolean', + flags: '--includeMainCategory', + defaultValue: true, + desc: 'Include the Main Category column as a Ghost tag' + }, + { + type: 'boolean', + flags: '--includeCategories', + defaultValue: true, + desc: 'Include the Categories column as Ghost tags' + }, + { + type: 'boolean', + flags: '--includeTags', + defaultValue: true, + desc: 'Include the Tags column as Ghost tags' + }, + { + type: 'string', + flags: '--tmpPath', + defaultValue: null, + desc: 'Specify the full path where the temporary files will be stored (Defaults a hidden tmp dir)' + }, + { + type: 'string', + flags: '--outputPath', + defaultValue: null, + desc: 'Specify the full path where the final zip file will be saved to (Defaults to CWD)' + }, + { + type: 'string', + flags: '--cacheName', + defaultValue: null, + desc: 'Provide a unique name for the cache directory (defaults to a UUID)' + }, + { + type: 'boolean', + flags: '--cache', + defaultValue: true, + desc: 'Persist local cache after migration is complete (Only if `--zip` is `true`)' + }, + { + type: 'boolean', + flags: '--zip', + defaultValue: true, + desc: 'Create a zip file (set to false to skip)' + }, + { + type: 'boolean', + flags: '-V --verbose', + defaultValue: Boolean(process?.env?.DEBUG), + desc: 'Show verbose output' + }, + { + type: 'boolean', + flags: '--veryVerbose', + defaultValue: false, + desc: 'Show very verbose output (implies --verbose)' + }, + ...ghostAuthOptions +]; + +const defaults = convertOptionsToDefaults(options); +const setup = sywac => convertOptionsToSywac(options, sywac); + +const run = async (argv) => { + const context = { + errors: [], + warnings: [] + }; + + if (argv.veryVerbose) { + argv.verbose = true; + } + + if (argv.url && argv.url.endsWith('/')) { + argv.url = argv.url.slice(0, -1); + } + + try { + const migrate = wixCSV.getTaskRunner(argv); + await migrate.run(context); + + if (argv.verbose && context.result) { + ui.log.info('Done'); + } + + if (argv.veryVerbose && context.result) { + ui.log.info(inspect(context.result.data, false, 2)); + } + } catch (error) { + ui.log.error(error); + } +}; + +export default { + id, + group, + flags, + desc, + setup, + run, + defaults +}; diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 2fcbeb3e7..24f30ba2b 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -67,6 +67,7 @@ "@tryghost/mg-tinynews-members": "workspace:*", "@tryghost/mg-utils": "workspace:*", "@tryghost/mg-webscraper": "workspace:*", + "@tryghost/mg-wix-csv": "workspace:*", "@tryghost/mg-wp-api": "workspace:*", "@tryghost/mg-wp-xml": "workspace:*", "@tryghost/pretty-cli": "3.0.3", diff --git a/packages/migrate/sources/wix-csv.js b/packages/migrate/sources/wix-csv.js new file mode 100644 index 000000000..b02ed7241 --- /dev/null +++ b/packages/migrate/sources/wix-csv.js @@ -0,0 +1,171 @@ +import {readFileSync} from 'node:fs'; +import wixCSVIngest from '@tryghost/mg-wix-csv'; +import {toGhostJSON} from '@tryghost/mg-json'; +import mgHtmlLexical from '@tryghost/mg-html-lexical'; +import MgAssetScraper from '@tryghost/mg-assetscraper-db'; +import MgLinkFixer from '@tryghost/mg-linkfixer'; +import fsUtils from '@tryghost/mg-fs-utils'; +import {makeTaskRunner} from '@tryghost/listr-smart-renderer'; +import {createGhostUserTasks} from '@tryghost/mg-ghost-authors'; +import prettyMilliseconds from 'pretty-ms'; + +const initialize = (options) => { + return { + title: 'Initializing Workspace', + task: async (ctx, task) => { + ctx.options = options; + ctx.allowScrape = { + assets: ctx.options.scrape.includes('assets') || ctx.options.scrape.includes('img') || ctx.options.scrape.includes('media') || ctx.options.scrape.includes('files') + }; + + ctx.options.cacheName = options.cacheName || fsUtils.utils.cacheNameFromPath(ctx.options.url); + ctx.fileCache = new fsUtils.FileCache(`wix-csv-${ctx.options.cacheName}`, { + tmpPath: ctx.options.tmpPath + }); + ctx.assetScraper = new MgAssetScraper(ctx.fileCache, { + domains: [ + 'http://static.wixstatic.com', + 'https://static.wixstatic.com' + ] + }, ctx); + await ctx.assetScraper.init(); + ctx.linkFixer = new MgLinkFixer(); + + task.output = `Workspace initialized at ${ctx.fileCache.cacheDir}`; + } + }; +}; + +const getFullTaskList = (options) => { + return [ + initialize(options), + { + title: 'Read Wix CSV content', + task: async (ctx) => { + try { + ctx.result = await wixCSVIngest({ + options + }); + await ctx.fileCache.writeTmpFile(ctx.result, 'wix-csv-export-data.json'); + } catch (error) { + ctx.errors.push('Failed to read Wix CSV content', error); // eslint-disable-line no-console + throw error; + } + } + }, + ...createGhostUserTasks(options), + { + title: 'Build Link Map', + task: async (ctx) => { + try { + ctx.linkFixer.buildMap(ctx); + } catch (error) { + ctx.errors.push('Failed to build link map', error); // eslint-disable-line no-console + throw error; + } + } + }, + { + title: 'Format data as Ghost JSON', + task: async (ctx) => { + try { + ctx.result = await toGhostJSON(ctx.result, ctx.options, ctx); + } catch (error) { + ctx.errors.push('Failed to format data as Ghost JSON', error); // eslint-disable-line no-console + throw error; + } + } + }, + { + title: 'Fetch images via AssetScraper', + skip: ctx => !ctx.allowScrape.assets, + task: async (ctx) => { + const tasks = ctx.assetScraper.getTasks(); + return makeTaskRunner(tasks, { + verbose: options.verbose, + exitOnError: false, + concurrent: false + }); + } + }, + { + title: 'Update links in content via LinkFixer', + task: async (ctx, task) => { + const tasks = ctx.linkFixer.fix(ctx, task); + return makeTaskRunner(tasks, options); + } + }, + { + title: 'Convert HTML -> Lexical', + task: (ctx) => { + try { + const tasks = mgHtmlLexical.convert(ctx); + return makeTaskRunner(tasks, options); + } catch (error) { + ctx.errors.push('Failed to convert HTML -> Lexical', error); // eslint-disable-line no-console + throw error; + } + } + }, + { + title: 'Write Ghost import JSON File', + task: async (ctx) => { + try { + await ctx.fileCache.writeGhostImportFile(ctx.result); + } catch (error) { + ctx.errors.push('Failed to write Ghost import JSON File', error); // eslint-disable-line no-console + throw error; + } + } + }, + { + title: 'Write Ghost import zip', + skip: () => !options.zip, + task: async (ctx, task) => { + const isStorage = (options?.outputStorage && typeof options.outputStorage === 'object') ?? false; + + try { + const timer = Date.now(); + const zipFinalPath = options.outputPath || process.cwd(); + ctx.outputFile = await fsUtils.zip.write(zipFinalPath, ctx.fileCache.zipDir, ctx.fileCache.defaultZipFileName); + + if (isStorage) { + const storage = options.outputStorage; + const localFilePath = ctx.outputFile.path; + const fileBuffer = await readFileSync(ctx.outputFile.path); + ctx.outputFile.path = await storage.upload({body: fileBuffer, fileName: `gh-wix-csv-${ctx.options.cacheName}.zip`}); + await fsUtils.zip.deleteFile(localFilePath); + } + + task.output = `Successfully written zip to ${ctx.outputFile.path} in ${prettyMilliseconds(Date.now() - timer)}`; + } catch (error) { + ctx.errors.push('Failed to write and upload ZIP file', error); // eslint-disable-line no-console + throw error; + } + } + }, + { + title: 'Clearing cached files', + enabled: () => !options.cache && options.zip, + task: async (ctx) => { + try { + await ctx.fileCache.emptyCurrentCacheDir(); + } catch (error) { + ctx.errors.push('Failed to clear temporary cached files', error); + throw error; + } + } + } + ]; +}; + +const getTaskRunner = (options) => { + const tasks = getFullTaskList(options); + return makeTaskRunner(tasks, Object.assign({topLevel: true}, options)); +}; + +export default { + initialize, + getFullTaskList, + getTaskRunner +}; diff --git a/packages/migrate/test/wix-csv-command.test.js b/packages/migrate/test/wix-csv-command.test.js new file mode 100644 index 000000000..d29911cf5 --- /dev/null +++ b/packages/migrate/test/wix-csv-command.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; +import wixCSVCommand from '../commands/wix-csv.js'; +import wixCSVSource from '../sources/wix-csv.js'; + +describe('Wix CSV command', function () { + it('registers command metadata and defaults', function () { + assert.equal(wixCSVCommand.id, 'wix-csv'); + assert.equal(wixCSVCommand.flags, 'wix-csv'); + assert.equal(wixCSVCommand.defaults.posts, null); + assert.equal(wixCSVCommand.defaults.url, null); + assert.deepEqual(wixCSVCommand.defaults.scrape, ['assets']); + assert.equal(wixCSVCommand.defaults.includeMainCategory, true); + assert.equal(wixCSVCommand.defaults.includeCategories, true); + assert.equal(wixCSVCommand.defaults.includeTags, true); + assert.equal(wixCSVCommand.defaults.zip, true); + }); + + it('provides the expected source task flow', function () { + const tasks = wixCSVSource.getFullTaskList({ + scrape: ['none'], + url: 'https://example.com', + zip: false, + cache: true + }); + + assert.deepEqual(tasks.map(task => task.title), [ + 'Initializing Workspace', + 'Read Wix CSV content', + 'Fetch existing Ghost users', + 'Build Link Map', + 'Format data as Ghost JSON', + 'Fetch images via AssetScraper', + 'Update links in content via LinkFixer', + 'Convert HTML -> Lexical', + 'Write Ghost import JSON File', + 'Write Ghost import zip', + 'Clearing cached files' + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24b342358..1138058b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,6 +1000,40 @@ importers: specifier: 6.0.3 version: 6.0.3 + packages/mg-wix-csv: + dependencies: + '@tryghost/errors': + specifier: 3.0.3 + version: 3.0.3 + '@tryghost/kg-default-cards': + specifier: 10.2.10 + version: 10.2.10 + '@tryghost/mg-fs-utils': + specifier: workspace:* + version: link:../mg-fs-utils + '@tryghost/string': + specifier: 0.3.1 + version: 0.3.1 + simple-dom: + specifier: 1.4.0 + version: 1.4.0 + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + '@typescript-eslint/parser': + specifier: 8.59.4 + version: 8.59.4(eslint@8.57.1)(typescript@6.0.3) + c8: + specifier: 11.0.0 + version: 11.0.0 + eslint: + specifier: 8.57.1 + version: 8.57.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + packages/mg-wp-api: dependencies: '@tryghost/debug': @@ -1168,6 +1202,9 @@ importers: '@tryghost/mg-webscraper': specifier: workspace:* version: link:../mg-webscraper + '@tryghost/mg-wix-csv': + specifier: workspace:* + version: link:../mg-wix-csv '@tryghost/mg-wp-api': specifier: workspace:* version: link:../mg-wp-api From 77939efbcf52e0ba72bf3d33abe3e7ecd1987821 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 18:21:08 +0100 Subject: [PATCH 2/7] Escape asset scraping domains --- packages/migrate/sources/wix-csv.js | 18 +++++-- packages/migrate/test/wix-csv-command.test.js | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/migrate/sources/wix-csv.js b/packages/migrate/sources/wix-csv.js index b02ed7241..4f87eab43 100644 --- a/packages/migrate/sources/wix-csv.js +++ b/packages/migrate/sources/wix-csv.js @@ -9,6 +9,13 @@ import {makeTaskRunner} from '@tryghost/listr-smart-renderer'; import {createGhostUserTasks} from '@tryghost/mg-ghost-authors'; import prettyMilliseconds from 'pretty-ms'; +// Small dependency seam so source initialization can be tested without real cache/scraper setup. +const dependencies = { + AssetScraper: MgAssetScraper, + FileCache: fsUtils.FileCache, + LinkFixer: MgLinkFixer +}; + const initialize = (options) => { return { title: 'Initializing Workspace', @@ -19,17 +26,17 @@ const initialize = (options) => { }; ctx.options.cacheName = options.cacheName || fsUtils.utils.cacheNameFromPath(ctx.options.url); - ctx.fileCache = new fsUtils.FileCache(`wix-csv-${ctx.options.cacheName}`, { + ctx.fileCache = new dependencies.FileCache(`wix-csv-${ctx.options.cacheName}`, { tmpPath: ctx.options.tmpPath }); - ctx.assetScraper = new MgAssetScraper(ctx.fileCache, { + ctx.assetScraper = new dependencies.AssetScraper(ctx.fileCache, { domains: [ - 'http://static.wixstatic.com', - 'https://static.wixstatic.com' + 'http://static\\.wixstatic\\.com', + 'https://static\\.wixstatic\\.com' ] }, ctx); await ctx.assetScraper.init(); - ctx.linkFixer = new MgLinkFixer(); + ctx.linkFixer = new dependencies.LinkFixer(); task.output = `Workspace initialized at ${ctx.fileCache.cacheDir}`; } @@ -165,6 +172,7 @@ const getTaskRunner = (options) => { }; export default { + dependencies, initialize, getFullTaskList, getTaskRunner diff --git a/packages/migrate/test/wix-csv-command.test.js b/packages/migrate/test/wix-csv-command.test.js index d29911cf5..56d0a9069 100644 --- a/packages/migrate/test/wix-csv-command.test.js +++ b/packages/migrate/test/wix-csv-command.test.js @@ -38,4 +38,53 @@ describe('Wix CSV command', function () { 'Clearing cached files' ]); }); + + it('uses escaped Wix static media domains for asset matching', async function () { + const calls = []; + const ctx = { + options: { + scrape: ['assets'], + url: 'https://example.com', + tmpPath: null + }, + errors: [], + linkFixer: null + }; + const task = {}; + const OriginalFileCache = wixCSVSource.dependencies.FileCache; + const OriginalAssetScraper = wixCSVSource.dependencies.AssetScraper; + const OriginalLinkFixer = wixCSVSource.dependencies.LinkFixer; + + wixCSVSource.dependencies.FileCache = class { + constructor(name, options) { + this.name = name; + this.options = options; + } + + get cacheDir() { + return '/tmp/wix-csv-test'; + } + }; + wixCSVSource.dependencies.AssetScraper = class { + constructor(fileCache, options) { + calls.push({fileCache, options}); + } + + async init() {} + }; + wixCSVSource.dependencies.LinkFixer = class {}; + + try { + await wixCSVSource.initialize(ctx.options).task(ctx, task); + } finally { + wixCSVSource.dependencies.FileCache = OriginalFileCache; + wixCSVSource.dependencies.AssetScraper = OriginalAssetScraper; + wixCSVSource.dependencies.LinkFixer = OriginalLinkFixer; + } + + assert.deepEqual(calls[0].options.domains, [ + 'http://static\\.wixstatic\\.com', + 'https://static\\.wixstatic\\.com' + ]); + }); }); From e883e96bb64ff586204d274af78cb1acd00f65e8 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 18:23:00 +0100 Subject: [PATCH 3/7] Throw error for empty CSV files --- packages/mg-wix-csv/src/lib/mapper.ts | 2 +- packages/mg-wix-csv/src/test/mapper.test.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/mg-wix-csv/src/lib/mapper.ts b/packages/mg-wix-csv/src/lib/mapper.ts index c7c31880f..060b35b7f 100644 --- a/packages/mg-wix-csv/src/lib/mapper.ts +++ b/packages/mg-wix-csv/src/lib/mapper.ts @@ -192,7 +192,7 @@ const mapContent = async (args: {options: any}) => { const mappedPosts = await mapPosts({pathToFile: args.options.posts, options: args.options}); if (mappedPosts.length < 1) { - return new errors.NoContentError({message: 'Input file is empty'}); + throw new errors.NoContentError({message: 'Input file is empty'}); } output.posts = mappedPosts; diff --git a/packages/mg-wix-csv/src/test/mapper.test.ts b/packages/mg-wix-csv/src/test/mapper.test.ts index 945150dce..f142a9410 100644 --- a/packages/mg-wix-csv/src/test/mapper.test.ts +++ b/packages/mg-wix-csv/src/test/mapper.test.ts @@ -157,14 +157,13 @@ describe('Wix CSV mapper', () => { assert.equal(mapped.data.author.data.name, 'Fallback Author'); }); - it('returns a NoContentError for empty CSV files', async () => { - const result = await mapContent({ + it('throws a NoContentError for empty CSV files', async () => { + await assert.rejects(mapContent({ options: { posts: join(fixturesPath, 'empty.csv') } + }), { + message: 'Input file is empty' }); - - assert.ok(result instanceof Error); - assert.equal(result.message, 'Input file is empty'); }); }); From 352a2c1bb8a1772e82a169afc936cc913c32fd31 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 18:25:43 +0100 Subject: [PATCH 4/7] Sanitize href values in links --- packages/mg-wix-csv/src/lib/rich-content.ts | 27 ++++++++++++++++--- .../mg-wix-csv/src/test/rich-content.test.ts | 11 +++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/mg-wix-csv/src/lib/rich-content.ts b/packages/mg-wix-csv/src/lib/rich-content.ts index 1d2c55aaf..755f993bc 100644 --- a/packages/mg-wix-csv/src/lib/rich-content.ts +++ b/packages/mg-wix-csv/src/lib/rich-content.ts @@ -65,6 +65,26 @@ const htmlCard = (html: string) => { ].join('\n'); }; +const sanitizeHref = (value?: string) => { + if (!value) { + return null; + } + + const trimmedValue = value.trim(); + + try { + const url = new URL(trimmedValue); + + if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) { + return null; + } + + return trimmedValue; + } catch (error) { + return null; + } +}; + const paragraphsFromPlainText = (plainText?: string) => { return (plainText || '') .split(/\n{2,}/) @@ -86,7 +106,7 @@ const renderText = (node: WixNode, options: {stripBold?: boolean} = {}) => { const linkText = textParts?.[2] || ''; const trailingWhitespace = textParts?.[3] || ''; let text = escapeHtml(linkText); - const linkUrl = decorations.find(decoration => decoration.type === 'LINK')?.linkData?.link?.url; + const linkUrl = sanitizeHref(decorations.find(decoration => decoration.type === 'LINK')?.linkData?.link?.url); for (const decoration of decorations) { if (decoration.type === 'BOLD' && !options.stripBold) { @@ -142,7 +162,7 @@ const renderImage = (node: WixNode) => { const renderButton = (node: WixNode) => { const text = escapeHtml(node.buttonData?.text || ''); - const url = node.buttonData?.link?.url; + const url = sanitizeHref(node.buttonData?.link?.url); const alignment = (node.buttonData?.containerData?.alignment || 'CENTER').toLowerCase(); const alignmentClass = ['left', 'center', 'right'].includes(alignment) ? `kg-align-${alignment}` : 'kg-align-center'; @@ -245,5 +265,6 @@ export { htmlCard, paragraphsFromPlainText, renderNode, - richContentToHtml + richContentToHtml, + sanitizeHref }; diff --git a/packages/mg-wix-csv/src/test/rich-content.test.ts b/packages/mg-wix-csv/src/test/rich-content.test.ts index e6e2d6a2b..119929452 100644 --- a/packages/mg-wix-csv/src/test/rich-content.test.ts +++ b/packages/mg-wix-csv/src/test/rich-content.test.ts @@ -5,7 +5,8 @@ import { htmlCard, paragraphsFromPlainText, renderNode, - richContentToHtml + richContentToHtml, + sanitizeHref } from '../lib/rich-content.js'; describe('Wix rich content converter', () => { @@ -13,6 +14,12 @@ describe('Wix rich content converter', () => { assert.equal(escapeHtml('It\'s'), '<a href="x">It's</a>'); assert.equal(htmlCard('

    Body

    '), '\n
    \n

    Body

    \n
    \n'); assert.equal(paragraphsFromPlainText('One\nline\n\nTwo'), '

    One
    line

    \n

    Two

    '); + assert.equal(sanitizeHref(' https://example.com/path '), 'https://example.com/path'); + assert.equal(sanitizeHref('mailto:hello@example.com'), 'mailto:hello@example.com'); + assert.equal(sanitizeHref('tel:+15551234567'), 'tel:+15551234567'); + assert.equal(sanitizeHref('javascript:alert(1)'), null); + assert.equal(sanitizeHref('/relative-path'), null); + assert.equal(sanitizeHref(), null); }); it('converts common block and inline nodes', () => { @@ -56,6 +63,7 @@ describe('Wix rich content converter', () => { assert.equal(renderNode({type: 'TEXT'}), ''); assert.equal(renderNode({type: 'TEXT', textData: {text: 'No URL', decorations: [{type: 'LINK'}]}}), 'No URL'); assert.equal(renderNode({type: 'TEXT', textData: {text: ' It ', decorations: [{type: 'LINK', linkData: {link: {url: 'https://example.com'}}}]}}), ' It '); + assert.equal(renderNode({type: 'TEXT', textData: {text: 'Bad link', decorations: [{type: 'LINK', linkData: {link: {url: 'data:text/html,unsafe'}}}]}}), 'Bad link'); }); it('converts images, buttons, and tables', () => { @@ -88,6 +96,7 @@ describe('Wix rich content converter', () => { assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Go', link: {url: 'https://example.com'}, containerData: {alignment: 'CENTER'}}}), ''); assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Left', link: {url: 'https://example.com'}, containerData: {alignment: 'LEFT'}}}), ''); assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Bad alignment', link: {url: 'https://example.com'}, containerData: {alignment: 'AUTO'}}}), ''); + assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'Unsafe', link: {url: 'javascript:alert(1)'}}}), '

    Unsafe

    '); assert.equal(renderNode({type: 'BUTTON', buttonData: {text: 'No link'}}), '

    No link

    '); assert.equal(renderNode({type: 'BUTTON', buttonData: {}}), ''); From fe9ab43105b4c48bbaa55db693e13782b6edf584 Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 18:27:11 +0100 Subject: [PATCH 5/7] Guard against malformed image references --- packages/mg-wix-csv/src/lib/wix-image.ts | 13 +++++++++++-- packages/mg-wix-csv/src/test/wix-image.test.ts | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/mg-wix-csv/src/lib/wix-image.ts b/packages/mg-wix-csv/src/lib/wix-image.ts index 040293f04..a5c721b8b 100644 --- a/packages/mg-wix-csv/src/lib/wix-image.ts +++ b/packages/mg-wix-csv/src/lib/wix-image.ts @@ -19,10 +19,19 @@ const parseWixImageUri = (value: string) => { } const params = new URLSearchParams(match[3] || ''); + let id; + let filename; + + try { + id = decodeURIComponent(match[1]); + filename = decodeURIComponent(match[2]); + } catch { + return null; + } return { - id: decodeURIComponent(match[1]), - filename: decodeURIComponent(match[2]) + id, + filename }; }; diff --git a/packages/mg-wix-csv/src/test/wix-image.test.ts b/packages/mg-wix-csv/src/test/wix-image.test.ts index a86d3ac35..1e6ff737a 100644 --- a/packages/mg-wix-csv/src/test/wix-image.test.ts +++ b/packages/mg-wix-csv/src/test/wix-image.test.ts @@ -30,6 +30,8 @@ describe('Wix image URL helpers', () => { id: 'id', filename: 'file.jpg' }); + assert.equal(parseWixImageUri('wix:image://v1/%ZZ/file.jpg'), null); + assert.equal(parseWixImageUri('wix:image://v1/id/%ZZ.jpg'), null); assert.equal(parseWixImageUri('bad'), null); }); From c1e8da7d2290062670c214c50f6b933880a39a0e Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Tue, 26 May 2026 18:27:35 +0100 Subject: [PATCH 6/7] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ac2a2d481..85276fb53 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Each tool has its own detailed documentation: - [Substack members](https://github.com/TryGhost/migrate/tree/main/packages/mg-substack-members-csv) - [Tiny News](https://github.com/TryGhost/migrate/tree/main/packages/mg-tinynews) - [Tiny News Members](https://github.com/TryGhost/migrate/tree/main/packages/mg-tinynews-members) +- [Wix CSV](https://github.com/TryGhost/migrate/tree/main/packages/mg-wix-csv) - [WordPress API](https://github.com/TryGhost/migrate/tree/main/packages/mg-wp-api) - [WordPress XML](https://github.com/TryGhost/migrate/tree/main/packages/mg-wp-xml) From 34f5561c4a5e13cb091a0d22ce532392306ebc8f Mon Sep 17 00:00:00 2001 From: Paul Davis Date: Wed, 27 May 2026 09:38:31 +0100 Subject: [PATCH 7/7] Add license --- packages/mg-wix-csv/LICENSE | 21 +++++++++++++++++++++ packages/mg-wix-csv/README.md | 5 +++++ 2 files changed, 26 insertions(+) create mode 100644 packages/mg-wix-csv/LICENSE diff --git a/packages/mg-wix-csv/LICENSE b/packages/mg-wix-csv/LICENSE new file mode 100644 index 000000000..efad547e8 --- /dev/null +++ b/packages/mg-wix-csv/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2026 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mg-wix-csv/README.md b/packages/mg-wix-csv/README.md index 0606841b4..30cca6331 100644 --- a/packages/mg-wix-csv/README.md +++ b/packages/mg-wix-csv/README.md @@ -49,3 +49,8 @@ For updated Wix CSV exports, `Categories` may contain readable names such as `Ta - `Plain Content` is used as fallback content when `Rich Content` is empty or invalid. - `Internal ID` is mapped to Ghost `comment_id`. - Wix image URLs are converted to original static media URLs, for example `https://static.wixstatic.com/media/`. + + +# Copyright & License + +Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE).