@nan0web/ui-payload 3.3.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.
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { fromNan0Html } from './fromNan0Html.js'
3
+ import { toNan0Html } from './toNan0Html.js'
4
+
5
+ describe('NaN0HTML ↔ Lexical Converter Round-trip', () => {
6
+ it('converts basic paragraphs and text formats', () => {
7
+ const ast = { p: [{ strong: 'Bold text' }, ' and ', { em: 'italic text' }] }
8
+ const lexical = fromNan0Html(ast)
9
+ expect(lexical.root.type).toBe('root')
10
+ expect(lexical.root.children.length).toBe(1)
11
+ expect(lexical.root.children[0].type).toBe('paragraph')
12
+
13
+ const backToAst = toNan0Html(lexical)
14
+ expect(backToAst).toEqual(ast)
15
+ })
16
+
17
+ it('converts headings correctly', () => {
18
+ const ast = { h2: 'Title Heading' }
19
+ const lexical = fromNan0Html(ast)
20
+ expect(lexical.root.children[0].type).toBe('heading')
21
+ expect(lexical.root.children[0].tag).toBe('h2')
22
+
23
+ const backToAst = toNan0Html(lexical)
24
+ expect(backToAst).toEqual(ast)
25
+ })
26
+
27
+ it('converts lists and items', () => {
28
+ const ast = { ul: [{ li: 'Item 1' }, { li: 'Item 2' }] }
29
+ const lexical = fromNan0Html(ast)
30
+ expect(lexical.root.children[0].type).toBe('list')
31
+ expect(lexical.root.children[0].listType).toBe('bullet')
32
+
33
+ const backToAst = toNan0Html(lexical)
34
+ expect(backToAst).toEqual(ast)
35
+ })
36
+
37
+ it('converts links with attributes', () => {
38
+ const ast = { a: 'Click here', $href: 'https://example.com', $target: '_blank' }
39
+ const lexical = fromNan0Html(ast)
40
+
41
+ const backToAst = toNan0Html(lexical)
42
+ expect(backToAst).toEqual({ a: 'Click here', $href: 'https://example.com', $target: '_blank' })
43
+ })
44
+
45
+ it('preserves nan0-component losslessly', () => {
46
+ const ast = { 'Card.Details': { card: '$' } }
47
+ const lexical = fromNan0Html(ast)
48
+ expect(lexical.root.children[0].type).toBe('nan0-component')
49
+ expect(lexical.root.children[0].component).toBe('Card.Details')
50
+
51
+ const backToAst = toNan0Html(lexical)
52
+ expect(backToAst).toEqual(ast)
53
+ })
54
+
55
+ it('preserves unknown tags as nan0-raw', () => {
56
+ const ast = { 'custom-widget': 'Widget content', $mode: 'test' }
57
+ const lexical = fromNan0Html(ast)
58
+ expect(lexical.root.children[0].type).toBe('nan0-raw')
59
+
60
+ const backToAst = toNan0Html(lexical)
61
+ expect(backToAst).toEqual({ 'custom-widget': 'Widget content', $mode: 'test' })
62
+ })
63
+ })
@@ -0,0 +1,188 @@
1
+ /**
2
+ * toNan0Html
3
+ * Converts a Payload Lexical state JSON back into a NaN0HTML AST.
4
+ *
5
+ * Reconstructs:
6
+ * - Native Lexical nodes (paragraph, heading, list, listitem, text, link, etc.) -> NaN0HTML tags/strings
7
+ * - nan0-element -> exact tag + attributes + converted children
8
+ * - nan0-component -> component name + props object
9
+ * - nan0-raw -> restored raw tag + attributes + children
10
+ */
11
+ import { FORMAT } from './fromNan0Html.js'
12
+
13
+ /**
14
+ * Convert Payload Lexical state (or root node) back into a NaN0HTML AST.
15
+ * @param {Object} lexicalState Lexical state object `{ root: { children: [...] } }` or node object
16
+ * @returns {any} NaN0HTML AST structure (Array, Object, or String)
17
+ */
18
+ export function toNan0Html(lexicalState) {
19
+ if (!lexicalState) return null
20
+
21
+ const root = lexicalState.root || lexicalState
22
+ if (root.type === 'root' && Array.isArray(root.children)) {
23
+ return convertNodes(root.children)
24
+ }
25
+
26
+ return convertNode(root)
27
+ }
28
+
29
+ /**
30
+ * Convert an array of Lexical nodes.
31
+ */
32
+ function convertNodes(nodes) {
33
+ if (!Array.isArray(nodes) || nodes.length === 0) return []
34
+ const result = []
35
+
36
+ for (const node of nodes) {
37
+ const converted = convertNode(node)
38
+ if (converted !== null && converted !== undefined) {
39
+ result.push(converted)
40
+ }
41
+ }
42
+
43
+ if (result.length === 1) return result[0]
44
+ return result
45
+ }
46
+
47
+ /**
48
+ * Convert a single Lexical node into its NaN0HTML AST representation.
49
+ */
50
+ function convertNode(node) {
51
+ if (!node || typeof node !== 'object') return null
52
+
53
+ switch (node.type) {
54
+ case 'text':
55
+ return convertTextNode(node)
56
+
57
+ case 'paragraph':
58
+ return wrapWithTag('p', convertNodes(node.children))
59
+
60
+ case 'heading': {
61
+ const tag = node.tag || 'h1'
62
+ return wrapWithTag(tag, convertNodes(node.children))
63
+ }
64
+
65
+ case 'blockquote':
66
+ return wrapWithTag('blockquote', convertNodes(node.children))
67
+
68
+ case 'horizontalrule':
69
+ return { hr: true }
70
+
71
+ case 'linebreak':
72
+ return { br: true }
73
+
74
+ case 'list': {
75
+ const tag = node.listType === 'number' || node.tag === 'ol' ? 'ol' : 'ul'
76
+ const content = convertNodes(node.children)
77
+ const attrs = {}
78
+ if (node.start && node.start !== 1) {
79
+ attrs.$start = node.start
80
+ }
81
+ return wrapWithTag(tag, content, attrs)
82
+ }
83
+
84
+ case 'listitem':
85
+ return wrapWithTag('li', convertNodes(node.children))
86
+
87
+ case 'link': {
88
+ const fields = node.fields || {}
89
+ const attrs = {}
90
+ if (fields.url) attrs.$href = fields.url
91
+ if (fields.newTab) attrs.$target = '_blank'
92
+ if (fields.rel) attrs.$rel = fields.rel
93
+ return wrapWithTag('a', convertNodes(node.children), attrs)
94
+ }
95
+
96
+ case 'upload': {
97
+ const fields = node.fields || {}
98
+ const attrs = {}
99
+ if (fields.value) attrs.$src = typeof fields.value === 'string' ? fields.value : fields.value.id || ''
100
+ return wrapWithTag('img', true, attrs)
101
+ }
102
+
103
+ case 'table':
104
+ return wrapWithTag('table', convertNodes(node.children))
105
+
106
+ case 'tablerow':
107
+ return wrapWithTag('tr', convertNodes(node.children))
108
+
109
+ case 'tablecell': {
110
+ const tag = node.header ? 'th' : 'td'
111
+ return wrapWithTag(tag, convertNodes(node.children))
112
+ }
113
+
114
+ case 'nan0-element': {
115
+ const tag = node.tag || 'div'
116
+ const attrs = formatAttributes(node.attributes || {})
117
+ const content = convertNodes(node.children)
118
+ return wrapWithTag(tag, content, attrs)
119
+ }
120
+
121
+ case 'nan0-component': {
122
+ const compName = node.component || 'Component'
123
+ const props = node.props || {}
124
+ return { [compName]: props }
125
+ }
126
+
127
+ case 'nan0-raw': {
128
+ const source = node.source || {}
129
+ const tag = source.tag || 'div'
130
+ const attrs = formatAttributes(source.attributes || {})
131
+ const content = Array.isArray(source.children) ? convertNodes(source.children) : (source.children || [])
132
+ return wrapWithTag(tag, content, attrs)
133
+ }
134
+
135
+ default:
136
+ if (Array.isArray(node.children)) {
137
+ return convertNodes(node.children)
138
+ }
139
+ return null
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Format text node taking inline format flags into account.
145
+ */
146
+ function convertTextNode(node) {
147
+ const text = node.text || ''
148
+ const format = node.format || 0
149
+ let result = text
150
+
151
+ if (format & FORMAT.bold) {
152
+ result = { strong: result }
153
+ }
154
+ if (format & FORMAT.italic) {
155
+ result = { em: result }
156
+ }
157
+ if (format & FORMAT.underline) {
158
+ result = { u: result }
159
+ }
160
+ if (format & FORMAT.strikethrough) {
161
+ result = { s: result }
162
+ }
163
+
164
+ return result
165
+ }
166
+
167
+ /**
168
+ * Prefix attribute object keys with `$`.
169
+ */
170
+ function formatAttributes(attrs) {
171
+ const result = {}
172
+ for (const [k, v] of Object.entries(attrs)) {
173
+ const key = k.startsWith('$') ? k : `$${k}`
174
+ result[key] = v
175
+ }
176
+ return result
177
+ }
178
+
179
+ /**
180
+ * Wrap content with a tag and optional $attributes.
181
+ */
182
+ function wrapWithTag(tag, content, attrs = {}) {
183
+ const element = { ...attrs }
184
+ element[tag] = content
185
+ return element
186
+ }
187
+
188
+ export default toNan0Html
@@ -0,0 +1,167 @@
1
+ import { Model } from '@nan0web/types'
2
+ import { CodeTemplate } from '@nan0web/ui'
3
+
4
+ /** @typedef {Object} Field */
5
+
6
+ /**
7
+ * PayloadCollectionTemplate - Model to generate Payload CMS CollectionConfig using CodeTemplate.
8
+ */
9
+ export class PayloadCollectionTemplate extends Model {
10
+ static alias = 'payload-collection-template'
11
+
12
+ static collectionSlug = {
13
+ help: 'Collection slug identifier',
14
+ default: 'item',
15
+ }
16
+ static useAsTitle = {
17
+ help: 'Field name used as title in admin',
18
+ default: 'title',
19
+ }
20
+ static labels = {
21
+ help: 'Localized labels object (singular/plural)',
22
+ default: { singular: 'Item', plural: 'Items' },
23
+ }
24
+ static group = {
25
+ help: 'Admin group configuration',
26
+ type: 'any',
27
+ default: 'Content',
28
+ }
29
+ static fields = {
30
+ help: 'Collection fields array',
31
+ default: [],
32
+ }
33
+
34
+ static template = {
35
+ help: 'Raw JavaScript template with CodeTemplate @replace blocks',
36
+ default: `/**
37
+ * @replace imports
38
+ * Custom imports block
39
+ */
40
+ /** @replace */
41
+
42
+ /**
43
+ * @replace collectionSlug
44
+ */
45
+ const collectionSlug = 'item'
46
+ /** @replace */
47
+
48
+ /**
49
+ * @replace labels
50
+ */
51
+ const labels = { singular: { uk: 'Item', en: 'Item' }, plural: { uk: 'Items', en: 'Items' } }
52
+ /** @replace */
53
+
54
+ /**
55
+ * @replace useAsTitle
56
+ */
57
+ const useAsTitle = 'title'
58
+ /** @replace */
59
+
60
+ /**
61
+ * @replace group
62
+ */
63
+ const group = { uk: 'Content', en: 'Content' }
64
+ /** @replace */
65
+
66
+ /**
67
+ * @replace fields
68
+ */
69
+ const fields = []
70
+ /** @replace */
71
+
72
+ import { accessFor, publicAccess } from '@nan0web/ui-payload'
73
+
74
+ /** @type {import('payload').CollectionConfig} */
75
+ export const collectionConfig = {
76
+ slug: collectionSlug,
77
+ labels,
78
+ admin: {
79
+ useAsTitle,
80
+ group,
81
+ },
82
+ access: {
83
+ read: publicAccess,
84
+ create: accessFor('admin', 'editor'),
85
+ update: accessFor('admin', 'editor'),
86
+ delete: accessFor('admin'),
87
+ },
88
+ fields,
89
+ }
90
+ `,
91
+ }
92
+
93
+ /**
94
+ * @param {Partial<PayloadCollectionTemplate>} [data={}]
95
+ * @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
96
+ */
97
+ constructor(data = {}, options = {}) {
98
+ super(data, options)
99
+ /** @type {string} Collection slug */ this.collectionSlug
100
+ /** @type {string} Label/title field name */ this.useAsTitle
101
+ /** @type {Object} Labels object */ this.labels
102
+ /** @type {Object|string} Group object or string */ this.group
103
+ /** @type {Array<Field>} Collection fields */ this.fields
104
+ /** @type {Object} Custom replace snippets */ this.snippets
105
+ }
106
+
107
+ /**
108
+ * Compiles the CollectionConfig template using native CodeTemplate replace blocks.
109
+ * @returns {Promise<string>} Generated TS code for the collection
110
+ */
111
+ async compile() {
112
+ const templateContent = PayloadCollectionTemplate.template.default
113
+ /** @type {Record<string, string>} */
114
+ const input = {
115
+ collectionSlug: `const collectionSlug = '${this.collectionSlug}'`,
116
+ useAsTitle: `const useAsTitle = '${this.useAsTitle}'`,
117
+ labels: `const labels = ${JSON.stringify(this.labels, null, 2)}`,
118
+ group: `const group = ${JSON.stringify(this.group, null, 2)}`,
119
+ fields: `const fields = ${JSON.stringify(this.fields, null, 2)}`,
120
+ .../** @type {Record<string, string>} */ (this.snippets || {}),
121
+ }
122
+
123
+ const app = new CodeTemplate({
124
+ template: templateContent,
125
+ input,
126
+ })
127
+
128
+ const gen = app.run()
129
+ let step = await gen.next()
130
+ while (!step.done) {
131
+ const res = /** @type {import('@nan0web/ui').ResultIntent | undefined} */ (step.value)
132
+ if (res?.data?.output) {
133
+ return res.data.output
134
+ }
135
+ step = await gen.next()
136
+ }
137
+ const finalRes = /** @type {import('@nan0web/ui').ResultIntent | undefined} */ (step.value)
138
+ return finalRes?.data?.output || templateContent
139
+ }
140
+
141
+ /**
142
+ * Synchronously compiles the CollectionConfig template.
143
+ * @returns {string} Generated TS code for the collection
144
+ */
145
+ compileSync() {
146
+ const templateContent = PayloadCollectionTemplate.template.default
147
+ const groupVal = typeof this.group === 'string' ? `'${this.group}'` : JSON.stringify(this.group, null, 2)
148
+ /** @type {Record<string, string>} */
149
+ const input = {
150
+ collectionSlug: `const collectionSlug = '${this.collectionSlug}'`,
151
+ useAsTitle: `const useAsTitle = '${this.useAsTitle}'`,
152
+ labels: `const labels = ${JSON.stringify(this.labels, null, 2)}`,
153
+ group: `const group = ${groupVal}`,
154
+ fields: `const fields = ${JSON.stringify(this.fields, null, 2)}`,
155
+ .../** @type {Record<string, string>} */ (this.snippets || {}),
156
+ }
157
+
158
+ let output = templateContent
159
+ for (const [key, replacement] of Object.entries(input)) {
160
+ const blockRegex = new RegExp(`(\\/\\*\\*\\s*\\n?\\s*\\*\\s*@replace\\s+${key}\\s*\\n?[\\s\\S]*?\\*\\/)([\\s\\S]*?)(\\/\\*\\*\\s*@replace\\s*\\*\\/)`, 'g')
161
+ if (blockRegex.test(output)) {
162
+ output = output.replace(blockRegex, `$1\n${replacement}\n$3`)
163
+ }
164
+ }
165
+ return output
166
+ }
167
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { PayloadCollectionTemplate } from './PayloadCollectionTemplate.js'
4
+
5
+ describe('PayloadCollectionTemplate', () => {
6
+ it('compiles Payload CMS collection code using CodeTemplate', async () => {
7
+ const tpl = new PayloadCollectionTemplate({
8
+ collectionSlug: 'card',
9
+ useAsTitle: 'name',
10
+ labels: { singular: { uk: 'Картка', en: 'Card' }, plural: { uk: 'Картки', en: 'Cards' } },
11
+ group: { uk: 'Продукти', en: 'Products' },
12
+ fields: [
13
+ { name: 'id', type: 'text', required: true },
14
+ { name: 'name', type: 'text', localized: true },
15
+ ],
16
+ })
17
+
18
+ const output = await tpl.compile()
19
+
20
+ assert.ok(output.includes("const collectionSlug = 'card'"))
21
+ assert.ok(output.includes("const useAsTitle = 'name'"))
22
+ assert.ok(output.includes('"uk": "Картка"'))
23
+ assert.ok(output.includes('"name": "id"'))
24
+
25
+ })
26
+ })
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Polymorphic Registry for Payload CMS UI block views.
3
+ */
4
+ export const uiPayloadRegistry = new Map()