@nuasite/cms 0.18.1 → 0.19.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/dist/editor.js +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +76 -2
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
package/src/media/local.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import fs from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
|
|
4
|
+
import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
|
|
5
5
|
|
|
6
6
|
export interface LocalStorageOptions {
|
|
7
7
|
/** Directory to store media files (relative to project root or absolute). Default: 'public/uploads' */
|
|
@@ -20,16 +20,29 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
|
|
|
20
20
|
async list(opts) {
|
|
21
21
|
const limit = opts?.limit ?? 50
|
|
22
22
|
const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0
|
|
23
|
+
const folder = opts?.folder ?? ''
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
const targetDir = folder ? path.join(dir, folder) : dir
|
|
26
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
25
27
|
|
|
26
|
-
const entries = await fs.readdir(
|
|
28
|
+
const entries = await fs.readdir(targetDir, { withFileTypes: true })
|
|
29
|
+
|
|
30
|
+
// Collect subfolders
|
|
31
|
+
const folders: MediaFolderItem[] = entries
|
|
32
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
33
|
+
.map((e) => ({
|
|
34
|
+
name: e.name,
|
|
35
|
+
path: folder ? `${folder}/${e.name}` : e.name,
|
|
36
|
+
}))
|
|
37
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
38
|
+
|
|
39
|
+
// Collect files
|
|
27
40
|
const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
|
|
28
41
|
|
|
29
42
|
// Get stats for sorting by mtime desc
|
|
30
43
|
const withStats = await Promise.all(
|
|
31
44
|
files.map(async (f) => {
|
|
32
|
-
const filePath = path.join(
|
|
45
|
+
const filePath = path.join(targetDir, f.name)
|
|
33
46
|
const stat = await fs.stat(filePath)
|
|
34
47
|
return { name: f.name, stat }
|
|
35
48
|
}),
|
|
@@ -39,45 +52,55 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
|
|
|
39
52
|
const slice = withStats.slice(offset, offset + limit)
|
|
40
53
|
const hasMore = offset + limit < withStats.length
|
|
41
54
|
|
|
55
|
+
const urlFolder = folder ? `/${folder}` : ''
|
|
42
56
|
const items = slice.map((f) => {
|
|
43
57
|
const ext = path.extname(f.name).toLowerCase()
|
|
44
58
|
const contentType = mimeFromExt(ext)
|
|
45
59
|
return {
|
|
46
|
-
id: f.name,
|
|
47
|
-
url: `${urlPrefix}/${f.name}`,
|
|
60
|
+
id: folder ? `${folder}/${f.name}` : f.name,
|
|
61
|
+
url: `${urlPrefix}${urlFolder}/${f.name}`,
|
|
48
62
|
filename: f.name,
|
|
49
63
|
contentType,
|
|
50
64
|
uploadedAt: f.stat.mtime.toISOString(),
|
|
65
|
+
folder: folder || undefined,
|
|
51
66
|
}
|
|
52
67
|
})
|
|
53
68
|
|
|
54
69
|
return {
|
|
55
70
|
items,
|
|
71
|
+
folders,
|
|
56
72
|
hasMore,
|
|
57
73
|
cursor: hasMore ? String(offset + limit) : undefined,
|
|
58
74
|
} satisfies MediaListResult
|
|
59
75
|
},
|
|
60
76
|
|
|
61
|
-
async upload(file, filename, contentType) {
|
|
62
|
-
|
|
77
|
+
async upload(file, filename, contentType, uploadOpts) {
|
|
78
|
+
const folder = uploadOpts?.folder ?? ''
|
|
79
|
+
const targetDir = folder ? path.join(dir, folder) : dir
|
|
80
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
63
81
|
|
|
64
82
|
const ext = getFileExtension(filename)
|
|
65
83
|
const uuid = randomUUID()
|
|
66
84
|
const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
|
|
67
|
-
const filePath = path.join(
|
|
85
|
+
const filePath = path.join(targetDir, newFilename)
|
|
68
86
|
|
|
69
87
|
await fs.writeFile(filePath, file)
|
|
70
88
|
|
|
89
|
+
const urlFolder = folder ? `/${folder}` : ''
|
|
90
|
+
const id = folder ? `${folder}/${newFilename}` : newFilename
|
|
91
|
+
|
|
71
92
|
return {
|
|
72
93
|
success: true,
|
|
73
|
-
url: `${urlPrefix}/${newFilename}`,
|
|
94
|
+
url: `${urlPrefix}${urlFolder}/${newFilename}`,
|
|
74
95
|
filename: newFilename,
|
|
75
|
-
id
|
|
96
|
+
id,
|
|
76
97
|
} satisfies MediaUploadResult
|
|
77
98
|
},
|
|
78
99
|
|
|
79
100
|
async delete(id) {
|
|
80
|
-
|
|
101
|
+
// id may contain folder path — resolve safely within dir
|
|
102
|
+
const safePath = id.split('/').map((s) => path.basename(s)).join('/')
|
|
103
|
+
const filePath = path.join(dir, safePath)
|
|
81
104
|
try {
|
|
82
105
|
await fs.unlink(filePath)
|
|
83
106
|
return { success: true }
|
|
@@ -86,29 +109,44 @@ export function createLocalStorageAdapter(options: LocalStorageOptions = {}): Me
|
|
|
86
109
|
return { success: false, error: message }
|
|
87
110
|
}
|
|
88
111
|
},
|
|
112
|
+
|
|
113
|
+
async createFolder(folder) {
|
|
114
|
+
const segments = folder.split('/').filter(Boolean)
|
|
115
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
116
|
+
return { success: false, error: 'Invalid folder name' }
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await fs.mkdir(path.join(dir, ...segments), { recursive: true })
|
|
120
|
+
return { success: true }
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
123
|
+
return { success: false, error: message }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
89
126
|
}
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
function getFileExtension(filename: string): string {
|
|
129
|
+
export function getFileExtension(filename: string): string {
|
|
93
130
|
const parts = filename.split('.')
|
|
94
131
|
const ext = parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
|
|
95
132
|
// Only allow alphanumeric extensions to prevent injection
|
|
96
133
|
return /^[a-z0-9]+$/.test(ext) ? ext : ''
|
|
97
134
|
}
|
|
98
135
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
136
|
+
export const MIME_BY_EXT: Record<string, string> = {
|
|
137
|
+
'.jpg': 'image/jpeg',
|
|
138
|
+
'.jpeg': 'image/jpeg',
|
|
139
|
+
'.png': 'image/png',
|
|
140
|
+
'.gif': 'image/gif',
|
|
141
|
+
'.webp': 'image/webp',
|
|
142
|
+
'.avif': 'image/avif',
|
|
143
|
+
'.svg': 'image/svg+xml',
|
|
144
|
+
'.ico': 'image/x-icon',
|
|
145
|
+
'.mp4': 'video/mp4',
|
|
146
|
+
'.webm': 'video/webm',
|
|
147
|
+
'.pdf': 'application/pdf',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function mimeFromExt(ext: string): string {
|
|
151
|
+
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
|
114
152
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Dirent } from 'node:fs'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getProjectRoot } from '../config'
|
|
5
|
+
import { MIME_BY_EXT, mimeFromExt } from './local'
|
|
6
|
+
import type { MediaItem } from './types'
|
|
7
|
+
|
|
8
|
+
const IMAGE_EXTENSIONS = new Set(Object.entries(MIME_BY_EXT).filter(([, mime]) => mime.startsWith('image/')).map(([ext]) => ext))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan the project for image files in `public/` and `src/` directories,
|
|
12
|
+
* excluding the media uploads directory to avoid duplicates.
|
|
13
|
+
*/
|
|
14
|
+
export async function listProjectImages(options?: {
|
|
15
|
+
excludeDir?: string
|
|
16
|
+
}): Promise<MediaItem[]> {
|
|
17
|
+
const root = getProjectRoot()
|
|
18
|
+
const excludeDir = options?.excludeDir ? path.resolve(options.excludeDir) : null
|
|
19
|
+
|
|
20
|
+
const scanDirs = [
|
|
21
|
+
{ dir: path.join(root, 'public'), urlPrefix: '' },
|
|
22
|
+
{ dir: path.join(root, 'src'), urlPrefix: null },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const results = await Promise.all(
|
|
26
|
+
scanDirs.map(({ dir, urlPrefix }) => {
|
|
27
|
+
const items: MediaItem[] = []
|
|
28
|
+
return scanDirectory(dir, dir, urlPrefix, excludeDir, items).then(() => items)
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const items = results.flat()
|
|
33
|
+
items.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
34
|
+
return items
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function scanDirectory(
|
|
38
|
+
currentDir: string,
|
|
39
|
+
baseDir: string,
|
|
40
|
+
urlPrefix: string | null,
|
|
41
|
+
excludeDir: string | null,
|
|
42
|
+
items: MediaItem[],
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (excludeDir && path.resolve(currentDir) === excludeDir) return
|
|
45
|
+
|
|
46
|
+
let entries: Dirent[]
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true }) as Dirent[]
|
|
49
|
+
} catch {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const subdirs: Promise<void>[] = []
|
|
54
|
+
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const name = String(entry.name)
|
|
57
|
+
if (name.startsWith('.') || name === 'node_modules') continue
|
|
58
|
+
const fullPath = path.join(currentDir, name)
|
|
59
|
+
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
subdirs.push(scanDirectory(fullPath, baseDir, urlPrefix, excludeDir, items))
|
|
62
|
+
} else if (entry.isFile()) {
|
|
63
|
+
const ext = path.extname(name).toLowerCase()
|
|
64
|
+
if (!IMAGE_EXTENSIONS.has(ext)) continue
|
|
65
|
+
|
|
66
|
+
const relativePath = path.relative(baseDir, fullPath).split(path.sep).join('/')
|
|
67
|
+
const url = urlPrefix !== null
|
|
68
|
+
? `/${relativePath}`
|
|
69
|
+
: `/${path.relative(getProjectRoot(), fullPath).split(path.sep).join('/')}`
|
|
70
|
+
|
|
71
|
+
items.push({
|
|
72
|
+
id: `project:${url}`,
|
|
73
|
+
url,
|
|
74
|
+
filename: name,
|
|
75
|
+
contentType: mimeFromExt(ext),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await Promise.all(subdirs)
|
|
81
|
+
}
|
package/src/media/s3.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import
|
|
3
|
+
import { getFileExtension, mimeFromExt } from './local'
|
|
4
|
+
import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
|
|
4
5
|
|
|
5
6
|
export interface S3StorageOptions {
|
|
6
7
|
bucket: string
|
|
@@ -52,15 +53,36 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
|
|
|
52
53
|
const client = await getClient()
|
|
53
54
|
|
|
54
55
|
const limit = opts?.limit ?? 50
|
|
56
|
+
const folder = opts?.folder ?? ''
|
|
57
|
+
|
|
58
|
+
// Build the S3 prefix: base prefix + subfolder
|
|
59
|
+
const listPrefix = [prefix, folder].filter(Boolean).join('/')
|
|
60
|
+
const delimiterPrefix = listPrefix ? `${listPrefix}/` : ''
|
|
61
|
+
|
|
55
62
|
const command = new ListObjectsV2Command({
|
|
56
63
|
Bucket: bucket,
|
|
57
|
-
Prefix:
|
|
64
|
+
Prefix: delimiterPrefix,
|
|
65
|
+
Delimiter: '/',
|
|
58
66
|
MaxKeys: limit + 1,
|
|
59
67
|
...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
|
|
60
68
|
})
|
|
61
69
|
|
|
62
70
|
const result = await client.send(command)
|
|
63
|
-
|
|
71
|
+
|
|
72
|
+
// Extract subfolders from CommonPrefixes
|
|
73
|
+
const folders: MediaFolderItem[] = (result.CommonPrefixes ?? []).map((cp: any) => {
|
|
74
|
+
const fullPrefix = (cp.Prefix as string).replace(/\/$/, '')
|
|
75
|
+
const name = fullPrefix.split('/').pop() ?? fullPrefix
|
|
76
|
+
const relativePath = prefix ? fullPrefix.slice(prefix.length + 1) : fullPrefix
|
|
77
|
+
return { name, path: relativePath }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Extract files (exclude folder marker keys)
|
|
81
|
+
const contents = (result.Contents ?? []).filter((obj: any) => {
|
|
82
|
+
const key = obj.Key as string
|
|
83
|
+
return key !== delimiterPrefix
|
|
84
|
+
})
|
|
85
|
+
|
|
64
86
|
const hasMore = contents.length > limit
|
|
65
87
|
const items = contents.slice(0, limit).map((obj: any) => {
|
|
66
88
|
const key = obj.Key as string
|
|
@@ -69,26 +91,30 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
|
|
|
69
91
|
id: key,
|
|
70
92
|
url: getUrl(key),
|
|
71
93
|
filename,
|
|
72
|
-
contentType:
|
|
94
|
+
contentType: mimeFromExt(path.extname(key).toLowerCase()),
|
|
73
95
|
uploadedAt: obj.LastModified?.toISOString(),
|
|
96
|
+
folder: folder || undefined,
|
|
74
97
|
}
|
|
75
98
|
})
|
|
76
99
|
|
|
77
100
|
return {
|
|
78
101
|
items,
|
|
102
|
+
folders,
|
|
79
103
|
hasMore,
|
|
80
104
|
cursor: hasMore ? result.NextContinuationToken : undefined,
|
|
81
105
|
} satisfies MediaListResult
|
|
82
106
|
},
|
|
83
107
|
|
|
84
|
-
async upload(file, filename, contentType) {
|
|
108
|
+
async upload(file, filename, contentType, uploadOpts) {
|
|
85
109
|
const { PutObjectCommand } = await loadS3()
|
|
86
110
|
const client = await getClient()
|
|
87
111
|
|
|
88
112
|
const ext = getFileExtension(filename)
|
|
89
113
|
const uuid = randomUUID()
|
|
90
114
|
const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
|
|
91
|
-
const
|
|
115
|
+
const folder = uploadOpts?.folder ?? ''
|
|
116
|
+
const keyParts = [prefix, folder, newFilename].filter(Boolean)
|
|
117
|
+
const key = keyParts.join('/')
|
|
92
118
|
|
|
93
119
|
const command = new PutObjectCommand({
|
|
94
120
|
Bucket: bucket,
|
|
@@ -126,8 +152,3 @@ export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageA
|
|
|
126
152
|
},
|
|
127
153
|
}
|
|
128
154
|
}
|
|
129
|
-
|
|
130
|
-
function getFileExtension(filename: string): string {
|
|
131
|
-
const parts = filename.split('.')
|
|
132
|
-
return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
|
|
133
|
-
}
|
package/src/media/types.ts
CHANGED
|
@@ -7,10 +7,30 @@ export interface MediaItem {
|
|
|
7
7
|
width?: number
|
|
8
8
|
height?: number
|
|
9
9
|
uploadedAt?: string
|
|
10
|
+
/** Folder path relative to media root (e.g. 'photos') */
|
|
11
|
+
folder?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MediaFolderItem {
|
|
15
|
+
/** Folder name (last segment) */
|
|
16
|
+
name: string
|
|
17
|
+
/** Full relative path from media root (e.g. 'photos/vacation') */
|
|
18
|
+
path: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MediaTypeFilter = 'all' | 'photo' | 'graphic' | 'video' | 'document'
|
|
22
|
+
|
|
23
|
+
export interface MediaListOptions {
|
|
24
|
+
limit?: number
|
|
25
|
+
cursor?: string
|
|
26
|
+
/** List contents of this subfolder (relative to media root) */
|
|
27
|
+
folder?: string
|
|
10
28
|
}
|
|
11
29
|
|
|
12
30
|
export interface MediaListResult {
|
|
13
31
|
items: MediaItem[]
|
|
32
|
+
/** Subfolders in the current directory */
|
|
33
|
+
folders: MediaFolderItem[]
|
|
14
34
|
hasMore: boolean
|
|
15
35
|
cursor?: string
|
|
16
36
|
}
|
|
@@ -25,9 +45,11 @@ export interface MediaUploadResult {
|
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
export interface MediaStorageAdapter {
|
|
28
|
-
list(options?:
|
|
29
|
-
upload(file: Buffer, filename: string, contentType: string): Promise<MediaUploadResult>
|
|
48
|
+
list(options?: MediaListOptions): Promise<MediaListResult>
|
|
49
|
+
upload(file: Buffer, filename: string, contentType: string, options?: { folder?: string }): Promise<MediaUploadResult>
|
|
30
50
|
delete(id: string): Promise<{ success: boolean; error?: string }>
|
|
51
|
+
/** Create an empty folder. Folders are also created implicitly on upload. */
|
|
52
|
+
createFolder?(folder: string): Promise<{ success: boolean; error?: string }>
|
|
31
53
|
/** Local filesystem info for direct file serving in dev (bypasses Vite's public dir cache) */
|
|
32
54
|
staticFiles?: { urlPrefix: string; dir: string }
|
|
33
55
|
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slugify text for URL paths.
|
|
3
|
+
* Lowercases, strips non-word characters, collapses whitespace/underscores to hyphens.
|
|
4
|
+
*/
|
|
5
|
+
export function slugify(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[^\w\s\-/]/g, '')
|
|
10
|
+
.replace(/[\s_]+/g, '-')
|
|
11
|
+
.replace(/^[-/]+|[-/]+$/g, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Slugify with diacritics normalization for href paths.
|
|
16
|
+
* "Lidé" → "lide", "Aktuálně z nezisku" → "aktualne-z-nezisku"
|
|
17
|
+
*/
|
|
18
|
+
export function slugifyHref(text: string): string {
|
|
19
|
+
return '/' + text
|
|
20
|
+
.normalize('NFD')
|
|
21
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/[^\w\s-]/g, '')
|
|
25
|
+
.replace(/[\s_]+/g, '-')
|
|
26
|
+
.replace(/^-+|-+$/g, '')
|
|
27
|
+
}
|