Skip to content

Commit 5b28bd8

Browse files
authored
Added custom taxonomy support and WP migration improvements (#1641)
- Added --customTaxonomies flag to wp-api and wp-xml commands to import custom taxonomy terms as Ghost tags - Handled custom taxonomies differently per package: wp-api uses wp:term embedded data, wp-xml uses category domain attributes - Batched processPosts in wp-api to avoid memory issues on large sites - Replaced concat with spread in wp-api fetch for better performance - Added Podigee podcast player embed support in wp-api processor - Guarded against null/undefined YouTube URLs in mg-utils - Added tests for custom taxonomy handling in both wp-api and wp-xml
1 parent 7bda4ff commit 5b28bd8

9 files changed

Lines changed: 169 additions & 8 deletions

File tree

‎packages/mg-utils/src/lib/youtube-utils.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
const getYouTubeID = (videoUrl: string): string => {
1+
const getYouTubeID = (videoUrl: string | null | undefined): string => {
2+
if (!videoUrl) {
3+
return '';
4+
}
25
const arr = videoUrl.split(/(vi\/|v%3D|v=|\/v\/|youtu\.be\/|\/embed\/)/);
36
return undefined !== arr[2] ? arr[2].split(/[^\w-]/i)[0] : arr[0];
47
};

‎packages/mg-utils/src/test/youtube-utils.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,12 @@ describe('getYouTubeID', function () {
3434
it('returns the input string when no ID is found', function () {
3535
assert.equal(getYouTubeID('not-a-youtube-url'), 'not-a-youtube-url');
3636
});
37+
38+
it('returns empty string for null input', function () {
39+
assert.equal(getYouTubeID(null), '');
40+
});
41+
42+
it('returns empty string for undefined input', function () {
43+
assert.equal(getYouTubeID(undefined), '');
44+
});
3745
});

‎packages/mg-wp-api/lib/fetch.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const buildTasks = (fileCache, tasks, api, type, limit, isAuthRequest, postsBefo
119119
// Treat all types as posts, except users
120120
let resultType = (type !== 'users') ? 'posts' : type;
121121

122-
ctx.result[resultType] = ctx.result[resultType].concat(response);
122+
ctx.result[resultType].push(...response);
123123
} catch (err) {
124124
// eslint-disable-next-line no-console
125125
console.error(`Failed to fetch ${type}, page ${page} of ${totalPages}`, err);

‎packages/mg-wp-api/lib/processor.js‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,12 @@ const processTerm = (wpTerm) => {
125125
};
126126
};
127127

128-
const processTerms = (wpTerms, fetchTags) => {
128+
const processTerms = (wpTerms, fetchTags, customTaxonomies) => {
129129
let categories = [];
130130
let tags = [];
131+
let customTerms = [];
132+
133+
const allowedCustomTaxonomies = customTaxonomies || [];
131134

132135
wpTerms.forEach((taxonomy) => {
133136
taxonomy.forEach((term) => {
@@ -138,10 +141,14 @@ const processTerms = (wpTerms, fetchTags) => {
138141
if (fetchTags && term.taxonomy === 'post_tag') {
139142
tags.push(processTerm(term));
140143
}
144+
145+
if (allowedCustomTaxonomies.includes(term.taxonomy)) {
146+
customTerms.push(processTerm(term));
147+
}
141148
});
142149
});
143150

144-
return categories.concat(tags);
151+
return categories.concat(tags).concat(customTerms);
145152
};
146153

147154
// Extract co-authors from wp:term data (used by Co-Authors Plus and PublishPress Authors plugins)
@@ -693,6 +700,17 @@ const processContent = async ({html, excerptSelector, featureImageSrc = false, f
693700

694701
await Promise.all(libsynPodcasts);
695702

703+
for (const el of parsed.$('script.podigee-podcast-player')) {
704+
let configUrl = el.getAttribute('data-configuration');
705+
if (!configUrl) {
706+
continue;
707+
}
708+
709+
let embedHTML = `<!--kg-card-begin: html--><iframe src="${configUrl}" style="width:100%;height:200px;" frameborder="0" scrolling="no"></iframe><!--kg-card-end: html-->`;
710+
711+
replaceWith(el, embedHTML);
712+
}
713+
696714
let wpEmbeds = parsed.$('.wp-block-embed.is-type-wp-embed').map(async (el) => {
697715
const blockquoteLink = el.querySelector('blockquote a');
698716
const bookmarkHref = blockquoteLink ? blockquoteLink.getAttribute('href') : null;
@@ -1109,7 +1127,7 @@ const processContent = async ({html, excerptSelector, featureImageSrc = false, f
11091127
* }
11101128
*/
11111129
const processPost = async (wpPost, users, options = {}, errors, fileCache) => { // eslint-disable-line no-shadow
1112-
let {tags: fetchTags, addTag, excerptSelector, excerpt, featureImageCaption} = options;
1130+
let {tags: fetchTags, addTag, excerptSelector, excerpt, featureImageCaption, customTaxonomies} = options;
11131131

11141132
let slug = wpPost.slug;
11151133
let titleText = parseFragment(wpPost.title.rendered).text();
@@ -1180,7 +1198,7 @@ const processPost = async (wpPost, users, options = {}, errors, fileCache) => {
11801198

11811199
if (wpPost._embedded && wpPost._embedded['wp:term']) {
11821200
const wpTerms = wpPost._embedded['wp:term'];
1183-
post.data.tags = processTerms(wpTerms, fetchTags);
1201+
post.data.tags = processTerms(wpTerms, fetchTags, customTaxonomies);
11841202

11851203
post.data.tags.push({
11861204
url: 'migrator-added-tag',
@@ -1263,7 +1281,16 @@ const processPosts = async (posts, users, options, errors, fileCache) => { // es
12631281
posts = foundPosts;
12641282
}
12651283

1266-
return Promise.all(posts.map(post => processPost(post, users, options, errors, fileCache)));
1284+
const BATCH_SIZE = 100;
1285+
const results = [];
1286+
1287+
for (let i = 0; i < posts.length; i += BATCH_SIZE) {
1288+
const batch = posts.slice(i, i + BATCH_SIZE);
1289+
const batchResults = await Promise.all(batch.map(post => processPost(post, users, options, errors, fileCache)));
1290+
results.push(...batchResults);
1291+
}
1292+
1293+
return results;
12671294
};
12681295

12691296
const processAuthors = (authors) => {

‎packages/mg-wp-api/test/process.test.js‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,68 @@ describe('Process WordPress REST API JSON', function () {
194194
assert.equal(data.tags[5].data.name, '#wp');
195195
});
196196

197+
it('Can include custom taxonomy terms as tags', async function () {
198+
const fixture = structuredClone(singlePostFixture);
199+
fixture._embedded['wp:term'].push([
200+
{id: 350, link: 'https://mysite.com/country/czech-republic', name: 'Czech Republic', slug: 'czech-republic', taxonomy: 'country'},
201+
{id: 12, link: 'https://mysite.com/country/romania', name: 'Romania', slug: 'romania', taxonomy: 'country'}
202+
]);
203+
fixture._embedded['wp:term'].push([
204+
{id: 500, link: 'https://mysite.com/city/prague', name: 'Prague', slug: 'prague', taxonomy: 'city'}
205+
]);
206+
207+
const users = [];
208+
const options = {tags: true, addTag: null, featureImage: 'featuredmedia', url: 'https://mysite.com', customTaxonomies: ['country']};
209+
const post = await processor.processPost(fixture, users, options);
210+
211+
const tagSlugs = post.data.tags.map(t => t.data.slug);
212+
assert.ok(tagSlugs.includes('czech-republic'), 'should include country term czech-republic');
213+
assert.ok(tagSlugs.includes('romania'), 'should include country term romania');
214+
assert.ok(!tagSlugs.includes('prague'), 'should not include city term when not in customTaxonomies');
215+
});
216+
217+
it('Does not include custom taxonomy terms when customTaxonomies is not set', async function () {
218+
const fixture = structuredClone(singlePostFixture);
219+
fixture._embedded['wp:term'].push([
220+
{id: 350, link: 'https://mysite.com/country/czech-republic', name: 'Czech Republic', slug: 'czech-republic', taxonomy: 'country'}
221+
]);
222+
223+
const users = [];
224+
const options = {tags: true, addTag: null, featureImage: 'featuredmedia', url: 'https://mysite.com'};
225+
const post = await processor.processPost(fixture, users, options);
226+
227+
const tagSlugs = post.data.tags.map(t => t.data.slug);
228+
assert.ok(!tagSlugs.includes('czech-republic'), 'should not include country term when customTaxonomies is not set');
229+
});
230+
231+
it('processTerms handles custom taxonomies directly', function () {
232+
const wpTerms = [
233+
[{id: 1, link: 'https://mysite.com/category/news', name: 'News', slug: 'news', taxonomy: 'category'}],
234+
[{id: 2, link: 'https://mysite.com/tag/tech', name: 'Tech', slug: 'tech', taxonomy: 'post_tag'}],
235+
[{id: 350, link: 'https://mysite.com/country/romania', name: 'Romania', slug: 'romania', taxonomy: 'country'}]
236+
];
237+
238+
const result = processor.processTerms(wpTerms, true, ['country']);
239+
assert.equal(result.length, 3);
240+
assert.equal(result[0].data.slug, 'news');
241+
assert.equal(result[1].data.slug, 'tech');
242+
assert.equal(result[2].data.slug, 'romania');
243+
});
244+
245+
it('processTerms handles multiple custom taxonomies', function () {
246+
const wpTerms = [
247+
[{id: 1, link: 'https://mysite.com/category/news', name: 'News', slug: 'news', taxonomy: 'category'}],
248+
[{id: 350, link: 'https://mysite.com/country/romania', name: 'Romania', slug: 'romania', taxonomy: 'country'}],
249+
[{id: 500, link: 'https://mysite.com/city/bucharest', name: 'Bucharest', slug: 'bucharest', taxonomy: 'city'}]
250+
];
251+
252+
const result = processor.processTerms(wpTerms, false, ['country', 'city']);
253+
assert.equal(result.length, 3);
254+
assert.equal(result[0].data.slug, 'news');
255+
assert.equal(result[1].data.slug, 'romania');
256+
assert.equal(result[2].data.slug, 'bucharest');
257+
});
258+
197259
it('Can remove first image in post if same as feature image', async function () {
198260
const users = [];
199261
const options = {tags: true, addTag: null, featureImage: 'featuredmedia', url: 'https://mysite.com', cpt: 'mycpt'};

‎packages/mg-wp-xml/lib/process.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ const processTags = (categories, options = {}) => {
129129
'legal_regulatory'
130130
];
131131

132+
if (options.customTaxonomies && Array.isArray(options.customTaxonomies)) {
133+
allowedTerms = allowedTerms.concat(options.customTaxonomies);
134+
}
135+
132136
const categoriesArray = ensureArray(categories);
133137

134138
for (const taxonomy of categoriesArray) {
@@ -146,7 +150,6 @@ const processTags = (categories, options = {}) => {
146150
}
147151
});
148152
} else if (includeTags && allowedTerms.includes(domain)) {
149-
// Only include tags if options.tags is not false
150153
tags.push({
151154
url: `/tag/${nicename}`,
152155
data: {

‎packages/mg-wp-xml/test/process.test.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,52 @@ describe('Process', function () {
236236
assert.deepEqual(author.data.slug, 'hermione-example-com');
237237
});
238238

239+
it('Can include custom taxonomy terms as tags', async function () {
240+
const categories = [
241+
{'@_domain': 'category', '@_nicename': 'company-news', '#text': 'Company News'},
242+
{'@_domain': 'post_tag', '@_nicename': 'programming', '#text': 'Programming'},
243+
{'@_domain': 'country', '@_nicename': 'czech-republic', '#text': 'Czech Republic'},
244+
{'@_domain': 'city', '@_nicename': 'prague', '#text': 'Prague'}
245+
];
246+
247+
const tags = process.processTags(categories, {tags: true, customTaxonomies: ['country']});
248+
const slugs = tags.map(t => t.data.slug);
249+
250+
assert.ok(slugs.includes('company-news'), 'should include category');
251+
assert.ok(slugs.includes('programming'), 'should include post_tag');
252+
assert.ok(slugs.includes('czech-republic'), 'should include custom taxonomy term');
253+
assert.ok(!slugs.includes('prague'), 'should not include taxonomy not in customTaxonomies');
254+
});
255+
256+
it('Does not include custom taxonomy terms when customTaxonomies is not set', function () {
257+
const categories = [
258+
{'@_domain': 'category', '@_nicename': 'company-news', '#text': 'Company News'},
259+
{'@_domain': 'country', '@_nicename': 'czech-republic', '#text': 'Czech Republic'}
260+
];
261+
262+
const tags = process.processTags(categories, {tags: true});
263+
const slugs = tags.map(t => t.data.slug);
264+
265+
assert.ok(slugs.includes('company-news'), 'should include category');
266+
assert.ok(!slugs.includes('czech-republic'), 'should not include custom taxonomy when not configured');
267+
});
268+
269+
it('Can include multiple custom taxonomies', function () {
270+
const categories = [
271+
{'@_domain': 'category', '@_nicename': 'news', '#text': 'News'},
272+
{'@_domain': 'country', '@_nicename': 'romania', '#text': 'Romania'},
273+
{'@_domain': 'city', '@_nicename': 'bucharest', '#text': 'Bucharest'}
274+
];
275+
276+
const tags = process.processTags(categories, {tags: true, customTaxonomies: ['country', 'city']});
277+
const slugs = tags.map(t => t.data.slug);
278+
279+
assert.equal(tags.length, 3);
280+
assert.ok(slugs.includes('news'));
281+
assert.ok(slugs.includes('romania'));
282+
assert.ok(slugs.includes('bucharest'));
283+
});
284+
239285
it('Can extract featured image alt text and caption', async function () {
240286
let ctx = {
241287
options: {

‎packages/migrate/commands/wp-api.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ const options = [
163163
defaultValue: null,
164164
desc: 'The slug(s) of custom post type(s), e.g. `resources,newsletters`'
165165
},
166+
{
167+
type: 'array',
168+
flags: '--customTaxonomies',
169+
defaultValue: null,
170+
desc: 'The slug(s) of custom taxonomy/taxonomies to import as Ghost tags, e.g. `country,city`'
171+
},
166172
{
167173
type: 'boolean',
168174
flags: '--excerpt',

‎packages/migrate/commands/wp-xml.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ const options = [
116116
defaultValue: null,
117117
desc: 'The slug(s) of custom post type(s), e.g. `resources,newsletters`'
118118
},
119+
{
120+
type: 'array',
121+
flags: '--customTaxonomies',
122+
defaultValue: null,
123+
desc: 'The slug(s) of custom taxonomy/taxonomies to import as Ghost tags, e.g. `country,city`'
124+
},
119125
{
120126
type: 'boolean',
121127
flags: '--excerpt',

0 commit comments

Comments
 (0)