@developer_tribe/react-builder 1.2.32 → 1.2.33

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.
Files changed (73) hide show
  1. package/dist/assets/prompt-scheme-onboard.generated.d.ts +1 -0
  2. package/dist/assets/prompt-scheme-paywall.generated.d.ts +1 -0
  3. package/dist/components/BuilderProvider.d.ts +2 -4
  4. package/dist/index.cjs.js +1 -1
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/index.web.cjs.js +6 -6
  9. package/dist/index.web.cjs.js.map +1 -1
  10. package/dist/index.web.esm.js +6 -6
  11. package/dist/index.web.esm.js.map +1 -1
  12. package/dist/modals/PromptManagerModal.d.ts +9 -0
  13. package/dist/modals/index.d.ts +1 -0
  14. package/dist/styles.css +1 -1
  15. package/dist/utils/nodeXml.d.ts +11 -0
  16. package/package.json +5 -1
  17. package/scripts/prebuild/assets/prompt_scheme.md +44 -0
  18. package/scripts/prebuild/generate-prompt-schemes.js +328 -0
  19. package/scripts/prebuild/prebuild.js +4 -0
  20. package/src/RenderPage.tsx +6 -6
  21. package/src/assets/meta.json +1 -1
  22. package/src/assets/prompt-scheme-onboard.generated.ts +4 -0
  23. package/src/assets/prompt-scheme-paywall.generated.ts +4 -0
  24. package/src/attribute-analyser/style/native/useExtractImageStyle.ts +1 -1
  25. package/src/attribute-analyser/style/native/useExtractTextStyle.ts +1 -1
  26. package/src/attribute-analyser/style/native/useExtractViewStyle.ts +1 -1
  27. package/src/attribute-analyser/style/web/useExtractImageStyle.ts +1 -1
  28. package/src/attribute-analyser/style/web/useExtractTextStyle.ts +1 -1
  29. package/src/attribute-analyser/style/web/useExtractViewStyle.ts +1 -1
  30. package/src/build-components/BIcon/pattern.json +1 -3
  31. package/src/build-components/BackgroundImage/pattern.json +2 -10
  32. package/src/build-components/Button/pattern.json +1 -3
  33. package/src/build-components/Carousel/pattern.json +2 -8
  34. package/src/build-components/CarouselDots/CarouselDots.tsx +1 -1
  35. package/src/build-components/CarouselProvider/pattern.json +1 -4
  36. package/src/build-components/CountDown/pattern.json +1 -3
  37. package/src/build-components/Image/pattern.json +2 -9
  38. package/src/build-components/Main/pattern.json +1 -3
  39. package/src/build-components/NavigationBarColor/NavigationBarColor.tsx +1 -1
  40. package/src/build-components/NavigationBarColor/pattern.json +1 -3
  41. package/src/build-components/Onboard/pattern.json +2 -6
  42. package/src/build-components/OnboardButton/OnboardButton.tsx +1 -1
  43. package/src/build-components/OnboardButton/pattern.json +3 -14
  44. package/src/build-components/OnboardButtons/pattern.json +4 -15
  45. package/src/build-components/OnboardDot/OnboardDot.tsx +1 -1
  46. package/src/build-components/OnboardDot/pattern.json +1 -3
  47. package/src/build-components/OnboardFooter/OnboardFooter.tsx +3 -3
  48. package/src/build-components/OnboardFooter/pattern.json +1 -3
  49. package/src/build-components/OnboardItem/pattern.json +3 -11
  50. package/src/build-components/OnboardProvider/pattern.json +2 -8
  51. package/src/build-components/OnboardSubtitle/pattern.json +1 -4
  52. package/src/build-components/OnboardTitle/pattern.json +1 -4
  53. package/src/build-components/PaywallBackground/pattern.json +1 -3
  54. package/src/build-components/PaywallCloseButton/pattern.json +1 -3
  55. package/src/build-components/PaywallOptions/pattern.json +1 -3
  56. package/src/build-components/PaywallProvider/pattern.json +1 -3
  57. package/src/build-components/PaywallSubscribeButton/pattern.json +1 -3
  58. package/src/build-components/PriceTag/pattern.json +2 -8
  59. package/src/build-components/Pricing/pattern.json +1 -3
  60. package/src/build-components/Promo/pattern.json +1 -3
  61. package/src/build-components/Separator/pattern.json +1 -3
  62. package/src/build-components/StatusBarColor/StatusBarColor.tsx +1 -1
  63. package/src/build-components/StatusBarColor/pattern.json +1 -3
  64. package/src/build-components/Text/pattern.json +1 -3
  65. package/src/build-components/View/pattern.json +4 -16
  66. package/src/components/BottomBar.tsx +28 -1
  67. package/src/components/BuilderProvider.tsx +5 -14
  68. package/src/hooks/useLocalize.ts +1 -1
  69. package/src/modals/PromptManagerModal.tsx +270 -0
  70. package/src/modals/index.ts +1 -0
  71. package/src/styles/index.scss +1 -0
  72. package/src/styles/modals/_prompt-manager-modal.scss +95 -0
  73. package/src/utils/nodeXml.ts +196 -0
