Compare commits

...

60 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
daedalus
ccb31f7389
chore(release) 2.3.1 (#80) 2022-10-24 21:57:38 -04:00
daedalus
8381b32a20 fix(register): do not pass strapi instance to getPluginService 2022-10-24 21:17:53 -04:00
daedalus
d4a991be6f refactor(slugService): move updateSlug check and get reference fields to separate files 2022-10-24 21:10:35 -04:00
daedalus
78ca3f6d5d refactor(bootstrap): move build and lifecycle setup to separate files
refactor(settingsService): add clear distinction between model types
2022-10-24 21:10:35 -04:00
daedalus
f1ff9339dc refactor(slugification): merge slug function into one with count option flag 2022-10-24 21:10:35 -04:00
daedalus
99e63342e2 feat(isValidModelField): add support for non model id field 2022-10-24 21:10:35 -04:00
daedalus
5cbaea6ab0 chore: order import/export by alphabet 2022-10-24 21:10:35 -04:00
daedalus
27b69ff063 refactor(getPluginService): remove unnecessary param for strapi 2022-10-24 21:10:35 -04:00
daedalus
c48e13fb0d refactor(config/validator): make validation sync
refactor(config/validator): convert to arrow function for consistency
2022-10-24 21:10:35 -04:00
daedalus
d44577e9c4 refactor(config): default should be an arrow function for consistency 2022-10-24 21:10:35 -04:00
daedalus
d0ff250a21
chore(release) 2.2.3 (#69) 2022-08-09 00:29:27 -04:00
selected-pixel-jameson
9ca09c76f0
fix(slugification): do not update slug once generated (#68)
Attempting to update the slug on updates was causing issues with `slugifyWithCount` increasing the number when it should not. Once a slug is generated it should never be auto updated as it can cause unintended side effects.
2022-08-09 00:26:39 -04:00
daedalus
1b20016f4c
chore(release): 2.2.2 (#53) 2022-04-07 21:12:24 -04:00
daedalus
6cd9b79e27
fix(registration): error with default settings and gql (#52)
* fix(registration): error on install with gql

Resolves #51

* chore: format
2022-04-07 21:10:15 -04:00
daedalus
6b28c77d3a
fix(README): plugin order for gql config should state before not after (#48) 2022-03-19 21:17:17 -04:00
daedalus
991af08b6b
chore(release): 2.2.1 (#43) 2022-03-15 20:13:56 -04:00
daedalus
906188608c
fix(slugController): incorrect relation response (#42)
Resolves #40
2022-03-15 20:04:45 -04:00
daedalus
09a279d9d9
chore(README): add plugin order note for gql (#37) 2022-03-09 22:48:59 -05:00
DomDew
c630ef7abd
fix(isValidFindSlugParams): incorrect draftAndPublished check (#36)
Validation error should only trigger for content types that do NOT have draftAndPublish enabled.
2022-03-09 22:37:10 -05:00
DomDew
b0aaa5d52b
feat(graphql): add publicationState filter support (#23)
* feat(slugController): add publicationState as a query parameter (#23)

* feat(graphql): add publicationState as an arg to graphql type (#24)

* chore(README): update readme (#25)

* feat(graphql): refactor publicationState query

* chore(README): update readme to include previewState example for GraphQL

* feat(graphql): update server/utils/isValidFindSlugParams.js

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>

* chore(readme): update readme with suggested changes

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>

* feat(graphql): apply suggestions from code review in types

Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>

* feat(graphql): include lodash check based on requested changes

* fix(graphql): contentType not defined in lodash check

Co-authored-by: Dominik Dewitz <dewitz@machbar.eu>
Co-authored-by: daedalus <44623501+ComfortablyCoding@users.noreply.github.com>
2022-03-07 22:40:40 -05:00
daedalus
32e5d0aebd
chore(release): 2.1.1 (#34) 2022-03-03 01:08:05 -05:00
daedalus
3fb1b0b9b5
fix(findSlug utility): respect foreign model permissions (#33)
* fix(slugController): respect foreign model access permissions (#32)

* feat(utils): add `sanitizeOutput`

* feat(utils): add `isValidFindSlugParams`

* feat(utils): add `hasRequiredModelsScope`

* refactor(slugController): utilise util functions

* fix(Query.findSlug): respect model access permissions

refactor(Query.findSlug): utilise utils functions

Co-authored-by: Jean-Sébastien Herbaux <jean-sebastien.herbaux@epitech.eu>
2022-03-02 21:07:46 -05:00
daedalus
1bf0670815
chore(release): 2.1.0 (#27) 2022-02-28 19:43:04 -05:00
daedalus
5fcfae3b85
chore(README): incorrect casing for slugifyWithCount setting (#26) 2022-02-28 19:40:14 -05:00
daedalus
6ca80869b4
feat(slugification): add compound references (#22)
* feat(sugification): add compound reference support

resolves #17

* chore(ReADME): update references data type to String or Array

* chore(README): add new `skipUndefinedReferences` setting
2022-02-27 23:22:17 -05:00
daedalus
1463e672e6
feat(slugification): add slugify with count (#21)
* feat(slugification): add slugifyWithCount

* chore(README): add slugifyWithCount documentation
2022-02-27 21:24:06 -05:00
daedalus
f27ce29e8e
fix(slugController): sanitize response (#19) 2022-02-24 22:54:08 -05:00
daedalus
2afe7117a2
refactor(transformResponse): import from strapi (#18) 2022-02-24 22:20:17 -05:00
daedalus
463484f096
2.0.0 (#14) 2022-02-23 00:09:08 -05:00
daedalus
14cc4c7874
fix(findSlug REST): incorrect response format (#13) 2022-02-23 00:00:33 -05:00
daedalus
cab60452bc
fix(graphql): error on invalid model (#12) 2022-02-22 23:58:20 -05:00
daedalus
b6e8e3edda
chore(readme) formatting (#11)
* chore(README): format examples
2022-02-22 23:06:34 -05:00
daedalus
16fb97ed94
fix(README): format REST error response (#10) 2022-02-22 23:00:28 -05:00
daedalus
70e1151104
fix (findSlug query): default live publication state (#9)
* fix(findSlug query): only return published records by default

* refactor(settingsService): add build util

* refactor(slugService): remove unnecessary validation
2022-02-22 22:52:38 -05:00
daedalus
b5ef5bca40
feat: add graphql support (#8)
* feat(getPluginService): add support for any plugin

* refactor(settings): add uid model veersion to models for unified resource access

* feat: add graphql support

* chore(README): add graphql examples

* chore(strapi meta): add displayName

* chore(FindSlugResponse): update description

* fix(graphql types): ensure model exists before processing

* chore(README): incorrect title in graphql response
2022-02-22 22:19:17 -05:00
daedalus
883d74f41b
1.0.1 (#6) 2022-02-18 00:06:14 -05:00
daedalus
1ccfbba2f7
refactor(slug-controller): add note that model names are case sensitive in error message (#5)
chore(slug-controller): `pah` should be `path`
2022-02-18 00:04:47 -05:00
daedalus
35295e5e87
chore(README): add badges (#4)
chore(README): add note about modelName being case sensitive
2022-02-17 23:56:50 -05:00
Tamas Piros
ba97bc5e12
chore(README): add clarifications (#1) 2022-02-17 23:48:53 -05:00
34 changed files with 9835 additions and 387 deletions

View File

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

170
README.md
View File

@ -2,6 +2,10 @@
A plugin for [Strapi](https://github.com/strapi/strapi) that provides the ability to auto slugify a field for any content type. It also provides a findOne by slug endpoint as a utility.
[![Downloads](https://img.shields.io/npm/dm/strapi-plugin-slugify?style=for-the-badge)](https://img.shields.io/npm/dm/strapi-plugin-slugify?style=for-the-badge)
[![Install size](https://img.shields.io/npm/l/strapi-plugin-slugify?style=for-the-badge)](https://img.shields.io/npm/l/strapi-plugin-slugify?style=for-the-badge)
[![Package version](https://img.shields.io/github/v/release/ComfortablyCoding/strapi-plugin-slugify?style=for-the-badge)](https://img.shields.io/github/v/release/ComfortablyCoding/strapi-plugin-slugify?style=for-the-badge)
## Requirements
The installation requirements are the same as Strapi itself and can be found in the documentation on the [Quick Start](https://strapi.io/documentation/developer-docs/latest/getting-started/quick-start.html) page in the Prerequisites info card.
@ -16,11 +20,9 @@ The installation requirements are the same as Strapi itself and can be found in
```sh
npm install strapi-plugin-slugify
```
**or**
# or
```sh
yarn add strapi-plugin-slugify
```
@ -28,36 +30,51 @@ yarn add strapi-plugin-slugify
The plugin configuration is stored in a config file located at `./config/plugins.js`.
> Please note that the field referenced in the configuration file must exist. You can add it using the Strapi Admin UI. Also note that adding a field at a later point in time will require you to unpublish, change, save and republish the entry/entries in order for this plugin to work correctly.
A sample configuration
```javascript
module.exports = ({ env }) => ({
slugify: {
enabled: true,
config: {
contentTypes: {
article: {
field: 'slug',
references: 'title',
},
},
},
},
// ...
slugify: {
enabled: true,
config: {
contentTypes: {
article: {
field: 'slug',
references: 'title',
},
},
},
},
// ...
});
```
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: 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.
### The Complete Plugin Configuration Object
### Additional Requirement for GraphQL
Per [#35](https://github.com/ComfortablyCoding/strapi-plugin-slugify/issues/35) please ensure that the slugify plugin configuration is placed **before** the graphql plugin configuration.
## The Complete Plugin Configuration Object
| Property | Description | Type | Default | Required |
| -------- | ----------- | ---- | ------- | -------- |
| contentTypes | The Content Types to add auto slugification and search findOne by slug search utility to | Object | {} | No |
| contentTypes[modelName] | The model name of the content type (it is the `singularName` in the [model schema](https://docs.strapi.io/developer-docs/latest/development/backend-customization/models.html#model-schema)) | String | N/A | Yes |
| contentTypes[modelName]field | The name of the field to add the slug | String | N/A | Yes |
| contentTypes[modelName]references | The name of the field that is used to build the slug | String | N/A | Yes |
| contentTypes[modelName]references | The name(s) of the field(s) used to build the slug. If an array of fields is set it will result in a compound slug | String or Array | N/A | Yes |
| slugifyWithCount | Duplicate strings will have their occurrence appended to the end of the slug | Boolean | false | No |
| shouldUpdateSlug | Allow the slug to be updated after initial generation. | Boolean | false | No |
| skipUndefinedReferences | Skip reference fields that have no data. Mostly applicable to compound slug | Boolean | false | No |
| slugifyOptions | The options to pass the the slugify function. All options can be found in the [slugify docs](https://github.com/sindresorhus/slugify#api) | Object | {} | No |
## Usage
@ -70,44 +87,131 @@ Any time the respective content types have an entity created or updated the slug
### Find One by Slug
Hitting the `/api/slugify/slugs/:modelName/:slug` endpoint for any configured content types will return the entity type that matches the slug in the url.
Hitting the `/api/slugify/slugs/:modelName/:slug` endpoint for any configured content types will return the entity type that matches the slug in the url. Additionally the endpoint accepts any of the parameters that can be added to the routes built into Strapi.
**IMPORTANT** The modelName is case sensitive and must match exactly with the name defined in the configuration.
#### Additional Requirements
Like all other created API endpoints the `findSlug` route must be allowed under `User & Permissions -> Roles -> Public/Authenticated` for the user to be able to access the route.
#### Example Request
## Examples
### Example Request
Making the following request with the sample configuration will look as follows
#### REST
```js
await fetch(`${API_URL}/api/slugify/slugs/article/lorem-ipsum-dolor`);
// GET /api/slugify/slugs/article/lorem-ipsum-dolor
```
#### Example Response
#### GraphQL
If an article with the slug of `lorem-ipsum-dolor` exists the reponse will look the same as a single entity response
```json
```graphql
{
"data": {
"id": 1,
"title": "lorem ipsum dolor",
"slug": "lorem-ipsum-dolor",
"createdAt": "2022-02-17T01:49:31.961Z",
"updatedAt": "2022-02-17T03:47:09.950Z",
"publishedAt": null
}
findSlug(modelName:"article",slug:"lorem-ipsum-dolor"){
... on ArticleEntityResponse{
data{
id
attributes{
title
}
}
}
}
}
```
**IMPORTANT NOTE** To be inline with Strapi's default behaviour for single types if an article with the slug of `lorem-ipsum-dolor` does not exist a 404 error will be returned.
Additionally if `draftAndPublish` is enabled for the content-type a `publicationState` arg can be passed to the GraphQL query that accepts either `preview` or `live` as input.
**IMPORTANT** Please beware that the request for an entry in `preview` will return both draft entries & published entries as per Strapi default.
```graphql
{
findSlug(modelName:"article",slug:"lorem-ipsum-dolor",publicationState:"preview"){
... on ArticleEntityResponse{
data{
id
attributes{
title
}
}
}
}
}
```
### Example Response
If an article with the slug of `lorem-ipsum-dolor` exists the response will look the same as a single entity response
#### REST
##### Successful Response
```json
{
"data": null,
"error": { "status": 404, "name": "NotFoundError", "message": "Not Found", "details": {} }
"data": {
"id": 1,
"attributes":{
"title": "lorem ipsum dolor",
"slug": "lorem-ipsum-dolor",
"createdAt": "2022-02-17T01:49:31.961Z",
"updatedAt": "2022-02-17T03:47:09.950Z",
"publishedAt": null
}
}
}
```
##### Error Response
To be inline with Strapi's default behavior for single types if an article with the slug of `lorem-ipsum-dolor` does not exist a 404 error will be returned.
```json
{
"data": null,
"error": {
"status": 404,
"name": "NotFoundError",
"message": "Not Found",
"details": {}
}
}
```
#### GraphQL
##### Successful Response
```json
{
"data": {
"findSlug": {
"data": {
"id": "1",
"attributes": {
"title": "lorem ipsum dolor"
}
}
}
}
}
```
##### Error Response
To be inline with Strapi's default behavior for single types if an article with the slug of `lorem-ipsum-dolor` does not exist the data will be null.
```json
{
"data": {
"findSlug": {
"data": null
}
}
}
```

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "strapi-plugin-slugify",
"version": "1.0.0",
"version": "2.3.8",
"description": "A plugin for Strapi Headless CMS that provides the ability to auto slugify a field for any content type.",
"scripts": {
"lint": "eslint . --fix",
@ -26,26 +26,26 @@
"url": "https://github.com/ComfortablyCoding/strapi-plugin-slugify/issues"
},
"dependencies": {
"@sindresorhus/slugify": "1.1.0"
},
"devDependencies": {
"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",
"@sindresorhus/slugify": "1.1.0",
"@strapi/strapi": "^4.14.0",
"@strapi/utils": "^4.14.0",
"lodash": "^4.17.21",
"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": {
"displayName": "Slugify",
"name": "slugify",
"description": "A plugin for Strapi Headless CMS that provides the ability to auto slugify a field for any content type.",
"kind": "plugin"
},
"engines": {
"node": ">=12.x.x <=16.x.x",
"node": ">=18.0.0 <=20.x.x",
"npm": ">=6.0.0"
},
"keywords": [

45
server/bootstrap.js vendored
View File

@ -1,45 +0,0 @@
'use strict';
const _ = require('lodash');
const { SUPPORTED_LIFECYCLES } = require('./utils/constants');
const { getPluginService } = require('./utils/getPluginService');
module.exports = ({ strapi }) => {
const settings = getPluginService(strapi, 'settingsService').get();
const { contentTypes, slugifyOptions } = settings;
// build settings structure
const uids = {};
const models = {};
_.forEach(strapi.contentTypes, (value, key) => {
if (contentTypes[value.modelName]) {
const data = {
uid: value.uid,
...contentTypes[value.modelName],
};
uids[key] = data;
models[value.modelName] = data;
}
});
// reset plugin settings
getPluginService(strapi, 'settingsService').set({
models,
uids,
slugifyOptions,
});
// set up lifecycles
const subscribe = {
models: _.map(models, (m) => m.uid),
};
SUPPORTED_LIFECYCLES.forEach((lifecycle) => {
subscribe[lifecycle] = (ctx) => {
getPluginService(strapi, 'slugService').slugify(ctx);
};
});
strapi.db.lifecycles.subscribe(subscribe);
};

View File

@ -0,0 +1,17 @@
const { getPluginService } = require('../utils/getPluginService');
const buildSettings = async () => {
const settingsService = getPluginService('settingsService');
const settings = await settingsService.get();
// build settings structure
const normalizedSettings = settingsService.build(settings);
// reset plugin settings
await settingsService.set(normalizedSettings);
return normalizedSettings;
};
module.exports = {
buildSettings,
};

16
server/bootstrap/index.js Normal file
View File

@ -0,0 +1,16 @@
'use strict';
const { buildSettings } = require('./buildSettings');
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);
}
setupLifecycles(settings);
};

View File

@ -0,0 +1,18 @@
const _ = require('lodash');
const { getPluginService } = require('../utils/getPluginService');
const setupLifecycles = (settings) => {
// set up lifecycles
const subscribe = {
models: _.map(settings.modelsByUID, (model) => model.uid),
};
['beforeCreate', 'afterCreate', 'beforeUpdate'].forEach((lifecycle) => {
subscribe[lifecycle] = (ctx) => getPluginService('slugService').slugify(ctx);
});
strapi.db.lifecycles.subscribe(subscribe);
};
module.exports = {
setupLifecycles,
};

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

@ -1,15 +1,14 @@
'use strict';
const { pluginConfigSchema } = require('./schema');
const schema = require('./schema');
module.exports = {
default() {
return {
contentTypes: {},
slugifyOptions: {},
};
},
async validator(config) {
await pluginConfigSchema.validate(config);
},
default: () => ({
contentTypes: {},
slugifyOptions: {},
slugifyWithCount: false,
shouldUpdateSlug: false,
skipUndefinedReferences: false,
}),
validator: (config) => schema.validateSync(config),
};

View File

@ -3,21 +3,23 @@
const yup = require('yup');
const _ = require('lodash');
const pluginConfigSchema = yup.object().shape({
const schema = yup.object().shape({
slugifyOptions: yup.object(),
contentTypes: yup.lazy((obj) => {
// eslint-disable-next-line no-unused-vars
let shape = {};
_.each(obj, (_, key) => {
_.each(obj, (_value, key) => {
shape[key] = yup.object().shape({
field: yup.string().required(),
references: yup.string().required(),
references: yup.lazy((v) =>
_.isArray(v) ? yup.array().of(yup.string()).required() : yup.string().required()
),
});
});
return yup.object().shape(shape);
}),
slugifyWithCount: yup.bool(),
shouldUpdateSlug: yup.bool(),
skipUndefinedReferences: yup.bool(),
});
module.exports = {
pluginConfigSchema,
};
module.exports = schema;

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,45 +1,55 @@
'use strict';
const _ = require('lodash');
const { getPluginService } = require('../utils/getPluginService');
const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams');
const { sanitizeOutput } = require('../utils/sanitizeOutput');
const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes');
const transform = require('../utils/transform');
module.exports = ({ strapi }) => ({
async findSlug(ctx) {
const { models } = getPluginService(strapi, 'settingsService').get();
const { params } = ctx.request;
const { modelName, slug } = params;
const { modelsByName } = getPluginService('settingsService').get();
const { modelName, slug } = ctx.request.params;
const { auth } = ctx.state;
try {
if (!modelName) {
throw Error('A model name pah variable is required.');
}
if (!slug) {
throw Error('A slug path variable is required.');
}
const model = models[modelName];
if (!model) {
throw Error(
`${modelName} model name not found, all models must be defined in the settings.`
);
}
const { uid, field } = model;
let query = ctx.query;
if (!query.filters) {
query.filters = {};
}
query.filters[field] = slug;
const data = await getPluginService(strapi, 'slugService').findOne(uid, query);
if (data) {
return ctx.send({ data });
}
ctx.notFound();
isValidFindSlugParams({
modelName,
slug,
modelsByName,
});
} catch (error) {
ctx.badRequest(error.message);
return ctx.badRequest(error.message);
}
const { uid, field, contentType } = modelsByName[modelName];
try {
await hasRequiredModelScopes(strapi, uid, auth);
} catch (error) {
return ctx.forbidden();
}
// add slug filter to any already existing query restrictions
let query = ctx.query || {};
if (!query.filters) {
query.filters = {};
}
query.filters[field] = slug;
// only return published entries by default if content type has draftAndPublish enabled
if (_.get(contentType, ['options', 'draftAndPublish'], false) && !query.publicationState) {
query.publicationState = 'live';
}
const data = await getPluginService('slugService').findOne(uid, query);
if (data) {
const sanitizedEntity = await sanitizeOutput(data, contentType, auth);
ctx.body = transform.response({ data: sanitizedEntity, schema: contentType });
} else {
ctx.notFound();
}
},
});

19
server/graphql/index.js Normal file
View File

@ -0,0 +1,19 @@
'use strict';
const { getCustomTypes } = require('./types');
const { getResolversConfig } = require('./resolversConfig');
const { getPluginService } = require('../utils/getPluginService');
const registerGraphlQLQuery = (strapi) => {
// build plugins schema extension
const extension = ({ nexus }) => ({
types: getCustomTypes(strapi, nexus),
resolversConfig: getResolversConfig(),
});
getPluginService('extension', 'graphql').use(extension);
};
module.exports = {
registerGraphlQLQuery,
};

View File

@ -0,0 +1,13 @@
const getResolversConfig = () => ({
Query: {
findSlug: {
auth: {
scope: 'plugin::slugify.slugController.findSlug',
},
},
},
});
module.exports = {
getResolversConfig,
};

100
server/graphql/types.js Normal file
View File

@ -0,0 +1,100 @@
const _ = require('lodash');
const { getPluginService } = require('../utils/getPluginService');
const { isValidFindSlugParams } = require('../utils/isValidFindSlugParams');
const { hasRequiredModelScopes } = require('../utils/hasRequiredModelScopes');
const { sanitizeOutput } = require('../utils/sanitizeOutput');
const { ForbiddenError, ValidationError } = require('@strapi/utils').errors;
const getCustomTypes = (strapi, nexus) => {
const { naming } = getPluginService('utils', 'graphql');
const { toEntityResponse } = getPluginService('format', 'graphql').returnTypes;
const { modelsByUID } = getPluginService('settingsService').get();
const { getEntityResponseName } = naming;
// get all types required for findSlug query
let findSlugTypes = {
response: [],
};
_.forEach(strapi.contentTypes, (contentType, uid) => {
if (modelsByUID[uid]) {
findSlugTypes.response.push(getEntityResponseName(contentType));
}
});
// ensure we have at least one type before attempting to register
if (!findSlugTypes.response.length) {
return [];
}
// build custom union type based on defined models
const FindSlugResponse = nexus.unionType({
name: 'FindSlugResponse',
description: 'Union Type of all registered slug content types',
definition(t) {
t.members(...findSlugTypes.response);
},
resolveType: (ctx) => {
return getEntityResponseName(modelsByUID[ctx.info.resourceUID].contentType);
},
});
return [
FindSlugResponse,
nexus.extendType({
type: 'Query',
definition: (t) => {
t.field('findSlug', {
type: FindSlugResponse,
args: {
modelName: nexus.stringArg('The model name of the content type'),
slug: nexus.stringArg('The slug to query for'),
publicationState: nexus.stringArg('The publication state of the entry'),
},
resolve: async (_parent, args, ctx) => {
const { modelsByName } = getPluginService('settingsService').get();
const { modelName, slug, publicationState } = args;
const { auth } = ctx.state;
try {
isValidFindSlugParams({
modelName,
slug,
modelsByName,
publicationState,
});
} catch (error) {
throw new ValidationError(error.message);
}
const { uid, field, contentType } = modelsByName[modelName];
try {
await hasRequiredModelScopes(strapi, uid, auth);
} catch (error) {
throw new ForbiddenError();
}
// build query
let query = {
filters: {
[field]: slug,
},
};
// only return published entries by default if content type has draftAndPublish enabled
if (_.get(contentType, ['options', 'draftAndPublish'], false)) {
query.publicationState = publicationState || 'live';
}
const data = await getPluginService('slugService').findOne(uid, query);
const sanitizedEntity = await sanitizeOutput(data, contentType, auth);
return toEntityResponse(sanitizedEntity, { resourceUID: uid });
},
});
},
}),
];
};
module.exports = {
getCustomTypes,
};

View File

@ -2,14 +2,18 @@
const bootstrap = require('./bootstrap');
const config = require('./config');
const contentTypes = require('./content-types');
const controllers = require('./controllers');
const register = require('./register');
const routes = require('./routes');
const services = require('./services');
module.exports = {
bootstrap,
config,
contentTypes,
controllers,
register,
routes,
services,
};

18
server/register.js Normal file
View File

@ -0,0 +1,18 @@
'use strict';
const { registerGraphlQLQuery } = require('./graphql');
const { getPluginService } = require('./utils/getPluginService');
module.exports = ({ strapi }) => {
const { contentTypes } = getPluginService('settingsService').get();
// ensure we have at least one model before attempting registration
if (!Object.keys(contentTypes).length) {
return;
}
// add graphql query if present
if (strapi.plugin('graphql')) {
strapi.log.info('[slugify] graphql detected, registering queries');
registerGraphlQLQuery(strapi);
}
};

View File

@ -1,7 +1,7 @@
'use strict';
const slugService = require('./slug-service');
const settingsService = require('./settings-service');
const slugService = require('./slug-service');
module.exports = {
slugService,

View File

@ -1,5 +1,7 @@
'use strict';
const _ = require('lodash');
const { isValidModelField } = require('../utils/isValidModelField');
const { pluginId } = require('../utils/pluginId');
module.exports = ({ strapi }) => ({
@ -9,4 +11,48 @@ module.exports = ({ strapi }) => ({
set(settings) {
return strapi.config.set(`plugin.${pluginId}`, settings);
},
build(settings) {
// build models
settings.modelsByUID = {};
settings.modelsByName = {};
_.each(strapi.contentTypes, (contentType, uid) => {
const model = settings.contentTypes[contentType.modelName];
if (!model) {
return;
}
// ensure provided fields are present on the model
const hasField = isValidModelField(contentType, model.field);
if (!hasField) {
strapi.log.warn(
`[slugify] skipping ${contentType.info.singularName} registration, invalid field provided.`
);
return;
}
let references = _.isArray(model.references) ? model.references : [model.references];
const hasReferences = references.every((referenceField) =>
isValidModelField(contentType, referenceField)
);
if (!hasReferences) {
strapi.log.warn(
`[slugify] skipping ${contentType.info.singularName} registration, invalid reference field provided.`
);
return;
}
const data = {
uid,
...model,
contentType,
references,
};
settings.modelsByUID[uid] = data;
settings.modelsByName[contentType.modelName] = data;
});
_.omit(settings, ['contentTypes']);
return settings;
},
});

View File

@ -1,34 +0,0 @@
'use strict';
const { getPluginService } = require('../utils/getPluginService');
const { stringToSlug } = require('../utils/stringToSlug');
module.exports = ({ strapi }) => ({
slugify(ctx) {
const { uids, slugifyOptions } = getPluginService(strapi, 'settingsService').get();
const { params, model: entityModel } = ctx;
const model = uids[entityModel.uid];
const { data } = params;
if (!data) {
return;
}
const field = model.field;
const references = data[model.references];
// for empty values they are null, undefined means they are not on the model.
if (!field || typeof references === 'undefined') {
return;
}
data[field] = stringToSlug(references, slugifyOptions);
},
async findOne(uid, query) {
const slugs = await strapi.entityService.findMany(uid, query);
return slugs.length ? slugs[0] : null;
},
});

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

@ -0,0 +1,40 @@
'use strict';
const getReferenceFieldValues = (ctx, data, references) => {
return references
.filter((referenceField) => {
// check action specific fields
if (
referenceField === 'id' &&
(ctx.action === 'afterCreate' || ctx.action === 'beforeUpdate')
) {
return true;
}
// check general data fields
if (typeof data[referenceField] === 'undefined') {
return false;
}
if (data[referenceField] === null) {
return false;
}
if (typeof data[referenceField] === 'string' && data[referenceField].length === 0) {
return false;
}
return true;
})
.map((referenceField) => {
if (referenceField === 'id') {
return ctx.result ? ctx.result.id : ctx.params.where.id;
}
return data[referenceField];
});
};
module.exports = {
getReferenceFieldValues,
};

View File

@ -0,0 +1,74 @@
'use strict';
const _ = require('lodash');
const { getPluginService } = require('../../utils/getPluginService');
const { shouldUpdateSlug } = require('./shoudUpdateSlug');
const { getReferenceFieldValues } = require('./getReferenceFieldValues');
const { buildSlug } = require('./buildSlug');
module.exports = ({ strapi }) => ({
async slugify(ctx) {
const { params, model: entityModel } = ctx;
const settings = getPluginService('settingsService').get();
const { data } = params;
const model = settings.modelsByUID[entityModel.uid];
if (!data) {
return;
}
const { field, references } = model;
// do not add/update slug if it already has a value unless settings specify otherwise
if (!settings.shouldUpdateSlug && data[field]) {
return;
}
// ensure the reference field has data
let referenceFieldValues = getReferenceFieldValues(ctx, data, references);
// respect skip undefined fields setting
const hasUndefinedFields = referenceFieldValues.length < references.length;
if ((!settings.skipUndefinedReferences && hasUndefinedFields) || !referenceFieldValues.length) {
return;
}
// check if the slug should be updated based on the action type
let shouldUpdateSlugByAction = await shouldUpdateSlug(strapi, ctx, references);
if (!shouldUpdateSlugByAction) {
return;
}
referenceFieldValues = referenceFieldValues.join(' ');
// update slug field based on action type
const slug = await buildSlug(referenceFieldValues, settings);
if (ctx.action === 'beforeCreate' || ctx.action === 'beforeUpdate') {
data[field] = slug;
} else if (ctx.action === 'afterCreate') {
strapi.entityService.update(model.uid, ctx.result.id, {
data: {
slug,
},
});
}
},
async findOne(uid, query) {
const slugs = await strapi.entityService.findMany(uid, query);
// single
if (slugs && _.isPlainObject(slugs)) {
return slugs;
}
// collection
if (slugs && _.isArray(slugs) && slugs.length) {
return slugs[0];
}
// no result
return null;
},
});

View File

@ -0,0 +1,50 @@
'use strict';
const { getReferenceFieldValues } = require('./getReferenceFieldValues');
const shouldUpdateSlugInAfterCreate = (strapi, ctx, references) => {
if (!references.includes('id')) {
return false;
}
return true;
};
const shouldUpdateSlugInBeforeCreate = (strapi, ctx, references) => {
if (references.includes('id')) {
return false;
}
return true;
};
const shouldUpdateSlugInBeforeUpdate = async (strapi, ctx, references) => {
const record = await strapi.entityService.findOne(ctx.model.uid, ctx.params.where.id);
let currentReferenceFieldValues = getReferenceFieldValues(ctx, record, references);
let referenceFieldValues = getReferenceFieldValues(ctx, ctx.params.data, references);
// only update if reference a reference field has changed
if (JSON.stringify(referenceFieldValues) == JSON.stringify(currentReferenceFieldValues)) {
return false;
}
return true;
};
const shouldUpdateSlug = async (strapi, ctx, references) => {
let shouldUpdate = false;
if (ctx.action === 'beforeCreate') {
shouldUpdate = shouldUpdateSlugInBeforeCreate(strapi, ctx, references);
} else if (ctx.action === 'afterCreate') {
shouldUpdate = shouldUpdateSlugInAfterCreate(strapi, ctx, references);
} else if (ctx.action === 'beforeUpdate') {
shouldUpdate = shouldUpdateSlugInBeforeUpdate(strapi, ctx, references);
}
return shouldUpdate;
};
module.exports = {
shouldUpdateSlug,
};

View File

@ -1,7 +0,0 @@
'use strict';
const SUPPORTED_LIFECYCLES = ['beforeCreate', 'beforeUpdate'];
module.exports = {
SUPPORTED_LIFECYCLES,
};

View File

@ -7,7 +7,7 @@ const { pluginId } = require('./pluginId');
*
* @return service
*/
const getPluginService = (strapi, name) => strapi.plugin(pluginId).service(name);
const getPluginService = (name, plugin = pluginId) => strapi.plugin(plugin).service(name);
module.exports = {
getPluginService,

View File

@ -0,0 +1,6 @@
const hasRequiredModelScopes = (strapi, uid, auth) =>
strapi.auth.verify(auth, { scope: `${uid}.find` });
module.exports = {
hasRequiredModelScopes,
};

View File

@ -0,0 +1,36 @@
const { ValidationError } = require('@strapi/utils').errors;
const _ = require('lodash');
const isValidFindSlugParams = (params) => {
if (!params) {
throw new ValidationError('A model and slug must be provided.');
}
const { modelName, slug, modelsByName, publicationState } = params;
const model = modelsByName[modelName];
if (!modelName) {
throw new ValidationError('A model name path variable is required.');
}
if (!slug) {
throw new ValidationError('A slug path variable is required.');
}
if (!_.get(model, ['contentType', 'options', 'draftAndPublish'], false) && publicationState) {
throw new ValidationError(
'Filtering by publication state is only supported for content types that have Draft and Publish enabled.'
);
}
// ensure valid model is passed
if (!model) {
throw new ValidationError(
`${modelName} model name not found, all models must be defined in the settings and are case sensitive.`
);
}
};
module.exports = {
isValidFindSlugParams,
};

View File

@ -0,0 +1,10 @@
'use strict';
const _ = require('lodash');
const isValidModelField = (model, field) =>
_.get(model, ['attributes', field], false) || field === 'id';
module.exports = {
isValidModelField,
};

View File

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

View File

@ -1,11 +0,0 @@
'use strict';
const slugify = require('@sindresorhus/slugify');
const stringToSlug = (string, options) => {
return slugify(string, options);
};
module.exports = {
stringToSlug,
};

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