@aduptive/strapi-blockscene 2.0.0-alpha.14 → 2.0.0-alpha.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aduptive/strapi-blockscene",
3
- "version": "2.0.0-alpha.14",
3
+ "version": "2.0.0-alpha.15",
4
4
  "description": "Visual component gallery for native Strapi dynamic zones.",
5
5
  "license": "MIT",
6
6
  "author": "Andrea Scarpello <aduptive@gmail.com>",
@@ -14,6 +14,7 @@
14
14
  "dist",
15
15
  "strapi-admin.js",
16
16
  "strapi-server.js",
17
+ "text-template.js",
17
18
  "README.md",
18
19
  "LICENSE"
19
20
  ],
@@ -27,6 +28,10 @@
27
28
  "require": "./strapi-server.js",
28
29
  "default": "./strapi-server.js"
29
30
  },
31
+ "./text-template": {
32
+ "require": "./text-template.js",
33
+ "default": "./text-template.js"
34
+ },
30
35
  "./package.json": "./package.json"
31
36
  },
32
37
  "strapi": {
@@ -60,6 +65,6 @@
60
65
  },
61
66
  "publishConfig": {
62
67
  "access": "public",
63
- "tag": "next"
68
+ "tag": "latest"
64
69
  }
65
70
  }
@@ -0,0 +1,201 @@
1
+ 'use strict'
2
+
3
+ const CUSTOM_FIELD = 'plugin::blockscene.text-template'
4
+ const TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p']
5
+ const ROLES = ['eyebrow', 'title', 'description', 'body']
6
+ const MODIFIERS = ['uppercase', 'lowercase', 'underline']
7
+ const ID = /^[a-z][a-z0-9_-]{0,47}$/
8
+ const TOKEN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/
9
+ const MAX_ENTRIES = 24
10
+ const MAX_TEXT = 50000
11
+ // Schema UIDs are lowercase, while Strapi attribute names commonly use camelCase.
12
+ const BINDING = /^[a-z][a-z0-9_-]*(?:::[a-z][a-z0-9_-]*)?(?:\.[A-Za-z_][A-Za-z0-9_-]*)+$/
13
+
14
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value)
15
+ const codePoints = value => [...String(value || '')].length
16
+ const cleanLineBreaks = value => String(value || '').replace(/\r\n?/g, '\n')
17
+
18
+ function templateError(message) {
19
+ const error = new Error(message)
20
+ error.name = 'ValidationError'
21
+ throw error
22
+ }
23
+
24
+ function validateTemplate(value) {
25
+ if (!Array.isArray(value) || value.length > MAX_ENTRIES) templateError(`Text template must contain at most ${MAX_ENTRIES} entries`)
26
+ const ids = new Set()
27
+ return value.map((raw, index) => {
28
+ if (!plain(raw)) templateError(`Text template entry ${index + 1} must be an object`)
29
+ const allowed = ['id', 'label', 'role', 'tag', 'token', 'modifiers', 'required', 'allowBreaks', 'softLines', 'maxChars', 'demo']
30
+ for (const key of Object.keys(raw)) if (!allowed.includes(key)) templateError(`Unknown text template option "${key}"`)
31
+ if (typeof raw.id !== 'string' || !ID.test(raw.id) || ids.has(raw.id)) templateError(`Invalid or duplicate text template id "${raw.id}"`)
32
+ ids.add(raw.id)
33
+ if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) templateError(`Invalid label for text template entry "${raw.id}"`)
34
+ if (!ROLES.includes(raw.role)) templateError(`Invalid role for text template entry "${raw.id}"`)
35
+ if (!TAGS.includes(raw.tag)) templateError(`Invalid Tag HTML for text template entry "${raw.id}"`)
36
+ if (raw.token !== undefined && (typeof raw.token !== 'string' || (raw.token && !TOKEN.test(raw.token)))) templateError(`Invalid style token for text template entry "${raw.id}"`)
37
+ if (!Array.isArray(raw.modifiers) || raw.modifiers.some(item => !MODIFIERS.includes(item)) || new Set(raw.modifiers).size !== raw.modifiers.length || (raw.modifiers.includes('uppercase') && raw.modifiers.includes('lowercase'))) templateError(`Invalid modifiers for text template entry "${raw.id}"`)
38
+ if (typeof raw.required !== 'boolean' || typeof raw.allowBreaks !== 'boolean') templateError(`Invalid rules for text template entry "${raw.id}"`)
39
+ if (raw.softLines !== undefined && (!Number.isInteger(raw.softLines) || raw.softLines < 1 || raw.softLines > 20)) templateError(`Invalid line guidance for text template entry "${raw.id}"`)
40
+ if (raw.maxChars !== undefined && (!Number.isInteger(raw.maxChars) || raw.maxChars < 1 || raw.maxChars > MAX_TEXT)) templateError(`Invalid character limit for text template entry "${raw.id}"`)
41
+ if (typeof raw.demo !== 'string' || codePoints(raw.demo) > MAX_TEXT) templateError(`Invalid demo text for text template entry "${raw.id}"`)
42
+ if (!raw.allowBreaks && /[\r\n]/.test(raw.demo)) templateError(`Demo text for "${raw.id}" cannot contain line breaks`)
43
+ if (raw.maxChars && codePoints(raw.demo) > raw.maxChars) templateError(`Demo text for "${raw.id}" exceeds its character limit`)
44
+ return { id: raw.id, label: raw.label.trim(), role: raw.role, tag: raw.tag, token: raw.token || '', modifiers: [...raw.modifiers], required: raw.required,
45
+ allowBreaks: raw.allowBreaks, ...(raw.softLines && { softLines: raw.softLines }), ...(raw.maxChars && { maxChars: raw.maxChars }), demo: cleanLineBreaks(raw.demo) }
46
+ })
47
+ }
48
+
49
+ function normalizeValue(value) {
50
+ if (value == null) return { version: 1, values: {} }
51
+ if (!plain(value) || value.version !== 1 || !plain(value.values)) templateError('Text template value must be { version: 1, values: { ... } }')
52
+ if (Object.keys(value.values).length > 100) templateError('Text template value has too many entries')
53
+ const values = {}
54
+ for (const [id, text] of Object.entries(value.values)) {
55
+ if (!ID.test(id) || typeof text !== 'string' || codePoints(text) > MAX_TEXT) templateError(`Invalid text template value for "${id}"`)
56
+ values[id] = cleanLineBreaks(text)
57
+ }
58
+ return { version: 1, values }
59
+ }
60
+
61
+ function validateValue(template, value) {
62
+ const entries = validateTemplate(template)
63
+ const normalized = normalizeValue(value)
64
+ const errors = []
65
+ for (const entry of entries) {
66
+ const text = normalized.values[entry.id] || ''
67
+ if (entry.required && !text.trim()) errors.push({ id: entry.id, code: 'required', message: `${entry.label} is required` })
68
+ if (!entry.allowBreaks && /\n/.test(text)) errors.push({ id: entry.id, code: 'lineBreak', message: `${entry.label} does not allow line breaks` })
69
+ if (entry.maxChars && codePoints(text) > entry.maxChars) errors.push({ id: entry.id, code: 'maxChars', message: `${entry.label} exceeds ${entry.maxChars} characters` })
70
+ }
71
+ return { value: normalized, errors }
72
+ }
73
+
74
+ function demoValue(template) {
75
+ return { version: 1, values: Object.fromEntries(validateTemplate(template).map(entry => [entry.id, entry.demo])) }
76
+ }
77
+
78
+ function renderTextTemplate(template, value) {
79
+ const entries = validateTemplate(template)
80
+ const normalized = normalizeValue(value)
81
+ return entries.flatMap(entry => {
82
+ const text = normalized.values[entry.id] || ''
83
+ if (!text.trim()) return []
84
+ return [{ id: entry.id, tag: entry.tag, text, lines: text.split('\n'), className: textTemplateClassName(entry) }]
85
+ })
86
+ }
87
+
88
+ function textTemplateClassName(entry) {
89
+ if (!plain(entry) || (entry.token && !TOKEN.test(entry.token)) || !Array.isArray(entry.modifiers) || entry.modifiers.some(item => !MODIFIERS.includes(item))) return ''
90
+ return [entry.token, ...entry.modifiers].filter(Boolean).join(' ')
91
+ }
92
+
93
+ function renderTextTemplateElement(part, createElement) {
94
+ if (!plain(part) || !TAGS.includes(part.tag) || typeof part.text !== 'string' || typeof createElement !== 'function') templateError('Invalid projected text template part')
95
+ const children = part.text.split('\n').flatMap((line, index) => index ? [createElement('br', { key: `${part.id}-break-${index}` }), line] : [line])
96
+ return createElement(part.tag, { key: part.id, className: part.className || undefined }, ...children)
97
+ }
98
+
99
+ function binding(uid, name) { return `${uid}.${name}` }
100
+ function isTextTemplateBinding(value) { return typeof value === 'string' && BINDING.test(value) }
101
+ function resolveBinding(attribute, contentTypeUID, name) {
102
+ const explicit = attribute?.options?.blockscene?.textTemplate || attribute?.pluginOptions?.blockscene?.textTemplate
103
+ if (isTextTemplateBinding(explicit)) return explicit
104
+ return typeof contentTypeUID === 'string' && contentTypeUID ? binding(contentTypeUID, String(name || '').split('.').pop()) : ''
105
+ }
106
+ function createEntryId(entries, values, nonce) {
107
+ const used = new Set([...entries.map(entry => entry.id), ...Object.keys(values || {})])
108
+ const safe = String(nonce || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32)
109
+ const base = `text-${safe || Date.now().toString(36)}`
110
+ let id = base, suffix = 2
111
+ while (used.has(id)) id = `${base}-${suffix++}`
112
+ return id
113
+ }
114
+ function applyTextChange(entry, current, next, { composing = false, compositionStart = current } = {}) {
115
+ const value = entry.allowBreaks ? cleanLineBreaks(next) : String(next || '').replace(/[\r\n]+/g, ' ')
116
+ const previous = String(compositionStart ?? current ?? '')
117
+ const exceeds = entry.maxChars && codePoints(value) > entry.maxChars
118
+ const recoverable = codePoints(previous) > entry.maxChars && codePoints(value) <= codePoints(previous)
119
+ if (exceeds && !composing && !recoverable) return { accepted: false, value: previous, code: 'maxChars' }
120
+ return { accepted: true, value }
121
+ }
122
+ function mergePartialDocument(stored, patch) { return { ...(plain(stored) ? stored : {}), ...(plain(patch) ? patch : {}) } }
123
+ function draftReadPlan(localized, { documentId, locale } = {}, populate = {}, defaultLocale) {
124
+ const common = { status: 'draft', ...(Object.keys(populate).length && { populate }) }
125
+ if (localized && locale === '*') return { many: true, query: { filters: { documentId }, locale: '*', ...common } }
126
+ return { many: false, query: { documentId, ...(localized && { locale: locale || defaultLocale }), ...common } }
127
+ }
128
+ async function validateBulkUpdates(query, where, populate, patch, validate) {
129
+ const entries = await query.findMany({ where: where || {}, ...(Object.keys(populate || {}).length && { populate }) })
130
+ for (const entry of entries) validate(mergePartialDocument(entry, patch))
131
+ return entries.length
132
+ }
133
+ // Schema files use `type: customField`; Strapi's runtime model exposes the registered underlying JSON type.
134
+ // The namespaced customField id is the stable discriminator in both representations.
135
+ function isTextTemplate(attr) { return attr?.customField === CUSTOM_FIELD }
136
+
137
+ function textTemplateBindings(schemas) {
138
+ const bindings = new Set()
139
+ for (const [uid, schema] of Object.entries(schemas || {})) {
140
+ for (const [name, attribute] of Object.entries(schema?.attributes || {})) {
141
+ if (!isTextTemplate(attribute)) continue
142
+ const key = resolveBinding(attribute, uid, name)
143
+ if (key) bindings.add(key)
144
+ }
145
+ }
146
+ return bindings
147
+ }
148
+
149
+ function validateDocument(schemas, uid, data, templates, path = []) {
150
+ if (!plain(data)) return []
151
+ const schema = schemas?.[uid]
152
+ if (!schema) return []
153
+ const errors = []
154
+ for (const [name, attr] of Object.entries(schema.attributes || {})) {
155
+ const here = [...path, name]
156
+ if (isTextTemplate(attr)) {
157
+ const template = templates?.[resolveBinding(attr, uid, name)]
158
+ if (!template) continue
159
+ let result
160
+ try { result = validateValue(template, data[name]) }
161
+ catch (error) { errors.push({ path: here, code: 'invalid', message: error.message }); continue }
162
+ errors.push(...result.errors.map(error => ({ ...error, path: [...here, error.id] })))
163
+ } else if (!(name in data)) continue
164
+ else if (attr?.type === 'component') {
165
+ const list = attr.repeatable ? data[name] : [data[name]]
166
+ if (Array.isArray(list)) list.forEach((item, index) => errors.push(...validateDocument(schemas, attr.component, item, templates, attr.repeatable ? [...here, index] : here)))
167
+ } else if (attr?.type === 'dynamiczone' && Array.isArray(data[name])) {
168
+ data[name].forEach((item, index) => errors.push(...validateDocument(schemas, item?.__component, item, templates, [...here, index])))
169
+ }
170
+ }
171
+ return errors
172
+ }
173
+
174
+ function textTemplatePopulate(schemas, uid, templates) {
175
+ const active = new Set(Object.keys(templates || {}))
176
+ const walk = (schemaUid, seen = new Set()) => {
177
+ if (seen.has(schemaUid)) return { configured: false, populate: {} }
178
+ const next = new Set(seen).add(schemaUid)
179
+ let configured = false
180
+ const populate = {}
181
+ for (const [name, attr] of Object.entries(schemas?.[schemaUid]?.attributes || {})) {
182
+ if (isTextTemplate(attr) && active.has(resolveBinding(attr, schemaUid, name))) configured = true
183
+ else if (attr?.type === 'component') {
184
+ const child = walk(attr.component, next)
185
+ if (child.configured) { populate[name] = Object.keys(child.populate).length ? { populate: child.populate } : true; configured = true }
186
+ } else if (attr?.type === 'dynamiczone') {
187
+ const children = (attr.components || []).map(component => [component, walk(component, next)]).filter(([, child]) => child.configured)
188
+ if (children.length) {
189
+ populate[name] = { on: Object.fromEntries(children.map(([component, child]) => [component,
190
+ Object.keys(child.populate).length ? { populate: child.populate } : {}])) }
191
+ configured = true
192
+ }
193
+ }
194
+ }
195
+ return { configured, populate }
196
+ }
197
+ const result = walk(uid)
198
+ return result.configured ? result.populate : null
199
+ }
200
+
201
+ module.exports = { CUSTOM_FIELD, TAGS, ROLES, MODIFIERS, MAX_ENTRIES, codePoints, validateTemplate, normalizeValue, validateValue, demoValue, renderTextTemplate, renderTextTemplateElement, textTemplateClassName, binding, isTextTemplateBinding, resolveBinding, createEntryId, applyTextChange, mergePartialDocument, draftReadPlan, validateBulkUpdates, isTextTemplate, textTemplateBindings, validateDocument, textTemplatePopulate }