Skip to content

Add migration package for bearblog.dev #1341

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions commands/bear.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Command} from 'commander';
import source from '@tryghost/mg-bear-export';
import {getProcessOptions} from '../lib/process-options.js';
import logging from '@tryghost/logging';

const command = new Command('bear')
.description('Migrate from Bear Blog')
.requiredOption('--pathToFile <path>', 'Path to Bear Blog CSV export file')
.option('-V, --verbose', 'Show verbose output', false)
.option('--zip', 'Create a zip file', true)
.option('-s, --scrape <mode>', 'Configure scraping tasks', 'all')
.option('--sizeLimit <size>', 'Max size (in MB) for media files', false)
.option('--addTags <tags>', 'Additional tags to add to all posts')
.option('--fallBackHTMLCard', 'Fall back to HTML card if Lexical conversion fails', true)
.option('--cache', 'Persist local cache after migration', true)
.action(async (options) => {
const processOptions = getProcessOptions(options);

processOptions.options = {
...processOptions.options,
pathToFile: options.pathToFile,
fallBackHTMLCard: options.fallBackHTMLCard,
addTags: options.addTags ? options.addTags.split(',').map(tag => tag.trim()) : []
};

try {
await source(processOptions);
} catch (error) {
logging.error(`Failed to migrate from Bear Blog: ${error.message}`);
process.exit(1);
}
});

export default command;
103 changes: 103 additions & 0 deletions packages/mg-bear-export/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Migrate Bear Blog Export

Migrate content from [Bear Blog](http://bearblog.dev/) using the supplied CSV file, and generate a `zip` file you can import into a Ghost installation.

## Install

To install the CLI, which is required for the Usage commands below:

```sh
npm install --global @tryghost/migrate
```

To use this package in your own project:

`npm install @tryghost/mg-bear-export --save`

or

`yarn add @tryghost/mg-bear-export`

## Usage

To run a Bear Blog migration, the required command is:

```sh
migrate bear --pathToFile /path/to/export.csv
```

The CSV file should contain the following required columns:
- `title` - Post title
- `slug` - URL slug
- `published date` - Publication date (ISO 8601 format preferred)
- `content` - Post content in Markdown format

Optional columns include:
- `first published at` - First publication date (falls back to `published date`)
- `all tags` - Tags in format `[tag1, tag2, tag3]`
- `publish` - Publication status (`True` for published, `False` for draft)
- `is page` - Content type (`True` for page, `False` for post)
- `meta description` - SEO description
- `meta image` - Featured image URL

It's possible to pass more options, in order to achieve a better migration file for Ghost:

- **`--pathToFile`** (required)
- Path to a Bear Blog CSV export
- string - default: `null`
- **`-V` `--verbose`**
- bool - default: `false`
- Show verbose output
- **`--zip`**
- bool - default: `true`
- Create a zip file
- **`-s` `--scrape`**
- Configure scraping tasks
- string - default: `all`
- Choices: `all`, `img`, `web`, `media`, `files`, `none`
- **`--sizeLimit`**
- number - default: `false`
- Media files larger than this size (defined in MB [i.e. `5`]) will be flagged as oversize
- **`--addTags`**
- string - default: `null`
- Provide one or more tag names which should be added to every post in this migration.
This is addition to a '#bearblog' tag, which is always added.
- **`--fallBackHTMLCard`**
- bool - default: `true`
- Fall back to convert to HTMLCard, if standard Lexical convert fails
- **`--cache`**
- Persist local cache after migration is complete (Only if `--zip` is `true`)
- bool - default: `true`

A more complex migration command could look like this:

```sh
migrate bear --pathToFile /path/to/export.csv --addTags "imported,migration"
```

This will process all posts from the CSV file and add the tags "imported" and "migration" to each post.

## Develop

This is a mono repository, managed with [lerna](https://lerna.js.org).

Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.

## Run

To run a local development copy, `cd` into this directory, and use `yarn dev` instead of `migrate` like so:

```sh
yarn dev bear --pathToFile /path/to/export.csv
```

## Test

- `yarn lint` run just eslint
- `yarn test` run lint and tests

# Copyright & License

Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).
15 changes: 15 additions & 0 deletions packages/mg-bear-export/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {promises as fs} from 'node:fs';
import process from './lib/process.js';

/**
* Process a Bear blog export CSV file
* @param {Object} params - Migration parameters
* @param {Object} params.options - Migration options
* @returns {Promise<Object>} - Ghost JSON format data
*/
export default async ({options}) => {
const input = await fs.readFile(options.pathToFile, 'utf-8');
const processed = await process.all(input, {options});

return processed;
};
6 changes: 6 additions & 0 deletions packages/mg-bear-export/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
testMatch: ['**/test/**/*.test.js'],
testEnvironment: 'node',
transform: {},
setupFilesAfterEnv: ['jest-extended/all']
};
197 changes: 197 additions & 0 deletions packages/mg-bear-export/lib/process.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {parse} from 'csv-parse/sync';
import {decode} from 'html-entities';
import MarkdownIt from 'markdown-it';
import mgHtmlLexical from '@tryghost/mg-html-lexical';
import {makeTaskRunner} from '@tryghost/listr-smart-renderer';
import errors from '@tryghost/errors';
import fsUtils from '@tryghost/mg-fs-utils';

