diff --git a/README.md b/README.md index 0a928b5..7386a3d 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,17 @@ Custom URLs will get the following XML attributes: To create dynamic URLs this plugin uses **URL patterns**. A URL pattern is used when adding URL bundles to the sitemap and has the following format: ``` -/pages/[my-uid-field] +/pages/[category.slug]/[my-uid-field] ``` Fields can be injected in the pattern by escaping them with `[]`. +Also relations can be queried in the pattern like so: `[relation.fieldname]`. + The following field types are by default allowed in a pattern: -- id -- uid +- `id` +- `uid` *Allowed field types can be altered with the `allowedFields` config. Read more about it below.* diff --git a/server/services/__tests__/pattern.test.js b/server/services/__tests__/pattern.test.js index a824fbe..4647690 100644 --- a/server/services/__tests__/pattern.test.js +++ b/server/services/__tests__/pattern.test.js @@ -3,27 +3,84 @@ const patternService = require('../pattern'); +global.strapi = { + contentTypes: { + 'another-test-relation:target:api': { + attributes: { + slugField: { + type: 'uid', + }, + textField: { + type: 'text', + }, + }, + }, + }, +}; + describe('Pattern service', () => { + describe('Get allowed fields for a content type', () => { + test('Should return the right fields', () => { + const allowedFields = ['id', 'uid']; + const contentType = { + attributes: { + urlField: { + type: 'uid', + }, + textField: { + type: 'text', + }, + localizations: { + type: 'relation', + target: 'test:target:api', + relation: 'oneToOne', + }, + relation: { + type: 'relation', + target: 'another-test:target:api', + relation: 'oneToMany', + }, + anotherRelation: { + type: 'relation', + target: 'another-test-relation:target:api', + relation: 'oneToOne', + }, + }, + }; + + const result = patternService().getAllowedFields(contentType, allowedFields); + + expect(result).toContain('id'); + expect(result).toContain('urlField'); + expect(result).not.toContain('textField'); + expect(result).toContain('anotherRelation.id'); + expect(result).toContain('anotherRelation.slugField'); + expect(result).not.toContain('anotherRelation.textField'); + }); + }); describe('Get fields from pattern', () => { test('Should return an array of fieldnames extracted from a pattern', () => { - const pattern = '/en/[category]/[slug]'; + const pattern = '/en/[category]/[slug]/[relation.id]'; const result = patternService().getFieldsFromPattern(pattern); - expect(result).toEqual(['category', 'slug']); + expect(result).toEqual(['category', 'slug', 'relation.id']); }); }); describe('Resolve pattern', () => { test('Resolve valid pattern', async () => { - const pattern = '/en/[category]/[slug]'; + const pattern = '/en/[category]/[slug]/[relation.url]'; const entity = { category: 'category-a', slug: 'my-page-slug', + relation: { + url: 'relation-url', + }, }; const result = await patternService().resolvePattern(pattern, entity); - expect(result).toMatch('/en/category-a/my-page-slug'); + expect(result).toMatch('/en/category-a/my-page-slug/relation-url'); }); test('Resolve pattern with missing field', async () => { diff --git a/server/services/core.js b/server/services/core.js index 5098b78..7bed7f7 100644 --- a/server/services/core.js +++ b/server/services/core.js @@ -26,6 +26,13 @@ const getLanguageLinks = async (page, contentType, defaultURL, excludeDrafts) => const links = []; links.push({ lang: page.locale, url: defaultURL }); + const populate = ['localizations'].concat(Object.keys(strapi.contentTypes[contentType].attributes).reduce((prev, current) => { + if (strapi.contentTypes[contentType].attributes[current].type === 'relation') { + prev.push(current); + } + return prev; + }, [])); + await Promise.all(page.localizations.map(async (translation) => { const translationEntity = await strapi.query(contentType).findOne({ where: { @@ -46,8 +53,7 @@ const getLanguageLinks = async (page, contentType, defaultURL, excludeDrafts) => $notNull: true, } : {}, }, - orderBy: 'id', - populate: ['localizations'], + populate, }); if (!translationEntity) return null; @@ -139,6 +145,14 @@ const createSitemapEntries = async () => { // Collection entries. await Promise.all(Object.keys(config.contentTypes).map(async (contentType) => { const excludeDrafts = config.excludeDrafts && strapi.contentTypes[contentType].options.draftAndPublish; + + const populate = ['localizations'].concat(Object.keys(strapi.contentTypes[contentType].attributes).reduce((prev, current) => { + if (strapi.contentTypes[contentType].attributes[current].type === 'relation') { + prev.push(current); + } + return prev; + }, [])); + const pages = await noLimit(strapi.query(contentType), { where: { $or: [ @@ -157,17 +171,16 @@ const createSitemapEntries = async () => { $notNull: true, } : {}, }, + populate, orderBy: 'id', - populate: ['localizations'], }); - // Add formatted sitemap page data to the array. await Promise.all(pages.map(async (page) => { + const pageData = await getSitemapPageData(page, contentType, excludeDrafts); if (pageData) sitemapEntries.push(pageData); })); })); - // Custom entries. await Promise.all(Object.keys(config.customEntries).map(async (customEntry) => { sitemapEntries.push({ @@ -234,6 +247,7 @@ const createSitemap = async () => { }); const sitemapEntries = await createSitemapEntries(); + if (isEmpty(sitemapEntries)) { strapi.log.info(logMessage(`No sitemap XML was generated because there were 0 URLs configured.`)); return; diff --git a/server/services/pattern.js b/server/services/pattern.js index f3692e8..fb96bbd 100644 --- a/server/services/pattern.js +++ b/server/services/pattern.js @@ -1,5 +1,7 @@ 'use strict'; +const { logMessage } = require("../utils"); + /** * Pattern service. */ @@ -8,36 +10,61 @@ * Get all field names allowed in the URL of a given content type. * * @param {string} contentType - The content type. + * @param {array} allowedFields - Override the allowed fields. * - * @returns {string} The fields. + * @returns {string[]} The fields. */ -const getAllowedFields = async (contentType) => { +const getAllowedFields = (contentType, allowedFields = []) => { const fields = []; - strapi.config.get('plugin.sitemap.allowedFields').map((fieldType) => { + const fieldTypes = allowedFields.length > 0 ? allowedFields : strapi.config.get('plugin.sitemap.allowedFields'); + fieldTypes.map((fieldType) => { Object.entries(contentType.attributes).map(([fieldName, field]) => { - if (field.type === fieldType) { + if (field.type === fieldType && field.type !== 'relation') { fields.push(fieldName); + } else if ( + field.type === 'relation' + && field.target + && field.relation.endsWith('ToOne') // TODO: implement `ToMany` relations (#78). + && fieldName !== 'localizations' + && fieldName !== 'createdBy' + && fieldName !== 'updatedBy' + ) { + const relation = strapi.contentTypes[field.target]; + + if ( + fieldTypes.includes('id') + && !fields.includes(`${fieldName}.id`) + ) { + fields.push(`${fieldName}.id`); + } + + Object.entries(relation.attributes).map(([subFieldName, subField]) => { + if (subField.type === fieldType) { + fields.push(`${fieldName}.${subFieldName}`); + } + }); } }); }); // Add id field manually because it is not on the attributes object of a content type. - if (strapi.config.get('plugin.sitemap.allowedFields').includes('id')) { + if (fieldTypes.includes('id')) { fields.push('id'); } return fields; }; + /** * Get all fields from a pattern. * * @param {string} pattern - The pattern. * - * @returns {array} The fields. + * @returns {array} The fields.\[([\w\d\[\]]+)\] */ const getFieldsFromPattern = (pattern) => { - let fields = pattern.match(/[[\w\d]+]/g); // Get all substrings between [] as array. + let fields = pattern.match(/[[\w\d.]+]/g); // Get all substrings between [] as array. fields = fields.map((field) => RegExp(/(?<=\[)(.*?)(?=\])/).exec(field)[0]); // Strip [] from string. return fields; }; @@ -50,11 +77,20 @@ const getFieldsFromPattern = (pattern) => { * * @returns {string} The path. */ -const resolvePattern = async (pattern, entity) => { + + const resolvePattern = async (pattern, entity) => { const fields = getFieldsFromPattern(pattern); fields.map((field) => { - pattern = pattern.replace(`[${field}]`, entity[field] || ''); + const relationalField = field.split('.').length > 1 ? field.split('.') : null; + + if (!relationalField) { + pattern = pattern.replace(`[${field}]`, entity[field] || ''); + } else if (Array.isArray(entity[relationalField[0]])) { + strapi.log.error(logMessage('Something went wrong whilst resolving the pattern.')); + } else if (typeof entity[relationalField[0]] === 'object') { + pattern = pattern.replace(`[${field}]`, entity[relationalField[0]] && entity[relationalField[0]][relationalField[1]] ? entity[relationalField[0]][relationalField[1]] : ''); + } }); pattern = pattern.replace(/([^:]\/)\/+/g, "$1"); // Remove duplicate forward slashes. @@ -76,28 +112,29 @@ const validatePattern = async (pattern, allowedFieldNames) => { if (!pattern) { return { valid: false, - message: "Pattern can not be empty", + message: 'Pattern can not be empty', }; } - const preCharCount = pattern.split("[").length - 1; - const postCharount = pattern.split("]").length - 1; + const preCharCount = pattern.split('[').length - 1; + const postCharount = pattern.split(']').length - 1; if (preCharCount < 1 || postCharount < 1) { return { valid: false, - message: "Pattern should contain at least one field", + message: 'Pattern should contain at least one field', }; } if (preCharCount !== postCharount) { return { valid: false, - message: "Fields in the pattern are not escaped correctly", + message: 'Fields in the pattern are not escaped correctly', }; } let fieldsAreAllowed = true; + getFieldsFromPattern(pattern).map((field) => { if (!allowedFieldNames.includes(field)) fieldsAreAllowed = false; }); @@ -105,13 +142,13 @@ const validatePattern = async (pattern, allowedFieldNames) => { if (!fieldsAreAllowed) { return { valid: false, - message: "Pattern contains forbidden fields", + message: 'Pattern contains forbidden fields', }; } return { valid: true, - message: "Valid pattern", + message: 'Valid pattern', }; };