Skip to content
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

Add ability to use nested fields from relations #75

Merged
merged 16 commits into from
Apr 24, 2022
Merged
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
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}`);
}
});
}
kibblerz marked this conversation as resolved.
Show resolved Hide resolved
});
});

// 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