@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.
- package/LICENSE +15 -0
- package/package.json +32 -0
- package/releases/3/3/v3.3.0/task.md +64 -0
- package/releases/3/3/v3.3.0/task.spec.js +13 -0
- package/src/access.js +39 -0
- package/src/components/BooleanCell.js +8 -0
- package/src/components/BooleanCell.tsx +50 -0
- package/src/components/ImageCell.js +26 -0
- package/src/components/ImageCell.tsx +54 -0
- package/src/components/MapCell.js +6 -0
- package/src/components/MapCell.tsx +59 -0
- package/src/domain/PayloadApp.js +48 -0
- package/src/domain/SeedModel.js +149 -0
- package/src/index.ts +17 -0
- package/src/richtext/Nan0ComponentNode.js +98 -0
- package/src/richtext/Nan0ElementNode.js +93 -0
- package/src/richtext/Nan0HTMLFeature.js +94 -0
- package/src/richtext/Nan0RawNode.js +92 -0
- package/src/richtext/client/index.js +20 -0
- package/src/richtext/fromNan0Html.js +294 -0
- package/src/richtext/index.js +10 -0
- package/src/richtext/nan0html-converter.spec.js +63 -0
- package/src/richtext/toNan0Html.js +188 -0
- package/src/templates/PayloadCollectionTemplate.js +167 -0
- package/src/templates/PayloadCollectionTemplate.spec.js +26 -0
- package/src/uiPayloadRegistry.js +4 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nan0ComponentNode
|
|
3
|
+
* Represents a NaN0Web component inside the Lexical tree
|
|
4
|
+
* (e.g. `App.Header`, `Card.Details`, `App.Deposits.Calculator`).
|
|
5
|
+
*
|
|
6
|
+
* Serialized shape:
|
|
7
|
+
* ```json
|
|
8
|
+
* {
|
|
9
|
+
* "type": "nan0-component",
|
|
10
|
+
* "version": 1,
|
|
11
|
+
* "component": "Card.Details",
|
|
12
|
+
* "props": { "card": "$" }
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Renders in the editor as a read-only placeholder for now. A future
|
|
17
|
+
* phase adds React components + props forms for editing.
|
|
18
|
+
*/
|
|
19
|
+
import { DecoratorNode, $applyNodeReplacement } from 'lexical'
|
|
20
|
+
|
|
21
|
+
export class Nan0ComponentNode extends DecoratorNode {
|
|
22
|
+
/** @type {string} */ __component
|
|
23
|
+
/** @type {Record<string, any>} */ __props
|
|
24
|
+
|
|
25
|
+
constructor({ component, props = {}, key }) {
|
|
26
|
+
super(key)
|
|
27
|
+
this.__component = component
|
|
28
|
+
this.__props = props
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static clone(node) {
|
|
32
|
+
return new this({ component: node.__component, props: { ...node.__props }, key: node.__key })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static getType() {
|
|
36
|
+
return 'nan0-component'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static importJSON(serializedNode) {
|
|
40
|
+
return $createNan0ComponentNode({
|
|
41
|
+
component: serializedNode.component,
|
|
42
|
+
props: serializedNode.props || {},
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
exportJSON() {
|
|
47
|
+
return {
|
|
48
|
+
...super.exportJSON(),
|
|
49
|
+
type: 'nan0-component',
|
|
50
|
+
version: 1,
|
|
51
|
+
component: this.__component,
|
|
52
|
+
props: this.__props,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getComponent() {
|
|
57
|
+
return this.getLatest().__component
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getProps() {
|
|
61
|
+
return this.getLatest().__props
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isInline() {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
createDOM() {
|
|
69
|
+
const el = document.createElement('div')
|
|
70
|
+
el.setAttribute('data-nan0-component', this.__component)
|
|
71
|
+
el.className = 'nan0-component-placeholder'
|
|
72
|
+
el.style.border = '1px dashed #c8a'
|
|
73
|
+
el.style.padding = '8px'
|
|
74
|
+
el.style.margin = '4px 0'
|
|
75
|
+
el.style.borderRadius = '4px'
|
|
76
|
+
el.style.background = '#fdf6ff'
|
|
77
|
+
el.style.fontSize = '0.85em'
|
|
78
|
+
el.style.color = '#a55'
|
|
79
|
+
el.textContent = `[component: ${this.__component}]`
|
|
80
|
+
return el
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
updateDOM() {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
decorate() {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function $createNan0ComponentNode({ component, props = {} }) {
|
|
93
|
+
return $applyNodeReplacement(new Nan0ComponentNode({ component, props }))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function $isNan0ComponentNode(node) {
|
|
97
|
+
return node instanceof Nan0ComponentNode
|
|
98
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nan0ElementNode
|
|
3
|
+
* Lossless wrapper for NaN0HTML elements that carry `$`-attributes.
|
|
4
|
+
*
|
|
5
|
+
* Serialized shape:
|
|
6
|
+
* ```json
|
|
7
|
+
* { "type": "nan0-element", "version": 1, "tag": "div", "attributes": { "class": "..." }, "children": [...] }
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* This node is used when a native Lexical node cannot represent the attributes
|
|
11
|
+
* without data loss. Clean elements (no attributes) use native nodes directly.
|
|
12
|
+
*/
|
|
13
|
+
import { ElementNode, $applyNodeReplacement } from 'lexical'
|
|
14
|
+
|
|
15
|
+
export class Nan0ElementNode extends ElementNode {
|
|
16
|
+
/** @type {string} */ __tag
|
|
17
|
+
/** @type {Record<string, any>} */ __attributes
|
|
18
|
+
|
|
19
|
+
constructor({ tag, attributes = {}, key }) {
|
|
20
|
+
super(key)
|
|
21
|
+
this.__tag = tag
|
|
22
|
+
this.__attributes = attributes
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static clone(node) {
|
|
26
|
+
return new this({ tag: node.__tag, attributes: { ...node.__attributes }, key: node.__key })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static getType() {
|
|
30
|
+
return 'nan0-element'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static importJSON(serializedNode) {
|
|
34
|
+
const node = $createNan0ElementNode({
|
|
35
|
+
tag: serializedNode.tag,
|
|
36
|
+
attributes: serializedNode.attributes || {},
|
|
37
|
+
})
|
|
38
|
+
return node
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
exportJSON() {
|
|
42
|
+
return {
|
|
43
|
+
...super.exportJSON(),
|
|
44
|
+
type: 'nan0-element',
|
|
45
|
+
version: 1,
|
|
46
|
+
tag: this.__tag,
|
|
47
|
+
attributes: this.__attributes,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getTag() {
|
|
52
|
+
return this.getLatest().__tag
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getAttributes() {
|
|
56
|
+
return this.getLatest().__attributes
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isInline() {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
canBeEmpty() {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
createDOM(config) {
|
|
68
|
+
const element = document.createElement(this.__tag || 'div')
|
|
69
|
+
const attrs = this.__attributes || {}
|
|
70
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
71
|
+
if (key === 'class') {
|
|
72
|
+
element.className = Array.isArray(value) ? value.join(' ') : String(value)
|
|
73
|
+
} else if (key === 'style' && typeof value === 'string') {
|
|
74
|
+
element.setAttribute('style', value)
|
|
75
|
+
} else if (typeof value === 'string' || typeof value === 'number') {
|
|
76
|
+
element.setAttribute(key, String(value))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return element
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
updateDOM() {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function $createNan0ElementNode({ tag, attributes = {} }) {
|
|
88
|
+
return $applyNodeReplacement(new Nan0ElementNode({ tag, attributes }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function $isNan0ElementNode(node) {
|
|
92
|
+
return node instanceof Nan0ElementNode
|
|
93
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nan0HTMLFeature
|
|
3
|
+
* Payload Lexical feature that registers the NaN0 custom nodes
|
|
4
|
+
* (nan0-element, nan0-raw, nan0-component) for server + client.
|
|
5
|
+
*
|
|
6
|
+
* Usage in a Payload collection:
|
|
7
|
+
* ```js
|
|
8
|
+
* editor: lexicalEditor({
|
|
9
|
+
* features: ({ defaultFeatures }) => [
|
|
10
|
+
* ...defaultFeatures,
|
|
11
|
+
* Nan0HTMLFeature(),
|
|
12
|
+
* ],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { createServerFeature } from '@payloadcms/richtext-lexical'
|
|
17
|
+
import { convertLexicalNodesToHTML } from '@payloadcms/richtext-lexical'
|
|
18
|
+
import { createNode } from '@payloadcms/richtext-lexical'
|
|
19
|
+
import { Nan0ElementNode } from './Nan0ElementNode.js'
|
|
20
|
+
import { Nan0RawNode } from './Nan0RawNode.js'
|
|
21
|
+
import { Nan0ComponentNode } from './Nan0ComponentNode.js'
|
|
22
|
+
|
|
23
|
+
export const Nan0HTMLFeature = createServerFeature({
|
|
24
|
+
key: 'nan0html',
|
|
25
|
+
feature: {
|
|
26
|
+
ClientFeature: '@nan0web/ui-payload/richtext/client#Nan0HTMLFeatureClient',
|
|
27
|
+
nodes: [
|
|
28
|
+
createNode({
|
|
29
|
+
node: Nan0ElementNode,
|
|
30
|
+
converters: {
|
|
31
|
+
html: {
|
|
32
|
+
converter: async ({
|
|
33
|
+
converters,
|
|
34
|
+
currentDepth,
|
|
35
|
+
depth,
|
|
36
|
+
draft,
|
|
37
|
+
node,
|
|
38
|
+
overrideAccess,
|
|
39
|
+
parent,
|
|
40
|
+
req,
|
|
41
|
+
showHiddenFields,
|
|
42
|
+
}) => {
|
|
43
|
+
const childrenText = await convertLexicalNodesToHTML({
|
|
44
|
+
converters,
|
|
45
|
+
currentDepth,
|
|
46
|
+
depth,
|
|
47
|
+
draft,
|
|
48
|
+
lexicalNodes: node.children,
|
|
49
|
+
overrideAccess,
|
|
50
|
+
parent: { ...node, parent },
|
|
51
|
+
req,
|
|
52
|
+
showHiddenFields,
|
|
53
|
+
})
|
|
54
|
+
const attrs = node.getAttributes() || {}
|
|
55
|
+
const attrString = Object.entries(attrs)
|
|
56
|
+
.filter(([k]) => k !== 'class' && k !== 'style')
|
|
57
|
+
.map(([k, v]) => `${k}="${String(v).replaceAll('"', '"')}"`)
|
|
58
|
+
.join(' ')
|
|
59
|
+
const classAttr = attrs.class ? ` class="${Array.isArray(attrs.class) ? attrs.class.join(' ') : attrs.class}"` : ''
|
|
60
|
+
const styleAttr = attrs.style ? ` style="${String(attrs.style).replaceAll('"', '"')}"` : ''
|
|
61
|
+
return `<${node.getTag()}${classAttr}${styleAttr}${attrString ? ' ' + attrString : ''}>${childrenText}</${node.getTag()}>`
|
|
62
|
+
},
|
|
63
|
+
nodeTypes: [Nan0ElementNode.getType()],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
createNode({
|
|
68
|
+
node: Nan0RawNode,
|
|
69
|
+
converters: {
|
|
70
|
+
html: {
|
|
71
|
+
converter: ({ node }) => {
|
|
72
|
+
const source = node.getSource() || {}
|
|
73
|
+
return `<!-- nan0-raw:${source.tag || 'unknown'} -->`
|
|
74
|
+
},
|
|
75
|
+
nodeTypes: [Nan0RawNode.getType()],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
createNode({
|
|
80
|
+
node: Nan0ComponentNode,
|
|
81
|
+
converters: {
|
|
82
|
+
html: {
|
|
83
|
+
converter: ({ node }) => {
|
|
84
|
+
return `<!-- nan0-component:${node.getComponent()} -->`
|
|
85
|
+
},
|
|
86
|
+
nodeTypes: [Nan0ComponentNode.getType()],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export default Nan0HTMLFeature
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nan0RawNode
|
|
3
|
+
* Fallback for unknown NaN0HTML tags and structures that do not yet have
|
|
4
|
+
* a native or nan0-element adapter.
|
|
5
|
+
*
|
|
6
|
+
* Serialized shape:
|
|
7
|
+
* ```json
|
|
8
|
+
* {
|
|
9
|
+
* "type": "nan0-raw",
|
|
10
|
+
* "version": 1,
|
|
11
|
+
* "source": {
|
|
12
|
+
* "tag": "legacy-widget",
|
|
13
|
+
* "attributes": { "data-mode": "legacy" },
|
|
14
|
+
* "children": []
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* In the editor, this renders as a read-only block showing the tag name
|
|
20
|
+
* so the user knows something is stored but not yet editable natively.
|
|
21
|
+
* A future migration can replace nan0-raw with the appropriate node.
|
|
22
|
+
*/
|
|
23
|
+
import { ElementNode, $applyNodeReplacement } from 'lexical'
|
|
24
|
+
|
|
25
|
+
export class Nan0RawNode extends ElementNode {
|
|
26
|
+
/** @type {{ tag: string, attributes: Record<string, any>, children: any[] }} */ __source
|
|
27
|
+
|
|
28
|
+
constructor({ source, key }) {
|
|
29
|
+
super(key)
|
|
30
|
+
this.__source = source
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static clone(node) {
|
|
34
|
+
return new this({ source: { ...node.__source }, key: node.__key })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static getType() {
|
|
38
|
+
return 'nan0-raw'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static importJSON(serializedNode) {
|
|
42
|
+
return $createNan0RawNode({ source: serializedNode.source || { tag: 'unknown', attributes: {}, children: [] } })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
exportJSON() {
|
|
46
|
+
return {
|
|
47
|
+
...super.exportJSON(),
|
|
48
|
+
type: 'nan0-raw',
|
|
49
|
+
version: 1,
|
|
50
|
+
source: this.__source,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getSource() {
|
|
55
|
+
return this.getLatest().__source
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isInline() {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
canBeEmpty() {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
createDOM(config) {
|
|
67
|
+
const el = document.createElement('div')
|
|
68
|
+
el.setAttribute('data-nan0-raw', this.__source.tag)
|
|
69
|
+
el.className = 'nan0-raw-fallback'
|
|
70
|
+
el.style.border = '1px dashed #ccc'
|
|
71
|
+
el.style.padding = '8px'
|
|
72
|
+
el.style.margin = '4px 0'
|
|
73
|
+
el.style.borderRadius = '4px'
|
|
74
|
+
el.style.background = '#fafafa'
|
|
75
|
+
el.style.fontSize = '0.85em'
|
|
76
|
+
el.style.color = '#888'
|
|
77
|
+
el.textContent = `[nan0-raw: ${this.__source.tag}]`
|
|
78
|
+
return el
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
updateDOM() {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function $createNan0RawNode({ source }) {
|
|
87
|
+
return $applyNodeReplacement(new Nan0RawNode({ source }))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function $isNan0RawNode(node) {
|
|
91
|
+
return node instanceof Nan0RawNode
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nan0HTMLFeatureClient
|
|
3
|
+
* Client-side registration of NaN0 custom nodes for the Lexical editor.
|
|
4
|
+
* Imported via the Payload import map from `@nan0web/ui-payload/richtext/client`.
|
|
5
|
+
*/
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { Nan0ElementNode } from '../Nan0ElementNode.js'
|
|
9
|
+
import { Nan0RawNode } from '../Nan0RawNode.js'
|
|
10
|
+
import { Nan0ComponentNode } from '../Nan0ComponentNode.js'
|
|
11
|
+
|
|
12
|
+
export const Nan0HTMLFeatureClient = (props = {}) => ({
|
|
13
|
+
clientFeatureProps: props,
|
|
14
|
+
feature: () => ({
|
|
15
|
+
nodes: [Nan0ElementNode, Nan0RawNode, Nan0ComponentNode],
|
|
16
|
+
sanitizedClientFeatureProps: props,
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default Nan0HTMLFeatureClient
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fromNan0Html
|
|
3
|
+
* Converts a NaN0HTML AST (page.content / content) into a Payload Lexical state JSON.
|
|
4
|
+
*
|
|
5
|
+
* NaN0HTML AST shape:
|
|
6
|
+
* - array → sequence of blocks
|
|
7
|
+
* - string → text content
|
|
8
|
+
* - object → element with keys: `$attr` for attributes, lowercase tag keys for
|
|
9
|
+
* child elements, `Uppercase.With.Dot` keys for NaN0 components
|
|
10
|
+
* - `tag: true` → void element (e.g. `br`, `hr`)
|
|
11
|
+
*
|
|
12
|
+
* Strategy:
|
|
13
|
+
* - Clean elements (no attributes) map to native Lexical nodes where possible.
|
|
14
|
+
* - Elements carrying any `$`-attribute are preserved losslessly as a `nan0-element`
|
|
15
|
+
* node (tag + attributes + children) so round-trip back to NaN0HTML is lossless.
|
|
16
|
+
* - Unknown tags fall back to a `nan0-raw` node and are recorded in the inventory.
|
|
17
|
+
* - NaN0 components (`App.Header`, `Card.Details`, ...) become `nan0-component` nodes.
|
|
18
|
+
*
|
|
19
|
+
* The converter returns a plain structure; localization is handled at the Payload
|
|
20
|
+
* collection level, not here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Lexical text format bitmask
|
|
24
|
+
export const FORMAT = Object.freeze({
|
|
25
|
+
bold: 1 << 0,
|
|
26
|
+
italic: 1 << 1,
|
|
27
|
+
underline: 1 << 2,
|
|
28
|
+
strikethrough: 1 << 3,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Native block-level tags we can represent without a custom node.
|
|
32
|
+
const BLOCK_TAGS = new Set([
|
|
33
|
+
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
34
|
+
'ul', 'ol', 'li',
|
|
35
|
+
'blockquote', 'hr', 'br',
|
|
36
|
+
'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
// Tags that map to native Lexical nodes.
|
|
40
|
+
const NATIVE_TAG_MAP = {
|
|
41
|
+
p: 'paragraph',
|
|
42
|
+
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
|
|
43
|
+
blockquote: 'blockquote',
|
|
44
|
+
hr: 'horizontalrule',
|
|
45
|
+
br: 'linebreak',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Inline formatting tags → format bit added to descendant text nodes.
|
|
49
|
+
const INLINE_FORMAT = {
|
|
50
|
+
strong: FORMAT.bold,
|
|
51
|
+
b: FORMAT.bold,
|
|
52
|
+
em: FORMAT.italic,
|
|
53
|
+
i: FORMAT.italic,
|
|
54
|
+
u: FORMAT.underline,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively walk the NaN0HTML AST and collect unknown constructs.
|
|
59
|
+
* @param {any} node
|
|
60
|
+
* @param {Array<{tag: string, attributes: Object, parent: string}>} out
|
|
61
|
+
* @param {string} parent
|
|
62
|
+
*/
|
|
63
|
+
export function inventoryNan0Html(node, out = [], parent = 'root') {
|
|
64
|
+
if (node == null) return out
|
|
65
|
+
if (Array.isArray(node)) {
|
|
66
|
+
for (const child of node) inventoryNan0Html(child, out, parent)
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
if (typeof node !== 'object') return out
|
|
70
|
+
for (const [key, value] of Object.entries(node)) {
|
|
71
|
+
if (key.startsWith('$')) continue
|
|
72
|
+
if (/^[a-z][a-z0-9]*$/.test(key)) {
|
|
73
|
+
if (!BLOCK_TAGS.has(key) && key !== 'a' && key !== 'img' && key !== 'span' && key !== 'div' && key !== 'wbr' && key !== 'small' && key !== 'sup') {
|
|
74
|
+
out.push({ tag: key, parent, attributes: collectAttrs(value) })
|
|
75
|
+
}
|
|
76
|
+
inventoryNan0Html(value, out, key)
|
|
77
|
+
} else if (/^[A-Z]/.test(key) && key.includes('.')) {
|
|
78
|
+
out.push({ component: key, parent })
|
|
79
|
+
inventoryNan0Html(value, out, key)
|
|
80
|
+
} else {
|
|
81
|
+
inventoryNan0Html(value, out, parent)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectAttrs(value) {
|
|
88
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
89
|
+
const attrs = {}
|
|
90
|
+
for (const [k, v] of Object.entries(value)) {
|
|
91
|
+
if (k.startsWith('$')) attrs[k.slice(1)] = v
|
|
92
|
+
}
|
|
93
|
+
return attrs
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Convert a NaN0HTML AST into a Payload Lexical root state.
|
|
98
|
+
* @param {any} content NaN0HTML AST (array of blocks, or a single block)
|
|
99
|
+
* @returns {{ root: { type: string, version: number, children: any[] } }}
|
|
100
|
+
*/
|
|
101
|
+
export function fromNan0Html(content) {
|
|
102
|
+
const children = convertBlock(content, 0)
|
|
103
|
+
return {
|
|
104
|
+
root: {
|
|
105
|
+
type: 'root',
|
|
106
|
+
version: 1,
|
|
107
|
+
children,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert a block (array of elements, or a single element) into Lexical children.
|
|
114
|
+
*/
|
|
115
|
+
function convertBlock(node, format) {
|
|
116
|
+
if (node == null) return []
|
|
117
|
+
if (Array.isArray(node)) {
|
|
118
|
+
const out = []
|
|
119
|
+
for (const child of node) {
|
|
120
|
+
out.push(...convertBlock(child, format))
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
if (typeof node === 'string') {
|
|
125
|
+
return [textNode(node, format)]
|
|
126
|
+
}
|
|
127
|
+
if (typeof node !== 'object') return []
|
|
128
|
+
// Object: split `$`-attributes from element keys.
|
|
129
|
+
const attrs = {}
|
|
130
|
+
const elements = []
|
|
131
|
+
for (const [key, value] of Object.entries(node)) {
|
|
132
|
+
if (key.startsWith('$')) {
|
|
133
|
+
attrs[key.slice(1)] = value
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
elements.push([key, value])
|
|
137
|
+
}
|
|
138
|
+
const out = []
|
|
139
|
+
for (const [tag, value] of elements) {
|
|
140
|
+
out.push(...convertElement(tag, value, attrs, format))
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Convert a single tag element into Lexical nodes.
|
|
147
|
+
* `attrs` are the attributes collected at the same object level.
|
|
148
|
+
*/
|
|
149
|
+
function convertElement(tag, value, attrs, format) {
|
|
150
|
+
// NaN0 component
|
|
151
|
+
if (/^[A-Z]/.test(tag) && tag.includes('.')) {
|
|
152
|
+
const props = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
153
|
+
return [componentNode(tag, props)]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Inline formatting tags alter the text format of descendants.
|
|
157
|
+
if (INLINE_FORMAT[tag] != null) {
|
|
158
|
+
const childFormat = format | INLINE_FORMAT[tag]
|
|
159
|
+
return convertBlock(value, childFormat)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let children = []
|
|
163
|
+
if (value === true) {
|
|
164
|
+
// void element
|
|
165
|
+
children = []
|
|
166
|
+
} else if (Array.isArray(value) || typeof value === 'string') {
|
|
167
|
+
children = convertBlock(value, format)
|
|
168
|
+
} else if (value && typeof value === 'object') {
|
|
169
|
+
children = convertBlock(value, format)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hasAttrs = Object.keys(attrs).length > 0
|
|
173
|
+
|
|
174
|
+
switch (tag) {
|
|
175
|
+
case 'p':
|
|
176
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
177
|
+
return [blockNode('paragraph', children)]
|
|
178
|
+
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
|
|
179
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
180
|
+
return [blockNode('heading', children, { tag })]
|
|
181
|
+
case 'blockquote':
|
|
182
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
183
|
+
return [blockNode('blockquote', children)]
|
|
184
|
+
case 'hr':
|
|
185
|
+
if (hasAttrs) return [elementNode(tag, attrs, [])]
|
|
186
|
+
return [{ type: 'horizontalrule', version: 1 }]
|
|
187
|
+
case 'br':
|
|
188
|
+
if (hasAttrs) return [elementNode(tag, attrs, [])]
|
|
189
|
+
return [{ type: 'linebreak', version: 1 }]
|
|
190
|
+
case 'ul': case 'ol':
|
|
191
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
192
|
+
return [listNode(tag, value, children)]
|
|
193
|
+
case 'li':
|
|
194
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
195
|
+
return [{ type: 'listitem', version: 1, value: 1, children }]
|
|
196
|
+
case 'a': {
|
|
197
|
+
const url = attrs.href || '#'
|
|
198
|
+
const linkAttrs = { url, linkType: 'custom' }
|
|
199
|
+
if (attrs.target) linkAttrs.newTab = String(attrs.target) === '_blank'
|
|
200
|
+
if (attrs.rel) linkAttrs.rel = attrs.rel
|
|
201
|
+
const node = { type: 'link', version: 1, fields: linkAttrs, children }
|
|
202
|
+
// Preserve extra attributes losslessly.
|
|
203
|
+
return [wrapElementIfNeeded(tag, attrs, node)]
|
|
204
|
+
}
|
|
205
|
+
case 'img': {
|
|
206
|
+
const src = attrs.src || ''
|
|
207
|
+
const node = {
|
|
208
|
+
type: 'upload',
|
|
209
|
+
version: 1,
|
|
210
|
+
fields: { relationTo: 'media', value: src },
|
|
211
|
+
children: [],
|
|
212
|
+
}
|
|
213
|
+
return [wrapElementIfNeeded(tag, attrs, node)]
|
|
214
|
+
}
|
|
215
|
+
case 'table': case 'thead': case 'tbody':
|
|
216
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
217
|
+
return [{ type: 'table', version: 1, children }]
|
|
218
|
+
case 'tr':
|
|
219
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
220
|
+
return [{ type: 'tablerow', version: 1, children }]
|
|
221
|
+
case 'td': case 'th': {
|
|
222
|
+
const cell = { type: 'tablecell', version: 1, header: tag === 'th', children }
|
|
223
|
+
return [wrapElementIfNeeded(tag, attrs, cell)]
|
|
224
|
+
}
|
|
225
|
+
case 'span': case 'div': case 'section': case 'header': case 'nav':
|
|
226
|
+
case 'figure': case 'caption': case 'small': case 'sup': case 'wbr':
|
|
227
|
+
case 'picture': case 'source':
|
|
228
|
+
// These are either inline or block wrappers; preserve attributes if any.
|
|
229
|
+
if (hasAttrs) return [elementNode(tag, attrs, children)]
|
|
230
|
+
return [blockNode('paragraph', children)]
|
|
231
|
+
default:
|
|
232
|
+
// Unknown tag → preserve losslessly as nan0-raw.
|
|
233
|
+
return [rawNode(tag, attrs, children)]
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* If the element carries attributes beyond those consumed by the native node,
|
|
239
|
+
* wrap it in a lossless `nan0-element` node so nothing is dropped.
|
|
240
|
+
*/
|
|
241
|
+
function wrapElementIfNeeded(tag, attrs, node) {
|
|
242
|
+
if (!attrs) return [node]
|
|
243
|
+
return [elementNode(tag, attrs, node.children || [])]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function textNode(text, format) {
|
|
247
|
+
return { type: 'text', text, format }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function blockNode(type, children, extra = {}) {
|
|
251
|
+
return { type, version: 1, ...extra, children }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function listNode(tag, value, children) {
|
|
255
|
+
const listType = tag === 'ol' ? 'number' : 'bullet'
|
|
256
|
+
let start = 1
|
|
257
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.$start != null) {
|
|
258
|
+
start = Number(value.$start) || 1
|
|
259
|
+
}
|
|
260
|
+
return { type: 'list', version: 1, listType, start, tag, children }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function elementNode(tag, attrs, children) {
|
|
264
|
+
return {
|
|
265
|
+
type: 'nan0-element',
|
|
266
|
+
version: 1,
|
|
267
|
+
tag,
|
|
268
|
+
attributes: attrs,
|
|
269
|
+
children,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function componentNode(component, props) {
|
|
274
|
+
return {
|
|
275
|
+
type: 'nan0-component',
|
|
276
|
+
version: 1,
|
|
277
|
+
component,
|
|
278
|
+
props,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function rawNode(tag, attrs, children) {
|
|
283
|
+
return {
|
|
284
|
+
type: 'nan0-raw',
|
|
285
|
+
version: 1,
|
|
286
|
+
source: {
|
|
287
|
+
tag,
|
|
288
|
+
attributes: attrs,
|
|
289
|
+
children,
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default fromNan0Html
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* richtext package index
|
|
3
|
+
* NaN0HTML → Payload Lexical conversion toolkit.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions (fromNan0Html, inventoryNan0Html) are safe to import
|
|
6
|
+
* from any context. Custom node classes (Nan0ElementNode, etc.) require
|
|
7
|
+
* `lexical` to be resolvable and are available via direct import paths.
|
|
8
|
+
*/
|
|
9
|
+
export { default as fromNan0Html, inventoryNan0Html, FORMAT } from './fromNan0Html.js'
|
|
10
|
+
export { default as toNan0Html } from './toNan0Html.js'
|