@@ -3,7 +3,12 @@ import { Icon } from './Icon.generated';
3
3
  import type { IconsType } from '../types/Icons.generated';
4
4
  import { useRenderStore } from '../store';
5
5
  import type { Localication } from '../types/PreviewConfig';
6
- import { InspectModal, LocalicationModal, Modal } from '../modals';
6
+ import {
7
+ InspectModal,
8
+ LocalicationModal,
9
+ Modal,
10
+ PromptManagerModal,
11
+ } from '../modals';
7
12
  import type { Node } from '../types/Node';
8
13
  import type { Project } from '../types/Project';
9
14
  import { DebugJsonPage } from '../pages/DebugJsonPage';
@@ -29,6 +34,7 @@ export function BottomBar({
29
34
  const debugIcon: IconsType = 'speedometer-03';
30
35
  const localizationIcon: IconsType = 'globe-01';
31
36
  const inspectIcon: IconsType = 'info-circle';
37
+ const promptAiIcon: IconsType = 'star';
32
38
 
33
39
  const {
34
40
  localization,
@@ -57,6 +63,7 @@ export function BottomBar({
57
63
  const [isDebugOpen, setIsDebugOpen] = useState(false);
58
64
  const [isLocalizationOpen, setIsLocalizationOpen] = useState(false);
59
65
  const [isInspectOpen, setIsInspectOpen] = useState(false);
66
+ const [isPromptManagerOpen, setIsPromptManagerOpen] = useState(false);
60
67
 
61
68
  const languages = useMemo(() => ['en', 'tr', 'ar'], []);
62
69
  const activeLanguage = defaultLanguage;
@@ -150,6 +157,18 @@ export function BottomBar({
150
157
  <Icon iconType={inspectIcon} size={20} color="currentColor" alt="" />
151
158
  </button>
152
159
 
160
+ <button
161
+ type="button"
162
+ className={`rb-bottom-bar__button${
163
+ isPromptManagerOpen ? ' is-active' : ''
164
+ }`}
165
+ aria-label="Prompt Manager"
166
+ aria-pressed={isPromptManagerOpen}
167
+ onClick={() => setIsPromptManagerOpen(true)}
168
+ >
169
+ <Icon iconType={promptAiIcon} size={20} color="currentColor" alt="" />
170
+ </button>
171
+
153
172
  <div className="rb-bottom-bar__spacer" />
154
173
 
155
174
  <div className="rb-bottom-bar__langs" aria-label="Language">
@@ -199,6 +218,14 @@ export function BottomBar({
199
218
  {isInspectOpen && (
200
219
  <InspectModal onClose={() => setIsInspectOpen(false)} />
201
220
  )}
221
+
222
+ {isPromptManagerOpen && (
223
+ <PromptManagerModal
224
+ data={data}
225
+ setData={setData}
226
+ onClose={() => setIsPromptManagerOpen(false)}
227
+ />
228
+ )}
202
229
  </>
203
230
  );
204
231
  }
@@ -36,19 +36,14 @@ export type BuilderProviderParams = {
36
36
  // but internally we prefer store values.
37
37
  mockProducts?: Products[];
38
38
  mockBenefits?: PaywallBenefits;
39
- mockTheme?: Theme;
40
- mockDefaultLanguage?: string;
39
+ selectedLanguage?: string;
40
+ selectedTheme?: Theme;
41
41
  localization?: Localication;
42
42
  baseSize?: BaseSize;
43
- // projectColors, fonts, previewMode, selectedKey kept as they are commonly used and not explicitly asked to rename
44
43
  projectColors?: ProjectColors;
45
44
  fonts?: Fonts;
46
45
  previewMode?: boolean;
47
46
  selectedKey?: string;
48
-
49
- // Derived / Convenience values provided by the context (populated from mock* if present)
50
- theme?: Theme;
51
- defaultLanguage?: string;
52
47
  };
53
48
 
54
49
  type BuilderProviderProps = {
@@ -63,17 +58,13 @@ const builderContext = createContext<BuilderProviderParams | undefined>(
63
58
  export function BuilderProvider({ params, children }: BuilderProviderProps) {
64
59
  // We rely purely on params passed from parent (which reads from store)
65
60
  const value = useMemo<BuilderProviderParams>(() => {
66
- const theme = params.mockTheme ?? defaultTheme;
67
- const lang = params.mockDefaultLanguage ?? defaultLanguage;
61
+ const lang = params.selectedLanguage ?? defaultLanguage;
68
62
  return {
69
63
  ...params,
70
- // Ensure defaults if mxissing
71
64
  mockProducts: params.mockProducts ?? [],
72
65
  mockBenefits: params.mockBenefits ?? {},
73
- mockTheme: theme,
74
- mockDefaultLanguage: lang,
75
- theme,
76
- defaultLanguage: lang,
66
+ selectedLanguage: lang,
67
+ selectedTheme: params.selectedTheme ?? defaultTheme,
77
68
  baseSize: params.baseSize ?? defaultBaseSize,
78
69
  projectColors: params.projectColors
79
70
  ? mergeProjectColors(defaultProjectColors, params.projectColors)
@@ -12,7 +12,7 @@ export function useLocalize(options?: {
12
12
  }): LocalizeFn {
13
13
  const {
14
14
  localization: builderLocalization,
15
- mockDefaultLanguage: builderDefaultLanguage,
15
+ selectedLanguage: builderDefaultLanguage,
16
16
  } = useBuilderParams();
17
17
  //TODO: genelle (react-native ksımına export et)
18
18
  const localization =
@@ -0,0 +1,270 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import Modal from './Modal';
3
+ import { useRenderStore } from '../store';
4
+ import type { Node } from '../types/Node';
5
+ import { nodeToXml, xmlToNode } from '../utils/nodeXml';
6
+ import { analyseAndProccess } from '../utils/analyseNode';
7
+ import { onboardPromptScheme } from '../assets/prompt-scheme-onboard.generated';
8
+ import { paywallPromptScheme } from '../assets/prompt-scheme-paywall.generated';
9
+
10
+ type PromptManagerTab = 'xml-editor' | 'prompt-schema';
11
+
12
+ type PromptManagerModalProps = {
13
+ data: Node;
14
+ setData: React.Dispatch<React.SetStateAction<Node>>;
15
+ onClose: () => void;
16
+ };
17
+
18
+ export function PromptManagerModal({
19
+ data,
20
+ setData,
21
+ onClose,
22
+ }: PromptManagerModalProps) {
23
+ const [activeTab, setActiveTab] = useState<PromptManagerTab>('xml-editor');
24
+
25
+ return (
26
+ <Modal
27
+ onClose={onClose}
28
+ ariaLabelledBy="prompt-manager-modal-title"
29
+ className="modal--large modal--scrollable"
30
+ contentClassName="prompt-manager-modal__content"
31
+ >
32
+ <div className="modal__header prompt-manager-modal__header">
33
+ <h3 id="prompt-manager-modal-title" className="modal__title">
34
+ Prompt Manager
35
+ </h3>
36
+ <button type="button" className="editor-button" onClick={onClose}>
37
+ Close
38
+ </button>
39
+ </div>
40
+
41
+ <div className="prompt-manager-modal__tabs">
42
+ <button
43
+ type="button"
44
+ className={`prompt-manager-modal__tab${
45
+ activeTab === 'xml-editor' ? ' is-active' : ''
46
+ }`}
47
+ onClick={() => setActiveTab('xml-editor')}
48
+ >
49
+ XML Editor
50
+ </button>
51
+ <button
52
+ type="button"
53
+ className={`prompt-manager-modal__tab${
54
+ activeTab === 'prompt-schema' ? ' is-active' : ''
55
+ }`}
56
+ onClick={() => setActiveTab('prompt-schema')}
57
+ >
58
+ Prompt Şeması
59
+ </button>
60
+ </div>
61
+
62
+ <div className="prompt-manager-modal__body">
63
+ {activeTab === 'xml-editor' && (
64
+ <XmlEditorTab data={data} setData={setData} />
65
+ )}
66
+ {activeTab === 'prompt-schema' && <PromptSchemaTab />}
67
+ </div>
68
+ </Modal>
69
+ );
70
+ }
71
+
72
+ /* ------------------------------------------------------------------ */
73
+ /* Tab 1 – XML Editor */
74
+ /* ------------------------------------------------------------------ */
75
+
76
+ function XmlEditorTab({
77
+ data,
78
+ setData,
79
+ }: {
80
+ data: Node;
81
+ setData: React.Dispatch<React.SetStateAction<Node>>;
82
+ }) {
83
+ const setCurrent = useRenderStore((s) => s.setCurrent);
84
+
85
+ const initialXml = useMemo(() => {
86
+ try {
87
+ return nodeToXml(data);
88
+ } catch {
89
+ return '';
90
+ }
91
+ }, [data]);
92
+
93
+ const [text, setText] = useState(initialXml);
94
+ const [parseError, setParseError] = useState<string | null>(null);
95
+ const [applyError, setApplyError] = useState<string | null>(null);
96
+ const [applySuccess, setApplySuccess] = useState(false);
97
+
98
+ useEffect(() => {
99
+ setText(initialXml);
100
+ setParseError(null);
101
+ setApplyError(null);
102
+ setApplySuccess(false);
103
+ }, [initialXml]);
104
+
105
+ const handleChange = useCallback((value: string) => {
106
+ setText(value);
107
+ setApplyError(null);
108
+ setApplySuccess(false);
109
+ }, []);
110
+
111
+ const handleApply = () => {
112
+ try {
113
+ const parsed = xmlToNode(text);
114
+ setParseError(null);
115
+ setApplyError(null);
116
+ setApplySuccess(false);
117
+ try {
118
+ const processed = analyseAndProccess(parsed as Node) as Node;
119
+ setData(processed);
120
+ // Keep selection in sync with the new root.
121
+ setCurrent(processed);
122
+ setApplySuccess(true);
123
+ } catch (e) {
124
+ setApplyError(e instanceof Error ? e.message : 'Failed to apply XML');
125
+ }
126
+ } catch (e) {
127
+ setParseError(e instanceof Error ? e.message : 'Invalid XML');
128
+ }
129
+ };
130
+
131
+ const handleCopy = async () => {
132
+ try {
133
+ await navigator.clipboard.writeText(text);
134
+ } catch {
135
+ // ignore
136
+ }
137
+ };
138
+
139
+ return (
140
+ <div className="prompt-manager-modal__editor-wrap">
141
+ <div className="prompt-manager-modal__toolbar">
142
+ <div style={{ fontSize: 12, opacity: 0.75 }}>node.xml</div>
143
+ <div style={{ display: 'flex', gap: 8 }}>
144
+ <button type="button" className="editor-button" onClick={handleCopy}>
145
+ Copy
146
+ </button>
147
+ <button type="button" className="editor-button" onClick={handleApply}>
148
+ Apply
149
+ </button>
150
+ </div>
151
+ </div>
152
+
153
+ <textarea
154
+ className="prompt-manager-modal__editor"
155
+ value={text}
156
+ onChange={(e) => {
157
+ setText(e.target.value);
158
+ setApplyError(null);
159
+ setApplySuccess(false);
160
+ }}
161
+ spellCheck={false}
162
+ />
163
+
164
+ {parseError ? (
165
+ <div style={{ fontSize: 12, color: '#d12f2f' }}>
166
+ Invalid XML: {parseError}
167
+ </div>
168
+ ) : applyError ? (
169
+ <div style={{ fontSize: 12, color: '#d12f2f' }}>
170
+ Could not apply: {applyError}
171
+ </div>
172
+ ) : applySuccess ? (
173
+ <div style={{ fontSize: 12, color: '#2e7d32' }}>
174
+ ✓ Applied successfully
175
+ </div>
176
+ ) : (
177
+ <div style={{ fontSize: 12, opacity: 0.7 }}>
178
+ Edit XML and press Apply to update the node tree.
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ /* ------------------------------------------------------------------ */
186
+ /* Tab 2 – Prompt Şeması */
187
+ /* ------------------------------------------------------------------ */
188
+
189
+ type PromptSchemaSubTab = 'onboard' | 'paywall';
190
+
191
+ function PromptSchemaTab() {
192
+ const [subTab, setSubTab] = useState<PromptSchemaSubTab>('onboard');
193
+ const [copied, setCopied] = useState(false);
194
+
195
+ const activeScheme =
196
+ subTab === 'onboard' ? onboardPromptScheme : paywallPromptScheme;
197
+
198
+ const handleCopy = async () => {
199
+ try {
200
+ await navigator.clipboard.writeText(activeScheme);
201
+ setCopied(true);
202
+ setTimeout(() => setCopied(false), 1500);
203
+ } catch {
204
+ // ignore
205
+ }
206
+ };
207
+
208
+ return (
209
+ <div className="prompt-manager-modal__editor-wrap">
210
+ {/* Sub-tab selector + copy */}
211
+ <div className="prompt-manager-modal__toolbar">
212
+ <div style={{ display: 'flex', gap: 4 }}>
213
+ <button
214
+ type="button"
215
+ className="editor-button"
216
+ onClick={() => {
217
+ setSubTab('onboard');
218
+ setCopied(false);
219
+ }}
220
+ style={
221
+ subTab === 'onboard'
222
+ ? {
223
+ background: '#1565c0',
224
+ color: '#fff',
225
+ borderColor: '#1565c0',
226
+ }
227
+ : undefined
228
+ }
229
+ >
230
+ Onboard
231
+ </button>
232
+ <button
233
+ type="button"
234
+ className="editor-button"
235
+ onClick={() => {
236
+ setSubTab('paywall');
237
+ setCopied(false);
238
+ }}
239
+ style={
240
+ subTab === 'paywall'
241
+ ? {
242
+ background: '#6a1b9a',
243
+ color: '#fff',
244
+ borderColor: '#6a1b9a',
245
+ }
246
+ : undefined
247
+ }
248
+ >
249
+ Paywall
250
+ </button>
251
+ </div>
252
+ <button type="button" className="editor-button" onClick={handleCopy}>
253
+ {copied ? '✓ Copied' : 'Copy'}
254
+ </button>
255
+ </div>
256
+
257
+ <textarea
258
+ className="prompt-manager-modal__editor"
259
+ value={activeScheme}
260
+ readOnly
261
+ spellCheck={false}
262
+ />
263
+
264
+ <div style={{ fontSize: 12, opacity: 0.7 }}>
265
+ {activeScheme.length.toLocaleString()} chars · read-only
266
+ (auto-generated)
267
+ </div>
268
+ </div>
269
+ );
270
+ }
@@ -10,3 +10,4 @@ export { ProductPresetsModal } from './ProductPresetsModal';
10
10
  export { BenefitEditModal } from './BenefitEditModal';
11
11
  export { BenefitPresetsModal } from './BenefitPresetsModal';
12
12
  export { InspectModal } from './InspectModal';
13
+ export { PromptManagerModal } from './PromptManagerModal';
@@ -28,5 +28,6 @@
28
28
  @use './modals/benefit-edit-modal';
29
29
  @use './modals/benefit-presets-modal';
30
30
  @use './modals/inspect-modal';
31
+ @use './modals/prompt-manager-modal';
31
32
 
32
33
  @use './utilities/carousel';
@@ -0,0 +1,95 @@
1
+ @use '../foundation/colors' as colors;
2
+ @use '../foundation/sizes' as sizes;
3
+ @use '../foundation/typography' as typography;
4
+
5
+ .prompt-manager-modal__content {
6
+ width: calc(100vw - 32px);
7
+ height: calc(100vh - 32px);
8
+ max-width: 1400px;
9
+ max-height: calc(100vh - 32px);
10
+ padding: 0;
11
+ display: flex;
12
+ flex-direction: column;
13
+ }
14
+
15
+ .prompt-manager-modal__header {
16
+ padding: sizes.$spaceComfy sizes.$spaceRoomy;
17
+ border-bottom: 1px solid colors.$borderColor;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ }
22
+
23
+ .prompt-manager-modal__tabs {
24
+ display: flex;
25
+ padding: 0 sizes.$spaceRoomy;
26
+ border-bottom: 1px solid colors.$borderColor;
27
+ background: colors.$canvasColor;
28
+ }
29
+
30
+ .prompt-manager-modal__tab {
31
+ padding: sizes.$spaceComfy sizes.$spaceRoomy;
32
+ font-size: 14px;
33
+ font-weight: 500;
34
+ color: colors.$mutedTextColor;
35
+ background: none;
36
+ border: none;
37
+ border-bottom: 2px solid transparent;
38
+ cursor: pointer;
39
+ transition: all 0.2s ease;
40
+
41
+ &:hover {
42
+ color: colors.$textColor;
43
+ }
44
+
45
+ &.is-active {
46
+ color: colors.$primary;
47
+ border-bottom-color: colors.$primary;
48
+ }
49
+ }
50
+
51
+ .prompt-manager-modal__body {
52
+ flex: 1;
53
+ min-height: 0;
54
+ padding: sizes.$spaceRoomy;
55
+ display: flex;
56
+ flex-direction: column;
57
+ overflow: hidden;
58
+ }
59
+
60
+ .prompt-manager-modal__editor-wrap {
61
+ flex: 1;
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: sizes.$spaceComfy;
65
+ min-height: 0;
66
+ }
67
+
68
+ .prompt-manager-modal__toolbar {
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ }
73
+
74
+ .prompt-manager-modal__editor {
75
+ flex: 1;
76
+ width: 100%;
77
+ height: 100%;
78
+ resize: none;
79
+ border: 1px solid colors.$borderColor;
80
+ border-radius: sizes.$radiusRounded;
81
+ padding: sizes.$spaceComfy;
82
+ font-family:
83
+ 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas',
84
+ 'Liberation Mono', 'Courier New', monospace;
85
+ font-size: 13px;
86
+ line-height: 1.5;
87
+ background: colors.$muted;
88
+ color: colors.$textColor;
89
+ outline: none;
90
+
91
+ &:focus {
92
+ border-color: colors.$primary;
93
+ box-shadow: 0 0 0 2px color-mix(in srgb, colors.$primary, transparent 90%);
94
+ }
95
+ }
@@ -0,0 +1,196 @@
1
+ import type { Node, NodeData } from '../types/Node';
2
+
3
+ /* ------------------------------------------------------------------ */
4
+ /* Node → XML */
5
+ /* ------------------------------------------------------------------ */
6
+
7
+ function escapeXmlAttr(str: string, quote: '"' | "'"): string {
8
+ let result = str
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;');
12
+ if (quote === '"') {
13
+ result = result.replace(/"/g, '&quot;');
14
+ } else {
15
+ result = result.replace(/'/g, '&apos;');
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function attrValueToString(value: unknown): string {
21
+ if (value === null || value === undefined) return '';
22
+ if (typeof value === 'string') return value;
23
+ if (typeof value === 'number' || typeof value === 'boolean')
24
+ return String(value);
25
+ return JSON.stringify(value);
26
+ }
27
+
28
+ function nodeToXmlInner(node: Node, indent: number): string {
29
+ const pad = ' '.repeat(indent);
30
+
31
+ if (node === null || node === undefined) return '';
32
+ if (typeof node === 'string')
33
+ return `${pad}${escapeXmlAttr(node, '"').replace(/"/g, '"')}`; // Text content doesn't need to escape quotes
34
+
35
+ if (Array.isArray(node)) {
36
+ return node.map((child) => nodeToXmlInner(child, indent)).join('\n');
37
+ }
38
+
39
+ if (typeof node !== 'object') return '';
40
+
41
+ const record = node as NodeData;
42
+ const type = record.type;
43
+ if (!type) return '';
44
+
45
+ // Build attribute string
46
+ const attrs: string[] = [];
47
+
48
+ const addAttr = (key: string, val: unknown) => {
49
+ const strValue = attrValueToString(val);
50
+ // Smart quote selection:
51
+ // If value contains double quotes but NOT single quotes, use single quotes.
52
+ // Otherwise default to double quotes (and escape internal double quotes).
53
+ const useSingle = strValue.includes('"') && !strValue.includes("'");
54
+ const quote = useSingle ? "'" : '"';
55
+ const escaped = escapeXmlAttr(strValue, quote);
56
+ attrs.push(`${key}=${quote}${escaped}${quote}`);
57
+ };
58
+
59
+ if (record.key) addAttr('key', record.key);
60
+ if (record.sourceType) addAttr('sourceType', record.sourceType);
61
+ if (record.isMain) addAttr('isMain', 'true');
62
+
63
+ if (record.attributes && typeof record.attributes === 'object') {
64
+ for (const [k, v] of Object.entries(record.attributes)) {
65
+ addAttr(k, v);
66
+ }
67
+ }
68
+
69
+ const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
70
+
71
+ // Children
72
+ const children = record.children;
73
+ if (
74
+ children === null ||
75
+ children === undefined ||
76
+ (Array.isArray(children) && children.length === 0)
77
+ ) {
78
+ return `${pad}<${type}${attrStr} />`;
79
+ }
80
+
81
+ const childXml = nodeToXmlInner(children, indent + 1);
82
+ return `${pad}<${type}${attrStr}>\n${childXml}\n${pad}</${type}>`;
83
+ }
84
+
85
+ /**
86
+ * Serialize a Node tree to an XML string.
87
+ */
88
+ export function nodeToXml(node: Node): string {
89
+ if (node === null || node === undefined) return '';
90
+ return nodeToXmlInner(node, 0);
91
+ }
92
+
93
+ /* ------------------------------------------------------------------ */
94
+ /* XML → Node */
95
+ /* ------------------------------------------------------------------ */
96
+
97
+ function unescapeXml(str: string): string {
98
+ return str
99
+ .replace(/&quot;/g, '"')
100
+ .replace(/&gt;/g, '>')
101
+ .replace(/&lt;/g, '<')
102
+ .replace(/&amp;/g, '&');
103
+ }
104
+
105
+ function parseAttrValue(raw: string): unknown {
106
+ const str = unescapeXml(raw);
107
+
108
+ // Boolean
109
+ if (str === 'true') return true;
110
+ if (str === 'false') return false;
111
+
112
+ // Number
113
+ if (str !== '' && !Number.isNaN(Number(str))) return Number(str);
114
+
115
+ // JSON object / array
116
+ if (
117
+ (str.startsWith('{') && str.endsWith('}')) ||
118
+ (str.startsWith('[') && str.endsWith(']'))
119
+ ) {
120
+ try {
121
+ return JSON.parse(str);
122
+ } catch {
123
+ // fall through to string
124
+ }
125
+ }
126
+
127
+ return str;
128
+ }
129
+
130
+ function domElementToNode(el: Element): NodeData {
131
+ const type = el.tagName;
132
+ const nodeData: NodeData = { type, children: null };
133
+
134
+ // Reserved XML attributes → NodeData fields
135
+ const reserved = new Set(['key', 'sourceType', 'isMain']);
136
+ const attributes: Record<string, unknown> = {};
137
+
138
+ for (let i = 0; i < el.attributes.length; i++) {
139
+ const attr = el.attributes[i];
140
+ if (reserved.has(attr.name)) {
141
+ if (attr.name === 'key') nodeData.key = attr.value;
142
+ else if (attr.name === 'sourceType') nodeData.sourceType = attr.value;
143
+ else if (attr.name === 'isMain') nodeData.isMain = attr.value === 'true';
144
+ } else {
145
+ attributes[attr.name] = parseAttrValue(attr.value);
146
+ }
147
+ }
148
+
149
+ if (Object.keys(attributes).length > 0) {
150
+ nodeData.attributes = attributes;
151
+ }
152
+
153
+ // Children
154
+ const childNodes: Node[] = [];
155
+ for (let i = 0; i < el.childNodes.length; i++) {
156
+ const child = el.childNodes[i];
157
+ if (child.nodeType === 1) {
158
+ // Element
159
+ childNodes.push(domElementToNode(child as Element));
160
+ } else if (child.nodeType === 3) {
161
+ // Text
162
+ const text = (child.textContent ?? '').trim();
163
+ if (text) childNodes.push(text);
164
+ }
165
+ }
166
+
167
+ if (childNodes.length === 0) {
168
+ nodeData.children = null;
169
+ } else if (childNodes.length === 1) {
170
+ nodeData.children = childNodes[0];
171
+ } else {
172
+ nodeData.children = childNodes;
173
+ }
174
+
175
+ return nodeData;
176
+ }
177
+
178
+ /**
179
+ * Parse an XML string back into a Node tree.
180
+ *
181
+ * Uses the browser's built-in DOMParser.
182
+ */
183
+ export function xmlToNode(xml: string): Node {
184
+ if (!xml.trim()) return null;
185
+
186
+ const parser = new DOMParser();
187
+ const doc = parser.parseFromString(xml, 'text/xml');
188
+
189
+ const parseError = doc.querySelector('parsererror');
190
+ if (parseError) {
191
+ throw new Error(parseError.textContent ?? 'XML parse error');
192
+ }
193
+
194
+ const root = doc.documentElement;
195
+ return domElementToNode(root);
196
+ }