Compare commits

...

21 Commits

Author SHA1 Message Date
DanielPantle
f7843ee318
docs: add note regarding compound slug (#123)
* Update README.md

Include note that multiple fields are possible as reference

* update wording

---------

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>
2024-01-16 23:28:54 -05:00
daedalus
65c3a4e5f0
chore(release): 2.3.8 (#121) 2023-11-19 11:19:34 -05:00
daedalus
66b9d2789c
fix(graphql): errors should not result in internal error (#120) 2023-11-19 11:17:44 -05:00
daedalus
68ea256e5b
chore(release): 2.3.7 (#119) 2023-11-14 19:57:32 -05:00
daedalus
5752ccfc93 fix(controller): errors should not result in internal error 2023-11-14 19:55:55 -05:00
daedalus
f7ba4cce37 chore: update deps 2023-11-14 19:55:55 -05:00
daedalus
430f90b992 refactor(slugController): internalise transform
Resolves #108
2023-11-14 19:55:55 -05:00
daedalus
e45d80d826 2.3.6 2023-10-22 13:52:05 -04:00
daedalus
eead698ec7 chore(workflow): update node version to 18 2023-10-22 13:52:05 -04:00
daedalus
78229365a5 chore(pkg): update engine restriction 2023-10-22 13:52:05 -04:00
daedalus
b668ceed26 chore(pkg): update deps to latest 2023-10-22 13:52:05 -04:00
daedalus
ddf130a110
chore(release): 2.3.5 (#111) 2023-09-28 20:25:54 -04:00
daedalus
7a41a004e1
chore(deps): update to latest (#110) 2023-09-28 20:24:05 -04:00
daedalus
691e39feb0
fix(controller): use correct import for transformResponse (#109) 2023-09-28 20:18:25 -04:00
daedalus
0d446655ec
chore(release): 2.3.4 (#106) 2023-08-23 22:43:48 -04:00
Amol Mungusmare
8f8f375fb0
fix(buildSlug): respect separator for appended count (#105)
* feat: added seperator to count

* fix(buildSlug): use supported features for object property

* fix(buildSlug:separator): it should be `separator` not `seperator`

---------

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>
2023-08-23 00:55:22 -04:00
daedalus
77a1c81978
chore(release): 2.3.3 (#100) 2023-07-07 16:49:00 -04:00
Max Starr
38292fedd3
fix: broken imports due to change in strapi (#99)
* refactor broken imports

* update package info

* update package name

* fix other broken import

* fix(pkg.json): revert changes

* fix(santizeOutput): update import

* fix: namespace error imports

---------

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>
2023-07-07 16:46:37 -04:00
daedalus
8943c3fcbe
chore(release): 2.3.2 (#88) 2022-12-07 21:32:31 -05:00
daedalus
2d53829e94
fix(slugifyWithCount): persist slug count (#82)
* fix(slugification): persist slug count

* fix(slugification): ensure correct count used for old plugin versions and projects with existing slugs

* fix(slug CT): do not show plugin CT in content manager

* fix(syncSlugCount): ensure we have slugs to sync for createMany

* refactor(buildSlug): use early return
2022-12-07 21:19:12 -05:00
daedalus
9c336ebb1d
fix(engines): incorrect engine range (#87)
Resolves #86
2022-12-07 20:59:04 -05:00
18 changed files with 9215 additions and 263 deletions

View File

@ -13,10 +13,10 @@ jobs:
- name: Checkout branch - name: Checkout branch
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install Node v14 - name: Install Node v18
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: '14.x' node-version: '18.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Install Yarn - name: Install Yarn

View File

@ -54,7 +54,9 @@ module.exports = ({ env }) => ({
This will listen for any record created or updated in the article content type and set a slugified value for the slug field automatically based on the title field. This will listen for any record created or updated in the article content type and set a slugified value for the slug field automatically based on the title field.
> Note that if you want to rewrite the same field (so `title` is both a reference and a slug) then you just put `title` for both the `field` and `references` properties. > Note: To rewrite the same field (e.g. `title` is both a reference and a slug) use `title` as the `field` and `references` value.
> Note: Compound slugs (basing the slug on multiple fields) can be achieved by passing an array of fields to the `references` property (e.g. `references: ['date','title']`).
**IMPORTANT NOTE**: Make sure any sensitive data is stored in env files. **IMPORTANT NOTE**: Make sure any sensitive data is stored in env files.

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package", "$schema": "https://json.schemastore.org/package",
"name": "strapi-plugin-slugify", "name": "strapi-plugin-slugify",
"version": "2.3.1", "version": "2.3.8",
"description": "A plugin for Strapi Headless CMS that provides the ability to auto slugify a field for any content type.", "description": "A plugin for Strapi Headless CMS that provides the ability to auto slugify a field for any content type.",
"scripts": { "scripts": {
"lint": "eslint . --fix", "lint": "eslint . --fix",
@ -26,20 +26,18 @@
"url": "https://github.com/ComfortablyCoding/strapi-plugin-slugify/issues" "url": "https://github.com/ComfortablyCoding/strapi-plugin-slugify/issues"
}, },
"dependencies": { "dependencies": {
"@sindresorhus/slugify": "1.1.0" "@sindresorhus/slugify": "1.1.0",
}, "@strapi/strapi": "^4.14.0",
"devDependencies": { "@strapi/utils": "^4.14.0",
"eslint": "^8.8.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-node": "^11.1.0",
"prettier": "^2.5.1"
},
"peerDependencies": {
"@strapi/strapi": "^4.0.7",
"@strapi/utils": "^4.0.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"yup": "^0.32.9" "yup": "^0.32.9"
}, },
"devDependencies": {
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-node": "^11.1.0",
"prettier": "^3.1.0"
},
"strapi": { "strapi": {
"displayName": "Slugify", "displayName": "Slugify",
"name": "slugify", "name": "slugify",
@ -47,7 +45,7 @@
"kind": "plugin" "kind": "plugin"
}, },
"engines": { "engines": {
"node": ">=12.x.x <=16.x.x", "node": ">=18.0.0 <=20.x.x",
"npm": ">=6.0.0" "npm": ">=6.0.0"
}, },
"keywords": [ "keywords": [

View File

@ -2,8 +2,15 @@
const { buildSettings } = require('./buildSettings'); const { buildSettings } = require('./buildSettings');
const { setupLifecycles } = require('./setupLifecycles'); const { setupLifecycles } = require('./setupLifecycles');
const { syncSlugCount } = require('./syncSlugCount');
module.exports = async () => {
const settings = await buildSettings();
if (settings.slugifyWithCount) {
// Ensure correct count used for old plugin versions and projects with existing slugs.
await syncSlugCount(settings);
}
module.exports = async ({ strapi }) => {
const settings = await buildSettings(strapi);
setupLifecycles(settings); setupLifecycles(settings);
}; };

View File

@ -0,0 +1,75 @@
'use strict';
const syncSlugCount = async (settings) => {
const entries = await strapi.entityService.findMany('plugin::slugify.slug', {
filters: { createdAt: { $gt: 1 } },
});
// if entries aready present we can skip sync
if (entries && entries.length) {
return;
}
strapi.log.info('[slugify] syncing slug count for registered content types');
const slugs = new Map();
// chec slugs in each reigistered model
for (const uid in settings.modelsByUID) {
if (!Object.hasOwnProperty.call(settings.modelsByUID, uid)) {
continue;
}
const model = settings.modelsByUID[uid];
// using db query to avoid the need to check if CT has draftAndPublish enabled
const modelEntries = await strapi.db.query(model.uid).findMany({
filters: { createdAt: { $gt: 1 } },
});
strapi.log.info(`[slugify] syncing slug count for ${model.uid}`);
for (const entry of modelEntries) {
const slug = entry[model.field];
if (!slug) {
continue;
}
const record = slugs.get(getNonAppendedSlug(slug));
if (!record) {
slugs.set(slug, { slug, count: 1 });
continue;
}
slugs.set(record.slug, { slug: record.slug, count: record.count + 1 });
}
strapi.log.info(`[slugify] sync for ${model.uid} completed`);
}
if (slugs.size) {
// create all required records
const createResponse = await strapi.db.query('plugin::slugify.slug').createMany({
data: [...slugs.values()],
});
strapi.log.info(
`[slugify] ${createResponse.count} out of ${slugs.size} slugs synced successfully`
);
} else {
strapi.log.info('[slugify] No syncable slugs found');
}
};
// removes any appended number from a slug/string if found
const getNonAppendedSlug = (slug) => {
const match = slug.match('[\\-]{1}[\\d]+$');
if (!match) {
return slug;
}
return slug.replace(match[0], '');
};
module.exports = {
syncSlugCount,
};

View File

@ -0,0 +1,9 @@
'use strict';
const slugSchema = require('./slug/schema.json');
module.exports = {
slug: {
schema: slugSchema,
},
};

View File

@ -0,0 +1,29 @@
{
"kind": "collectionType",
"collectionName": "slugs",
"info": {
"singularName": "slug",
"pluralName": "slugs",
"displayName": "slug"
},
"options": {
"draftAndPublish": false,
"comment": ""
},
"pluginOptions": {
"content-manager": {
"visible": false
},
"content-type-builder": {
"visible": false
}
},
"attributes": {
"slug": {
"type": "text"
},
"count": {
"type": "integer"
}
}
}

View File

@ -1,12 +1,11 @@
'use strict'; 'use strict';
const _ = require('lodash'); const _ = require('lodash');
const { NotFoundError } = require('@strapi/utils/lib/errors');
const { getPluginService } = require('../utils/getPluginService'); const { getPluginService } = require('../utils/getPluginService');
const { transformResponse } = require('@strapi/strapi/lib/core-api/controller/transform');
const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams'); const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams');
const { sanitizeOutput } = require('../utils/sanitizeOutput'); const { sanitizeOutput } = require('../utils/sanitizeOutput');
const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes'); const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes');
const transform = require('../utils/transform');
module.exports = ({ strapi }) => ({ module.exports = ({ strapi }) => ({
async findSlug(ctx) { async findSlug(ctx) {
@ -14,15 +13,23 @@ module.exports = ({ strapi }) => ({
const { modelName, slug } = ctx.request.params; const { modelName, slug } = ctx.request.params;
const { auth } = ctx.state; const { auth } = ctx.state;
isValidFindSlugParams({ try {
modelName, isValidFindSlugParams({
slug, modelName,
modelsByName, slug,
}); modelsByName,
});
} catch (error) {
return ctx.badRequest(error.message);
}
const { uid, field, contentType } = modelsByName[modelName]; const { uid, field, contentType } = modelsByName[modelName];
await hasRequiredModelScopes(strapi, uid, auth); try {
await hasRequiredModelScopes(strapi, uid, auth);
} catch (error) {
return ctx.forbidden();
}
// add slug filter to any already existing query restrictions // add slug filter to any already existing query restrictions
let query = ctx.query || {}; let query = ctx.query || {};
@ -40,9 +47,9 @@ module.exports = ({ strapi }) => ({
if (data) { if (data) {
const sanitizedEntity = await sanitizeOutput(data, contentType, auth); const sanitizedEntity = await sanitizeOutput(data, contentType, auth);
ctx.body = transformResponse(sanitizedEntity, {}, { contentType }); ctx.body = transform.response({ data: sanitizedEntity, schema: contentType });
} else { } else {
throw new NotFoundError(); ctx.notFound();
} }
}, },
}); });

View File

@ -3,6 +3,7 @@ const { getPluginService } = require('../utils/getPluginService');
const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams'); const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams');
const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes'); const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes');
const { sanitizeOutput } = require('../utils/sanitizeOutput'); const { sanitizeOutput } = require('../utils/sanitizeOutput');
const { ForbiddenError, ValidationError } = require('@strapi/utils').errors;
const getCustomTypes = (strapi, nexus) => { const getCustomTypes = (strapi, nexus) => {
const { naming } = getPluginService('utils', 'graphql'); const { naming } = getPluginService('utils', 'graphql');
@ -54,16 +55,23 @@ const getCustomTypes = (strapi, nexus) => {
const { modelName, slug, publicationState } = args; const { modelName, slug, publicationState } = args;
const { auth } = ctx.state; const { auth } = ctx.state;
isValidFindSlugParams({ try {
modelName, isValidFindSlugParams({
slug, modelName,
modelsByName, slug,
publicationState, modelsByName,
}); publicationState,
});
} catch (error) {
throw new ValidationError(error.message);
}
const { uid, field, contentType } = modelsByName[modelName]; const { uid, field, contentType } = modelsByName[modelName];
await hasRequiredModelScopes(strapi, uid, auth); try {
await hasRequiredModelScopes(strapi, uid, auth);
} catch (error) {
throw new ForbiddenError();
}
// build query // build query
let query = { let query = {

View File

@ -2,6 +2,7 @@
const bootstrap = require('./bootstrap'); const bootstrap = require('./bootstrap');
const config = require('./config'); const config = require('./config');
const contentTypes = require('./content-types');
const controllers = require('./controllers'); const controllers = require('./controllers');
const register = require('./register'); const register = require('./register');
const routes = require('./routes'); const routes = require('./routes');
@ -10,6 +11,7 @@ const services = require('./services');
module.exports = { module.exports = {
bootstrap, bootstrap,
config, config,
contentTypes,
controllers, controllers,
register, register,
routes, routes,

View File

@ -0,0 +1,41 @@
const slugify = require('@sindresorhus/slugify');
const buildSlug = async (string, settings) => {
let slug = slugify(string, settings.slugifyOptions);
// slugify with count
if (!settings.slugifyWithCount) {
return slug;
}
const slugEntry = await strapi.db.query('plugin::slugify.slug').findOne({
select: ['id', 'count'],
where: { slug },
});
// if no result then count is 1 and base slug is returned
if (!slugEntry) {
await strapi.entityService.create('plugin::slugify.slug', {
data: {
slug,
count: 1,
},
});
return slug;
}
const count = slugEntry.count + 1;
await strapi.entityService.update('plugin::slugify.slug', slugEntry.id, {
data: {
count,
},
});
const separator = settings.slugifyOptions.separator || '-';
return `${slug}${separator}${count}`;
};
module.exports = {
buildSlug,
};

View File

@ -1,10 +1,10 @@
'use strict'; 'use strict';
const _ = require('lodash'); const _ = require('lodash');
const { toSlug } = require('../../utils/slugification');
const { getPluginService } = require('../../utils/getPluginService'); const { getPluginService } = require('../../utils/getPluginService');
const { shouldUpdateSlug } = require('./shoudUpdateSlug'); const { shouldUpdateSlug } = require('./shoudUpdateSlug');
const { getReferenceFieldValues } = require('./getReferenceFieldValues'); const { getReferenceFieldValues } = require('./getReferenceFieldValues');
const { buildSlug } = require('./buildSlug');
module.exports = ({ strapi }) => ({ module.exports = ({ strapi }) => ({
async slugify(ctx) { async slugify(ctx) {
@ -40,13 +40,10 @@ module.exports = ({ strapi }) => ({
} }
referenceFieldValues = referenceFieldValues.join(' '); referenceFieldValues = referenceFieldValues.join(' ');
const toSlugOptions = settings.slugifyOptions;
if (settings.slugifyWithCount) {
toSlugOptions.slugifyWithCount = settings.slugifyWithCount;
}
// update slug field based on action type // update slug field based on action type
const slug = toSlug(referenceFieldValues, toSlugOptions); const slug = await buildSlug(referenceFieldValues, settings);
if (ctx.action === 'beforeCreate' || ctx.action === 'beforeUpdate') { if (ctx.action === 'beforeCreate' || ctx.action === 'beforeUpdate') {
data[field] = slug; data[field] = slug;
} else if (ctx.action === 'afterCreate') { } else if (ctx.action === 'afterCreate') {

View File

@ -1,12 +1,5 @@
const { ForbiddenError } = require('@strapi/utils/lib/errors'); const hasRequiredModelScopes = (strapi, uid, auth) =>
strapi.auth.verify(auth, { scope: `${uid}.find` });
const hasRequiredModelScopes = async (strapi, uid, auth) => {
try {
await strapi.auth.verify(auth, { scope: `${uid}.find` });
} catch (e) {
throw new ForbiddenError();
}
};
module.exports = { module.exports = {
hasRequiredModelScopes, hasRequiredModelScopes,

View File

@ -1,4 +1,4 @@
const { ValidationError } = require('@strapi/utils/lib/errors'); const { ValidationError } = require('@strapi/utils').errors;
const _ = require('lodash'); const _ = require('lodash');
const isValidFindSlugParams = (params) => { const isValidFindSlugParams = (params) => {

View File

@ -1,6 +1,7 @@
const { contentAPI } = require('@strapi/utils/lib/sanitize'); const { sanitize } = require('@strapi/utils');
const sanitizeOutput = (data, contentType, auth) => contentAPI.output(data, contentType, { auth }); const sanitizeOutput = (data, contentType, auth) =>
sanitize.contentAPI.output(data, contentType, { auth });
module.exports = { module.exports = {
sanitizeOutput, sanitizeOutput,

View File

@ -1,18 +0,0 @@
'use strict';
const _ = require('lodash');
const slugify = require('@sindresorhus/slugify');
const slugifyWithCount = slugify.counter();
const toSlug = (string, options) => {
if (options.slugifyWithCount) {
_.omit(options, 'slugifyWithCount');
return slugifyWithCount(string, options);
}
return slugify(string, options);
};
module.exports = {
toSlug,
};

97
server/utils/transform.js Normal file
View File

@ -0,0 +1,97 @@
'use strict';
const { isNil, isPlainObject } = require('lodash/fp');
function response({ data, schema }) {
return transformResponse(data, {}, { contentType: schema });
}
// adapted from https://github.com/strapi/strapi/blob/main/packages/core/strapi/src/core-api/controller/transform.ts
function isEntry(property) {
return property === null || isPlainObject(property) || Array.isArray(property);
}
function isDZEntries(property) {
return Array.isArray(property);
}
function transformResponse(resource, meta = {}, opts = {}) {
if (isNil(resource)) {
return resource;
}
return {
data: transformEntry(resource, opts?.contentType),
meta,
};
}
function transformComponent(data, component) {
if (Array.isArray(data)) {
return data.map((datum) => transformComponent(datum, component));
}
const res = transformEntry(data, component);
if (isNil(res)) {
return res;
}
const { id, attributes } = res;
return { id, ...attributes };
}
function transformEntry(entry, type) {
if (isNil(entry)) {
return entry;
}
if (Array.isArray(entry)) {
return entry.map((singleEntry) => transformEntry(singleEntry, type));
}
if (!isPlainObject(entry)) {
throw new Error('Entry must be an object');
}
const { id, ...properties } = entry;
const attributeValues = {};
for (const key of Object.keys(properties)) {
const property = properties[key];
const attribute = type && type.attributes[key];
if (attribute && attribute.type === 'relation' && isEntry(property) && 'target' in attribute) {
const data = transformEntry(property, strapi.contentType(attribute.target));
attributeValues[key] = { data };
} else if (attribute && attribute.type === 'component' && isEntry(property)) {
attributeValues[key] = transformComponent(property, strapi.components[attribute.component]);
} else if (attribute && attribute.type === 'dynamiczone' && isDZEntries(property)) {
if (isNil(property)) {
attributeValues[key] = property;
}
attributeValues[key] = property.map((subProperty) => {
return transformComponent(subProperty, strapi.components[subProperty.__component]);
});
} else if (attribute && attribute.type === 'media' && isEntry(property)) {
const data = transformEntry(property, strapi.contentType('plugin::upload.file'));
attributeValues[key] = { data };
} else {
attributeValues[key] = property;
}
}
return {
id,
attributes: attributeValues,
// NOTE: not necessary for now
// meta: {},
};
}
module.exports = {
response,
};

9086
yarn.lock

File diff suppressed because it is too large Load Diff