Skip to content

Commit

Permalink
Merge pull request #75 from kibblerz/master
Browse files Browse the repository at this point in the history
Add ability to use nested fields from relations
  • Loading branch information
boazpoolman authored Apr 24, 2022
2 parents 74735e8 + ecedd29 commit fa428d9
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 28 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down
65 changes: 61 additions & 4 deletions server/services/__tests__/pattern.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
24 changes: 19 additions & 5 deletions server/services/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -46,8 +53,7 @@ const getLanguageLinks = async (page, contentType, defaultURL, excludeDrafts) =>
$notNull: true,
} : {},
},
orderBy: 'id',
populate: ['localizations'],
populate,
});

if (!translationEntity) return null;
Expand Down Expand Up @@ -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: [
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down
69 changes: 53 additions & 16 deletions server/services/pattern.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const { logMessage } = require("../utils");

/**
* Pattern service.
*/
Expand All @@ -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;
};
Expand All @@ -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.
Expand All @@ -76,42 +112,43 @@ 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;
});

if (!fieldsAreAllowed) {
return {
valid: false,
message: "Pattern contains forbidden fields",
message: 'Pattern contains forbidden fields',
};
}

return {
valid: true,
message: "Valid pattern",
message: 'Valid pattern',
};
};

Expand Down

0 comments on commit fa428d9

Please sign in to comment.