@gruodis/slug-for-strapi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gruodis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # Slug For Strapi
2
+
3
+ ## 🚀 Installation
4
+
5
+ ```bash
6
+ npm install @gruodis/slug-for-strapi
7
+ # or
8
+ yarn add @gruodis/slug-for-strapi
9
+ ```
10
+
11
+ ## Config
12
+
13
+ `config/plugins.ts`:
14
+
15
+ ```javascript
16
+ module.exports = {
17
+ 'slug-for-strapi': {
18
+ enabled: true,
19
+ resolve: './node_modules/@gruodis/slug-for-strapi', // Path to the plugin
20
+ config: {
21
+ enabled: true, // Enable/disable plugin globally
22
+ sourceField: 'title', // Primary field to generate slug from
23
+ fallbackField: 'name', // Fallback field if primary is empty
24
+ addSuffixForUnique: true, // Add suffixes for uniqueness
25
+ updateExistingSlugs: true, // Update existing slugs when title changes
26
+ slugifyOptions: {
27
+ lower: true,
28
+ strict: true,
29
+ locale: 'lt'
30
+ }
31
+ }
32
+ }
33
+ };
34
+ ```
35
+
36
+ ## 📖 Usage
37
+
38
+ 1. **Add a `slug` field** to any content type in your Strapi schema
39
+ 2. **Create or edit entries** - slugs will be automatically generated from `title` or `name` fields
40
+ 3. **String support** - Works with regular string fields
41
+
42
+ ### Example Content Type Schema
43
+
44
+ ```json
45
+ {
46
+ "kind": "collectionType",
47
+ "collectionName": "articles",
48
+ "info": {
49
+ "singularName": "article",
50
+ "pluralName": "articles",
51
+ "displayName": "Articles"
52
+ },
53
+ "attributes": {
54
+ "title": {
55
+ "type": "string"
56
+ },
57
+ "slug": {
58
+ "type": "uid",
59
+ "targetField": "title"
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## 🔧 API Endpoints
66
+
67
+ ### 🔍 Find By Slug
68
+
69
+ For every content type with a generated slug, the plugin automatically creates a GET endpoint:
70
+
71
+ - `GET /api/:pluralApiId/slug/:slug`
72
+
73
+ Example:
74
+ - `GET /api/articles/slug/my-awesome-article`
75
+
76
+ Response:
77
+ ```json
78
+ {
79
+ "data": {
80
+ "id": 1,
81
+ "documentId": "...",
82
+ "title": "My Awesome Article",
83
+ "slug": "my-awesome-article",
84
+ ...
85
+ }
86
+ }
87
+ ```
88
+
89
+ > **Note:** These endpoints are read-only and public by default.
90
+
91
+ ## 📝 Field Types Supported
92
+
93
+ ### Regular String
94
+ ```json
95
+ {
96
+ "title": "My Article Title"
97
+ }
98
+ ```
99
+
100
+ Will generate: `my-article-title`
101
+
102
+ ## Multi-locale Support
103
+
104
+ The plugin supports different locales for transliteration. You can change the locale in your `config/plugins.ts`.
105
+
106
+ ## 🔧 Development
107
+
108
+ ```bash
109
+
110
+
111
+ # Install dependencies
112
+ npm install
113
+
114
+ # Build the plugin
115
+ npm run build
116
+ ```
117
+
118
+ ## 📄 License
119
+
120
+ MIT License.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@gruodis/slug-for-strapi",
3
+ "version": "1.0.0",
4
+ "description": "Does what it says. Slug for Strapi.",
5
+ "main": "strapi-server.js",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "scripts": {
10
+ "develop": "tsc -w",
11
+ "build": "tsc",
12
+ "test": "echo \"Error: no test specified\" && exit 0"
13
+ },
14
+ "keywords": [
15
+ "strapi",
16
+ "plugin",
17
+ "slug",
18
+ "auto-generation",
19
+ "universal"
20
+ ],
21
+ "author": "Gruodis",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Gruodis/slug-for-strapi.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/Gruodis/slug-for-strapi/issues"
29
+ },
30
+ "homepage": "https://github.com/Gruodis/slug-for-strapi#readme",
31
+ "strapi": {
32
+ "name": "slug-for-strapi",
33
+ "displayName": "Slug For Strapi",
34
+ "description": "Does what it says. Slug for Strapi.",
35
+ "kind": "plugin"
36
+ },
37
+ "dependencies": {
38
+ "slugify": "^1.6.6"
39
+ },
40
+ "peerDependencies": {
41
+ "@strapi/strapi": "^5.0.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0",
45
+ "npm": ">=6.0.0"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "server",
50
+ "admin",
51
+ "strapi-admin.js",
52
+ "strapi-server.js",
53
+ "package.json",
54
+ "README.md",
55
+ "LICENSE"
56
+ ]
57
+ }
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ module.exports = ({ strapi }) => {
4
+ // Register universal lifecycle hooks at Strapi startup
5
+ const registerSlugLifecycles = () => {
6
+ console.log('🚀 [Slug For Strapi] Initializing plugin...');
7
+
8
+ // Get plugin configuration from Strapi
9
+ const pluginConfig = strapi.config.get('plugin.slug-for-strapi') || {};
10
+ console.log('⚙️ [Slug For Strapi] Plugin configuration:', pluginConfig);
11
+
12
+ const slugService = strapi.plugin('slug-for-strapi').service('slug-generator');
13
+
14
+ // Get all content-types with slug field
15
+ const contentTypesWithSlug = slugService.getContentTypesWithSlug();
16
+
17
+ if (contentTypesWithSlug.length === 0) {
18
+ console.log('⚠️ [Slug For Strapi] No content-types found with slug field');
19
+ return;
20
+ }
21
+
22
+ // Register lifecycle hooks for each content-type
23
+ contentTypesWithSlug.forEach(({ uid, displayName }) => {
24
+ console.log(`📝 [Slug For Strapi] Registering lifecycle for ${displayName} (${uid})`);
25
+
26
+ // In Strapi v5 use direct registration via strapi.db.lifecycles
27
+ strapi.db.lifecycles.subscribe({
28
+ models: [uid],
29
+
30
+ // beforeCreate hook
31
+ async beforeCreate(event) {
32
+ const { data } = event.params;
33
+ console.log(`🆕 [Slug For Strapi] beforeCreate for ${uid}:`, data.title || data.name);
34
+
35
+ if (!data.slug) {
36
+ const slug = await slugService.generateSlugForEntry(data, uid);
37
+ if (slug) {
38
+ data.slug = slug;
39
+ console.log(`✅ [Slug For Strapi] Slug created: "${slug}"`);
40
+ }
41
+ }
42
+ },
43
+
44
+ // beforeUpdate hook
45
+ async beforeUpdate(event) {
46
+ const { data, where } = event.params;
47
+ console.log(`🔄 [Slug For Strapi] beforeUpdate for ${uid}:`, data.title || data.name);
48
+
49
+ // Generate slug only if it's missing or needs update
50
+ if (data.title || data.name) {
51
+ // Get current entity
52
+ const currentEntity = await strapi.db.query(uid).findOne({ where });
53
+
54
+ // Try to generate slug (service decides whether to update or not)
55
+ const slug = await slugService.generateSlugForEntry(data, uid, currentEntity?.documentId);
56
+ if (slug) {
57
+ data.slug = slug;
58
+ console.log(`✅ [Slug For Strapi] Slug updated: "${slug}"`);
59
+ } else if (currentEntity?.slug) {
60
+ console.log(`⚠️ [Slug For Strapi] Slug already exists, skipping: "${currentEntity.slug}"`);
61
+ }
62
+ }
63
+ }
64
+ });
65
+ });
66
+
67
+ console.log(`✅ [Slug For Strapi] Plugin initialized for ${contentTypesWithSlug.length} content-types`);
68
+
69
+ // Register findBySlug routes for each content-type
70
+ contentTypesWithSlug.forEach(({ uid, displayName }) => {
71
+ const contentType = strapi.contentTypes[uid];
72
+ const pluralName = contentType.info.pluralName;
73
+
74
+ if (!pluralName) {
75
+ console.warn(`⚠️ [Slug For Strapi] Could not determine pluralName for ${uid}`);
76
+ return;
77
+ }
78
+
79
+ // Check if route already exists (plugin might re-initialize)
80
+ const routePath = `/api/${pluralName}/slug/:slug`;
81
+ const routes = strapi.server.router.stack.filter(layer => layer.path === routePath);
82
+
83
+ if (routes.length > 0) {
84
+ console.log(`ℹ️ [Slug For Strapi] Route already exists, skipping: GET ${routePath}`);
85
+ return;
86
+ }
87
+
88
+ console.log(`🛣️ [Slug For Strapi] Registering route: GET ${routePath}`);
89
+
90
+ // Add route to Strapi Koa router
91
+ strapi.server.router.get(routePath, async (ctx, next) => {
92
+ // Add UID to params for controller
93
+ ctx.params.uid = uid;
94
+
95
+ // Call controller
96
+ await strapi.plugin('slug-for-strapi').controller('general').findBySlug(ctx, next);
97
+ });
98
+ });
99
+ };
100
+
101
+ // Run registration after full Strapi initialization
102
+ registerSlugLifecycles();
103
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ default: {
5
+ // Global settings
6
+ enabled: true,
7
+ sourceField: 'title', // Primary field to generate slug from
8
+ fallbackField: 'name', // Fallback field if primary is empty
9
+ addSuffixForUnique: true, // Add suffixes for uniqueness (-1, -2, -3)
10
+ slugifyOptions: {
11
+ lower: true,
12
+ strict: true,
13
+ locale: 'lt'
14
+ },
15
+
16
+ // Content-types settings (filled automatically)
17
+ contentTypes: {
18
+ // 'api::article.article': { enabled: true },
19
+ // 'api::page.page': { enabled: true },
20
+ }
21
+ },
22
+ validator: (config) => {
23
+ // Configuration validation
24
+ if (typeof config.enabled !== 'boolean') {
25
+ throw new Error('enabled must be a boolean');
26
+ }
27
+ if (typeof config.sourceField !== 'string') {
28
+ throw new Error('sourceField must be a string');
29
+ }
30
+ },
31
+ };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ module.exports = ({ strapi }) => ({
4
+ async findBySlug(ctx) {
5
+ const { uid, slug } = ctx.params;
6
+
7
+ if (!uid || !slug) {
8
+ return ctx.badRequest('Missing uid or slug');
9
+ }
10
+
11
+ try {
12
+ const entity = await strapi.db.query(uid).findOne({
13
+ where: { slug },
14
+ populate: true,
15
+ });
16
+
17
+ if (!entity) {
18
+ return ctx.notFound();
19
+ }
20
+
21
+ const sanitizedEntity = await strapi.contentAPI.sanitize.output(entity, strapi.getModel(uid));
22
+
23
+ ctx.body = { data: sanitizedEntity };
24
+ } catch (error) {
25
+ strapi.log.error(error);
26
+ ctx.internalServerError('An error occurred while fetching the entity by slug');
27
+ }
28
+ },
29
+ });
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ const general = require('./general');
4
+
5
+ module.exports = {
6
+ general,
7
+ };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const config = require('./config');
4
+ const services = require('./services');
5
+ const controllers = require('./controllers');
6
+ const routes = require('./routes');
7
+ const bootstrap = require('./bootstrap');
8
+
9
+ module.exports = {
10
+ config,
11
+ services,
12
+ controllers,
13
+ routes,
14
+ bootstrap,
15
+ };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = [
4
+ // Settings routes removed as configuration is done via config/plugins.ts
5
+ ];
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ const slugGenerator = require('./slug-generator');
4
+
5
+ module.exports = {
6
+ 'slug-generator': slugGenerator,
7
+ };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const slugify = require('slugify');
4
+
5
+ module.exports = ({ strapi }) => ({
6
+ /**
7
+ * Extracts text from field (string)
8
+ * @param {any} fieldValue - field value
9
+ * @returns {string} - text for slug generation
10
+ */
11
+ extractTextFromField(fieldValue) {
12
+ if (!fieldValue) return '';
13
+
14
+ // If it's a regular string
15
+ if (typeof fieldValue === 'string') {
16
+ console.log('🔍 [Slug For Strapi] Regular string detected');
17
+ return fieldValue;
18
+ }
19
+
20
+ console.log('⚠️ [Slug For Strapi] Unsupported field type (not a string):', typeof fieldValue, fieldValue);
21
+ return '';
22
+ },
23
+
24
+ /**
25
+ * Generates unique slug
26
+ * @param {string} text - source text
27
+ * @param {string} contentType - content type
28
+ * @param {string} documentId - document ID (to exclude from check)
29
+ * @param {object} options - slugify options
30
+ * @returns {Promise<string>} - unique slug
31
+ */
32
+ async generateUniqueSlug(text, contentType, documentId = null, options = {}) {
33
+ if (!text) {
34
+ console.log('⚠️ [Slug For Strapi] Empty text for slug generation');
35
+ return '';
36
+ }
37
+
38
+ // Get settings from Strapi config
39
+ const config = strapi.config.get('plugin.slug-for-strapi');
40
+
41
+ // Generate base slug with settings from configuration
42
+ const baseSlug = slugify(text, {
43
+ ...config.slugifyOptions,
44
+ ...options
45
+ });
46
+
47
+ console.log('🔄 [Slug For Strapi] Base slug:', baseSlug);
48
+
49
+ // Check uniqueness
50
+ let slug = baseSlug;
51
+ let counter = 1;
52
+
53
+ while (true) {
54
+ // Find existing entries with this slug
55
+ const existing = await strapi.db.query(contentType).findOne({
56
+ where: {
57
+ slug: slug,
58
+ ...(documentId && { documentId: { $ne: documentId } })
59
+ }
60
+ });
61
+
62
+ if (!existing) {
63
+ console.log('✅ [Slug For Strapi] Unique slug found:', slug);
64
+ break;
65
+ }
66
+
67
+ slug = `${baseSlug}-${counter}`;
68
+ counter++;
69
+ console.log('🔄 [Slug For Strapi] Trying slug:', slug);
70
+ }
71
+
72
+ return slug;
73
+ },
74
+
75
+ /**
76
+ * Generates slug for entry
77
+ * @param {object} data - entry data
78
+ * @param {string} contentType - content type
79
+ * @param {string} documentId - document ID
80
+ * @returns {Promise<string|null>} - generated slug or null
81
+ */
82
+ async generateSlugForEntry(data, contentType, documentId = null) {
83
+ console.log(`🔍 [Slug For Strapi] generateSlugForEntry called for ${contentType}`);
84
+ console.log(`📋 [Slug For Strapi] Data:`, JSON.stringify(data, null, 2));
85
+
86
+ // Get current settings from Strapi config
87
+ const config = strapi.config.get('plugin.slug-for-strapi');
88
+ console.log(`⚙️ [Slug For Strapi] Configuration:`, config);
89
+
90
+ // Check if plugin is enabled globally
91
+ if (!config.enabled) {
92
+ console.log(`❌ [Slug For Strapi] Plugin disabled globally`);
93
+ return null;
94
+ }
95
+
96
+ // Check if enabled for this content-type
97
+ const contentTypeConfig = config.contentTypes[contentType];
98
+ if (contentTypeConfig && contentTypeConfig.enabled === false) {
99
+ console.log(`⚠️ [Slug For Strapi] Generation disabled for ${contentType}`);
100
+ return null;
101
+ }
102
+
103
+ // If slug already exists, check update settings
104
+ if (data.slug && !config.updateExistingSlugs) {
105
+ console.log(`⚠️ [Slug For Strapi] Slug already exists, skipping: "${data.slug}"`);
106
+ return null;
107
+ }
108
+
109
+ if (data.slug && config.updateExistingSlugs) {
110
+ console.log(`🔄 [Slug For Strapi] Slug exists, but updating according to settings: "${data.slug}"`);
111
+ }
112
+
113
+ console.log(`🔍 [Slug For Strapi] Looking for text in field "${config.sourceField}":`, data[config.sourceField]);
114
+
115
+ // Get text from primary field
116
+ let sourceText = this.extractTextFromField(
117
+ data[config.sourceField]
118
+ );
119
+
120
+ console.log(`📝 [Slug For Strapi] Extracted text from primary field:`, sourceText);
121
+
122
+ // If primary field is empty, try fallback
123
+ if (!sourceText && config.fallbackField) {
124
+ console.log(`🔍 [Slug For Strapi] Trying fallback field "${config.fallbackField}":`, data[config.fallbackField]);
125
+ sourceText = this.extractTextFromField(
126
+ data[config.fallbackField]
127
+ );
128
+ console.log(`📝 [Slug For Strapi] Extracted text from fallback field:`, sourceText);
129
+ }
130
+
131
+ if (!sourceText) {
132
+ console.log(`⚠️ [Slug For Strapi] No text found for slug generation in ${contentType}`);
133
+ console.log(`🔍 [Slug For Strapi] Checked fields: ${config.sourceField}, ${config.fallbackField}`);
134
+ return null;
135
+ }
136
+
137
+ console.log(`🚀 [Slug For Strapi] Generating slug for ${contentType} from text:`, sourceText);
138
+
139
+ // Generate unique slug
140
+ const slug = await this.generateUniqueSlug(
141
+ sourceText,
142
+ contentType,
143
+ documentId,
144
+ config.slugifyOptions
145
+ );
146
+
147
+ console.log(`✅ [Slug For Strapi] Final slug:`, slug);
148
+ return slug;
149
+ },
150
+
151
+ /**
152
+ * Processes all content-types and finds those with slug field
153
+ * @returns {Array} - list of content-types with slug field
154
+ */
155
+ getContentTypesWithSlug() {
156
+ const contentTypes = strapi.contentTypes;
157
+ const typesWithSlug = [];
158
+
159
+ for (const [uid, contentType] of Object.entries(contentTypes)) {
160
+ // Skip system types
161
+ if (!uid.startsWith('api::')) continue;
162
+
163
+ // Check if slug field exists
164
+ if (contentType.attributes && contentType.attributes.slug) {
165
+ typesWithSlug.push({
166
+ uid,
167
+ displayName: contentType.info?.displayName || uid,
168
+ hasSlugField: true,
169
+ hasTitleField: !!contentType.attributes.title,
170
+ hasNameField: !!contentType.attributes.name,
171
+ });
172
+ }
173
+ }
174
+
175
+ console.log('📋 [Slug For Strapi] Found content-types with slug field:', typesWithSlug);
176
+ return typesWithSlug;
177
+ }
178
+ });
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ export default {
4
+ register(app) {
5
+ // Registration of link in main menu removed, as settings are done via config file
6
+ // app.addMenuLink({...});
7
+ },
8
+
9
+ bootstrap(app) {
10
+ console.log('🚀 [Slug For Strapi] Admin panel bootstrap');
11
+ },
12
+ };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('./server');