const md = new MarkdownIt({
html: true
});

// Required fields in the CSV export
const REQUIRED_FIELDS = ['title', 'slug', 'published date', 'content'];

/**
* Parse tags from Bear Blog format
* @param {string} tagString - Tag string in format "[tag1, tag2, tag3]"
* @returns {Array<Object>} Array of tag objects
*/
const parseTags = (tagString) => {
if (!tagString) {
return [];
}

// Remove brackets and split by comma, which Bear Blog adds
const tags = tagString
.slice(1, -1) // Remove [ and ]
.split(',')
.map(tag => tag.trim())
.filter(Boolean) // Remove empty tags
.map(tag => ({
url: tag.trim(),
data: {
name: tag.trim(),
slug: tag.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_#]/g, '')
}
}));

return tags;
};

/**
* Validate CSV data has required fields
* @param {Array<Object>} posts - Array of post objects from CSV
* @throws {errors.ValidationError} If required fields are missing
*/
const validatePosts = (posts) => {
if (!Array.isArray(posts) || posts.length === 0) {
throw new errors.ValidationError({
message: 'Invalid CSV format: No posts found'
});
}

// Check if we have any object with properties
if (!posts.some(post => Object.keys(post).length > 0)) {
throw new errors.ValidationError({
message: 'Invalid CSV format: No valid columns found'
});
}

const firstPost = posts[0];
const missingFields = REQUIRED_FIELDS.filter(field => !(field in firstPost));

if (missingFields.length > 0) {
throw new errors.ValidationError({
message: `Missing required fields: ${missingFields.join(', ')}`
});
}
};

/**
* Validate basic CSV structure
* @param {string} input - CSV content
* @throws {errors.ValidationError} If CSV structure is invalid
*/
const validateCsvStructure = (input) => {
try {
const parsed = parse(input, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: false,
relax_quotes: true,
relax: false
});

if (parsed.length === 0) {
throw new errors.ValidationError({
message: 'Invalid CSV format: File must have a header row and at least one data row'
});
}

const foundFields = new Set(Object.keys(parsed[0]));

// Check if all required fields are present in headers
const hasAllRequiredFields = REQUIRED_FIELDS.every(field => foundFields.has(field));
if (!hasAllRequiredFields) {
throw new errors.ValidationError({
message: 'Invalid CSV format: Missing required columns'
});
}
} catch (error) {
if (error instanceof errors.ValidationError) {
throw error;
}
throw new errors.ValidationError({
message: `Invalid CSV format: ${error.message}`
});
}
};

/**
* Convert Bear blog post to Ghost format
* @param {Object} post - Bear blog post data
* @returns {Promise<Object>} - Ghost post format
*/
const processPost = async (post) => {
try {
// Convert markdown to HTML
const html = md.render(post.content || '');

// Convert HTML to Lexical
const ctx = {
logger: console,
result: {
posts: [{
title: post.title,
slug: post.slug,
html
}]
}
};

const tasks = mgHtmlLexical.convert(ctx, false);
const taskRunner = makeTaskRunner(tasks, {
renderer: 'silent'
});
await taskRunner.run();
const lexical = ctx.result.posts[0].lexical;

return {
url: post.slug,
data: {
title: decode(post.title),
slug: post.slug,
status: post.publish ? 'published' : 'draft',
created_at: post['first published at'] || post['published date'],
published_at: post['published date'],
custom_excerpt: post['meta description'] || '',
feature_image: post['meta image'] || '',
type: post['is page'] === 'True' ? 'page' : 'post',
tags: parseTags(post['all tags']),
lexical
}
};
} catch (error) {
throw new errors.InternalServerError({
message: `Error processing post "${post.title}": ${error.message}`,
context: error
});
}
};

/**
* Process all posts from Bear export
* @param {string} input - CSV content
* @returns {Promise<Object>} - Ghost data
*/
const all = async (input) => {
try {
const posts = fsUtils.csv.parseString(input);

validatePosts(posts);

const processedPosts = await Promise.all(posts.map(post => processPost(post)));

return {posts: processedPosts};
} catch (error) {
if (error instanceof errors.ValidationError || error instanceof errors.InternalServerError) {
throw error;
}

throw new errors.InternalServerError({
message: `Error processing CSV: ${error.message}`,
context: error
});
}
};

export default {
processPost,
all,
parseTags,
validatePosts,
validateCsvStructure
};
Loading