@aprovan/bobbin 0.1.0-dev.03aaf5b

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 (45) hide show
  1. package/.turbo/turbo-build.log +16 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +402 -0
  4. package/dist/index.js +3704 -0
  5. package/package.json +30 -0
  6. package/src/Bobbin.tsx +89 -0
  7. package/src/components/EditPanel/EditPanel.tsx +376 -0
  8. package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
  9. package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
  10. package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
  11. package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
  12. package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
  13. package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
  14. package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
  15. package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
  16. package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
  17. package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
  18. package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
  19. package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
  20. package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
  21. package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
  22. package/src/components/Inspector/Inspector.tsx +221 -0
  23. package/src/components/Overlay/ControlHandles.tsx +572 -0
  24. package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
  25. package/src/components/Overlay/SelectionOverlay.tsx +73 -0
  26. package/src/components/Pill/Pill.tsx +155 -0
  27. package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
  28. package/src/core/changeSerializer.ts +139 -0
  29. package/src/core/useBobbin.ts +399 -0
  30. package/src/core/useChangeTracker.ts +186 -0
  31. package/src/core/useClipboard.ts +21 -0
  32. package/src/core/useElementSelection.ts +146 -0
  33. package/src/index.ts +46 -0
  34. package/src/tokens/borders.ts +19 -0
  35. package/src/tokens/colors.ts +150 -0
  36. package/src/tokens/index.ts +37 -0
  37. package/src/tokens/shadows.ts +10 -0
  38. package/src/tokens/spacing.ts +37 -0
  39. package/src/tokens/typography.ts +51 -0
  40. package/src/types.ts +157 -0
  41. package/src/utils/animation.ts +40 -0
  42. package/src/utils/dom.ts +36 -0
  43. package/src/utils/selectors.ts +76 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +10 -0
@@ -0,0 +1,229 @@
1
+ import type { SelectedElement } from '../../types';
2
+
3
+ interface MarginPaddingOverlayProps {
4
+ selectedElement: SelectedElement;
5
+ zIndex?: number;
6
+ }
7
+
8
+ export function MarginPaddingOverlay({
9
+ selectedElement,
10
+ zIndex = 9997,
11
+ }: MarginPaddingOverlayProps) {
12
+ const { element, rect } = selectedElement;
13
+ const computed = window.getComputedStyle(element);
14
+
15
+ const margin = {
16
+ top: parseFloat(computed.marginTop) || 0,
17
+ right: parseFloat(computed.marginRight) || 0,
18
+ bottom: parseFloat(computed.marginBottom) || 0,
19
+ left: parseFloat(computed.marginLeft) || 0,
20
+ };
21
+
22
+ const padding = {
23
+ top: parseFloat(computed.paddingTop) || 0,
24
+ right: parseFloat(computed.paddingRight) || 0,
25
+ bottom: parseFloat(computed.paddingBottom) || 0,
26
+ left: parseFloat(computed.paddingLeft) || 0,
27
+ };
28
+
29
+ // Get gap information
30
+ const gap = parseFloat(computed.gap) || 0;
31
+ const rowGap = parseFloat(computed.rowGap) || gap;
32
+ const columnGap = parseFloat(computed.columnGap) || gap;
33
+ const display = computed.display;
34
+ const flexDirection = computed.flexDirection;
35
+ const isFlexOrGrid = display.includes('flex') || display.includes('grid');
36
+ const isColumn = flexDirection === 'column' || flexDirection === 'column-reverse';
37
+
38
+ // Margin color: orange tint
39
+ const marginColor = 'rgba(251, 146, 60, 0.3)';
40
+ // Padding color: green tint
41
+ const paddingColor = 'rgba(74, 222, 128, 0.3)';
42
+ // Gap color: purple tint
43
+ const gapColor = 'rgba(168, 85, 247, 0.35)';
44
+
45
+ // Get visible children and their rects
46
+ const getGapOverlays = () => {
47
+ if (!isFlexOrGrid || (rowGap === 0 && columnGap === 0)) return [];
48
+
49
+ const children = Array.from(element.children).filter(
50
+ child => child instanceof HTMLElement &&
51
+ getComputedStyle(child).display !== 'none' &&
52
+ getComputedStyle(child).visibility !== 'hidden'
53
+ ) as HTMLElement[];
54
+
55
+ if (children.length < 2) return [];
56
+
57
+ const overlays: Array<{ top: number; left: number; width: number; height: number }> = [];
58
+
59
+ for (let i = 0; i < children.length - 1; i++) {
60
+ const currentChild = children[i];
61
+ const nextChild = children[i + 1];
62
+ if (!currentChild || !nextChild) continue;
63
+
64
+ const currentRect = currentChild.getBoundingClientRect();
65
+ const nextRect = nextChild.getBoundingClientRect();
66
+
67
+ if (isColumn || display.includes('grid')) {
68
+ // For column flex or grid, show row gaps (vertical gaps between rows)
69
+ if (rowGap > 0 && nextRect.top > currentRect.bottom) {
70
+ const gapTop = currentRect.bottom;
71
+ const gapHeight = Math.min(rowGap, nextRect.top - currentRect.bottom);
72
+ if (gapHeight > 0) {
73
+ overlays.push({
74
+ top: gapTop,
75
+ left: rect.left + padding.left,
76
+ width: rect.width - padding.left - padding.right,
77
+ height: gapHeight,
78
+ });
79
+ }
80
+ }
81
+ }
82
+
83
+ if (!isColumn || display.includes('grid')) {
84
+ // For row flex or grid, show column gaps (horizontal gaps between columns)
85
+ if (columnGap > 0 && nextRect.left > currentRect.right) {
86
+ const gapLeft = currentRect.right;
87
+ const gapWidth = Math.min(columnGap, nextRect.left - currentRect.right);
88
+ if (gapWidth > 0) {
89
+ overlays.push({
90
+ top: rect.top + padding.top,
91
+ left: gapLeft,
92
+ width: gapWidth,
93
+ height: rect.height - padding.top - padding.bottom,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ return overlays;
101
+ };
102
+
103
+ const gapOverlays = getGapOverlays();
104
+
105
+ return (
106
+ <div data-bobbin="margin-padding-overlay" style={{ position: 'fixed', top: 0, left: 0, zIndex, pointerEvents: 'none' }}>
107
+ {/* Top margin */}
108
+ {margin.top > 0 && (
109
+ <div
110
+ style={{
111
+ position: 'fixed',
112
+ top: rect.top - margin.top,
113
+ left: rect.left,
114
+ width: rect.width,
115
+ height: margin.top,
116
+ backgroundColor: marginColor,
117
+ }}
118
+ />
119
+ )}
120
+ {/* Bottom margin */}
121
+ {margin.bottom > 0 && (
122
+ <div
123
+ style={{
124
+ position: 'fixed',
125
+ top: rect.bottom,
126
+ left: rect.left,
127
+ width: rect.width,
128
+ height: margin.bottom,
129
+ backgroundColor: marginColor,
130
+ }}
131
+ />
132
+ )}
133
+ {/* Left margin */}
134
+ {margin.left > 0 && (
135
+ <div
136
+ style={{
137
+ position: 'fixed',
138
+ top: rect.top,
139
+ left: rect.left - margin.left,
140
+ width: margin.left,
141
+ height: rect.height,
142
+ backgroundColor: marginColor,
143
+ }}
144
+ />
145
+ )}
146
+ {/* Right margin */}
147
+ {margin.right > 0 && (
148
+ <div
149
+ style={{
150
+ position: 'fixed',
151
+ top: rect.top,
152
+ left: rect.right,
153
+ width: margin.right,
154
+ height: rect.height,
155
+ backgroundColor: marginColor,
156
+ }}
157
+ />
158
+ )}
159
+
160
+ {/* Top padding */}
161
+ {padding.top > 0 && (
162
+ <div
163
+ style={{
164
+ position: 'fixed',
165
+ top: rect.top,
166
+ left: rect.left,
167
+ width: rect.width,
168
+ height: padding.top,
169
+ backgroundColor: paddingColor,
170
+ }}
171
+ />
172
+ )}
173
+ {/* Bottom padding */}
174
+ {padding.bottom > 0 && (
175
+ <div
176
+ style={{
177
+ position: 'fixed',
178
+ top: rect.bottom - padding.bottom,
179
+ left: rect.left,
180
+ width: rect.width,
181
+ height: padding.bottom,
182
+ backgroundColor: paddingColor,
183
+ }}
184
+ />
185
+ )}
186
+ {/* Left padding */}
187
+ {padding.left > 0 && (
188
+ <div
189
+ style={{
190
+ position: 'fixed',
191
+ top: rect.top + padding.top,
192
+ left: rect.left,
193
+ width: padding.left,
194
+ height: rect.height - padding.top - padding.bottom,
195
+ backgroundColor: paddingColor,
196
+ }}
197
+ />
198
+ )}
199
+ {/* Right padding */}
200
+ {padding.right > 0 && (
201
+ <div
202
+ style={{
203
+ position: 'fixed',
204
+ top: rect.top + padding.top,
205
+ left: rect.right - padding.right,
206
+ width: padding.right,
207
+ height: rect.height - padding.top - padding.bottom,
208
+ backgroundColor: paddingColor,
209
+ }}
210
+ />
211
+ )}
212
+
213
+ {/* Gap overlays */}
214
+ {gapOverlays.map((overlay, index) => (
215
+ <div
216
+ key={`gap-${index}`}
217
+ style={{
218
+ position: 'fixed',
219
+ top: overlay.top,
220
+ left: overlay.left,
221
+ width: overlay.width,
222
+ height: overlay.height,
223
+ backgroundColor: gapColor,
224
+ }}
225
+ />
226
+ ))}
227
+ </div>
228
+ );
229
+ }
@@ -0,0 +1,73 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ import type { SelectedElement } from '../../types';
3
+
4
+ interface SelectionOverlayProps {
5
+ hoveredElement: SelectedElement | null;
6
+ selectedElement: SelectedElement | null;
7
+ offset?: number;
8
+ zIndex?: number;
9
+ }
10
+
11
+ export function SelectionOverlay({
12
+ hoveredElement,
13
+ selectedElement,
14
+ offset = 4,
15
+ zIndex = 9998,
16
+ }: SelectionOverlayProps) {
17
+ const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
18
+ const [selectRect, setSelectRect] = useState<DOMRect | null>(null);
19
+
20
+ // Animate hover box
21
+ useLayoutEffect(() => {
22
+ if (!hoveredElement || hoveredElement === selectedElement) {
23
+ setHoverRect(null);
24
+ return;
25
+ }
26
+ setHoverRect(hoveredElement.rect);
27
+ }, [hoveredElement, selectedElement]);
28
+
29
+ // Animate selection box
30
+ useLayoutEffect(() => {
31
+ if (!selectedElement) {
32
+ setSelectRect(null);
33
+ return;
34
+ }
35
+ setSelectRect(selectedElement.rect);
36
+ }, [selectedElement]);
37
+
38
+ // ShadCN-style: subtle dark gray for hover, black for selection
39
+ const createBoxStyle = (rect: DOMRect | null, isSelected: boolean): React.CSSProperties => {
40
+ if (!rect) return { opacity: 0, pointerEvents: 'none' };
41
+
42
+ return {
43
+ position: 'fixed',
44
+ top: rect.top - offset,
45
+ left: rect.left - offset,
46
+ width: rect.width + offset * 2,
47
+ height: rect.height + offset * 2,
48
+ border: isSelected ? '1.5px solid #18181b' : '1.5px dashed #71717a',
49
+ borderRadius: '3px',
50
+ pointerEvents: 'none',
51
+ zIndex,
52
+ transition: 'all 0.12s cubic-bezier(0.4, 0, 0.2, 1)',
53
+ opacity: 1,
54
+ boxShadow: isSelected ? '0 0 0 1px rgba(24, 24, 27, 0.1)' : 'none',
55
+ };
56
+ };
57
+
58
+ return (
59
+ <>
60
+ {/* Hover overlay */}
61
+ <div
62
+ data-bobbin="hover-overlay"
63
+ style={createBoxStyle(hoverRect, false)}
64
+ />
65
+
66
+ {/* Selection overlay */}
67
+ <div
68
+ data-bobbin="select-overlay"
69
+ style={createBoxStyle(selectRect, true)}
70
+ />
71
+ </>
72
+ );
73
+ }
@@ -0,0 +1,155 @@
1
+ import { useState, useLayoutEffect } from 'react';
2
+ import type { BobbinState, BobbinActions } from '../../types';
3
+
4
+ interface PillProps {
5
+ state: BobbinState;
6
+ actions: BobbinActions;
7
+ position?: { bottom: number; right: number };
8
+ container?: HTMLElement | null;
9
+ zIndex?: number;
10
+ }
11
+
12
+ // Copy icon
13
+ const CopyIcon = () => (
14
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
15
+ <rect x="9" y="9" width="13" height="13" rx="2" />
16
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
17
+ </svg>
18
+ );
19
+
20
+ // Edit icon (pencil)
21
+ const EditIcon = () => (
22
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
23
+ <path d="M12 20h9" />
24
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
25
+ </svg>
26
+ );
27
+
28
+ export function Pill({ state, actions, position, container, zIndex = 9999 }: PillProps) {
29
+ const [copyHovered, setCopyHovered] = useState(false);
30
+ const [copied, setCopied] = useState(false);
31
+ const [computedPosition, setComputedPosition] = useState<{ bottom: number; right: number } | null>(null);
32
+
33
+ const offset = position ?? { bottom: 16, right: 16 };
34
+
35
+ // Calculate position relative to container if provided
36
+ useLayoutEffect(() => {
37
+ if (!container) {
38
+ setComputedPosition(null);
39
+ return;
40
+ }
41
+
42
+ const updatePosition = () => {
43
+ const rect = container.getBoundingClientRect();
44
+ // Position from viewport edges based on container position
45
+ setComputedPosition({
46
+ bottom: window.innerHeight - rect.bottom + offset.bottom,
47
+ right: window.innerWidth - rect.right + offset.right,
48
+ });
49
+ };
50
+
51
+ updatePosition();
52
+
53
+ // Update on resize/scroll
54
+ const observer = new ResizeObserver(updatePosition);
55
+ observer.observe(container);
56
+ window.addEventListener('scroll', updatePosition, true);
57
+
58
+ return () => {
59
+ observer.disconnect();
60
+ window.removeEventListener('scroll', updatePosition, true);
61
+ };
62
+ }, [container, offset.bottom, offset.right]);
63
+
64
+ const pos = computedPosition ?? offset;
65
+
66
+ const handleClick = () => {
67
+ if (state.isActive) {
68
+ actions.deactivate();
69
+ } else {
70
+ actions.activate();
71
+ }
72
+ };
73
+
74
+ const handleCopyChanges = (e: React.MouseEvent) => {
75
+ e.stopPropagation();
76
+ const yaml = actions.exportChanges();
77
+ navigator.clipboard.writeText(yaml);
78
+ setCopied(true);
79
+ setTimeout(() => setCopied(false), 1500);
80
+ };
81
+
82
+ // ShadCN-style: minimal black/white with subtle borders
83
+ return (
84
+ <div
85
+ data-bobbin="pill"
86
+ className="bobbin-pill"
87
+ style={{
88
+ position: 'fixed',
89
+ bottom: pos.bottom,
90
+ right: pos.right,
91
+ zIndex,
92
+ display: 'flex',
93
+ alignItems: 'center',
94
+ gap: '6px',
95
+ padding: '6px 10px',
96
+ borderRadius: '9999px',
97
+ backgroundColor: state.isActive ? '#18181b' : '#fafafa',
98
+ color: state.isActive ? '#fafafa' : '#18181b',
99
+ border: '1px solid #e4e4e7',
100
+ cursor: 'pointer',
101
+ boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
102
+ transition: 'all 0.15s ease',
103
+ userSelect: 'none',
104
+ fontSize: '13px',
105
+ fontFamily: 'system-ui, -apple-system, sans-serif',
106
+ }}
107
+ onClick={handleClick}
108
+ >
109
+ {/* Edit Icon */}
110
+ <EditIcon />
111
+
112
+ {/* Change count badge with copy button - shows for changes OR annotations */}
113
+ {(state.changes.length > 0 || state.annotations.length > 0) && (
114
+ <button
115
+ onClick={handleCopyChanges}
116
+ onMouseEnter={() => setCopyHovered(true)}
117
+ onMouseLeave={() => setCopyHovered(false)}
118
+ style={{
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ gap: '4px',
122
+ padding: '2px 6px',
123
+ borderRadius: '9999px',
124
+ backgroundColor: state.isActive ? '#fafafa' : '#18181b',
125
+ color: state.isActive ? '#18181b' : '#fafafa',
126
+ fontSize: '11px',
127
+ fontWeight: 500,
128
+ border: 'none',
129
+ cursor: 'pointer',
130
+ transition: 'all 0.1s ease',
131
+ }}
132
+ title="Copy changes as YAML"
133
+ >
134
+ <span>{state.changes.length + state.annotations.length}</span>
135
+ {copyHovered && <CopyIcon />}
136
+ {copied && <span style={{ fontSize: '10px' }}>✓</span>}
137
+ </button>
138
+ )}
139
+
140
+ {/* Clipboard indicator */}
141
+ {state.clipboard && (
142
+ <div
143
+ style={{
144
+ width: '6px',
145
+ height: '6px',
146
+ borderRadius: '50%',
147
+ backgroundColor: state.isActive ? '#fafafa' : '#18181b',
148
+ border: '1px solid #a1a1aa',
149
+ }}
150
+ title="Element copied"
151
+ />
152
+ )}
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,72 @@
1
+ interface ThemeToggleProps {
2
+ theme: 'light' | 'dark' | 'system';
3
+ onToggle: () => void;
4
+ zIndex?: number;
5
+ }
6
+
7
+ // Sun icon for light theme
8
+ const SunIcon = () => (
9
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
10
+ <circle cx="12" cy="12" r="5" />
11
+ <line x1="12" y1="1" x2="12" y2="3" />
12
+ <line x1="12" y1="21" x2="12" y2="23" />
13
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
14
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
15
+ <line x1="1" y1="12" x2="3" y2="12" />
16
+ <line x1="21" y1="12" x2="23" y2="12" />
17
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
18
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
19
+ </svg>
20
+ );
21
+
22
+ // Moon icon for dark theme
23
+ const MoonIcon = () => (
24
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
25
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
26
+ </svg>
27
+ );
28
+
29
+ // Monitor icon for system theme
30
+ const MonitorIcon = () => (
31
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
32
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
33
+ <line x1="8" y1="21" x2="16" y2="21" />
34
+ <line x1="12" y1="17" x2="12" y2="21" />
35
+ </svg>
36
+ );
37
+
38
+ export function ThemeToggle({ theme, onToggle, zIndex = 9999 }: ThemeToggleProps) {
39
+ const icons = {
40
+ light: <SunIcon />,
41
+ dark: <MoonIcon />,
42
+ system: <MonitorIcon />,
43
+ };
44
+
45
+ return (
46
+ <button
47
+ data-bobbin="theme-toggle"
48
+ onClick={onToggle}
49
+ style={{
50
+ position: 'fixed',
51
+ bottom: '24px',
52
+ left: '24px',
53
+ width: '32px',
54
+ height: '32px',
55
+ borderRadius: '8px',
56
+ border: '1px solid #e4e4e7',
57
+ backgroundColor: '#fafafa',
58
+ color: '#18181b',
59
+ cursor: 'pointer',
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ fontSize: '14px',
64
+ boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
65
+ zIndex,
66
+ }}
67
+ title={`Theme: ${theme}`}
68
+ >
69
+ {icons[theme]}
70
+ </button>
71
+ );
72
+ }
@@ -0,0 +1,139 @@
1
+ import type { Change, Annotation, BobbinChangeset } from '../types';
2
+
3
+ export function serializeChangesToYAML(
4
+ changes: Change[],
5
+ annotations: Annotation[] = [],
6
+ ): string {
7
+ const changeset: BobbinChangeset = {
8
+ version: '1.0',
9
+ timestamp: new Date().toISOString(),
10
+ changeCount: changes.length + annotations.length,
11
+ changes: changes.map((change) => {
12
+ const base = {
13
+ type: change.type,
14
+ target: change.target.path,
15
+ xpath: change.target.xpath,
16
+ };
17
+
18
+ switch (change.type) {
19
+ case 'style':
20
+ return {
21
+ ...base,
22
+ property: (change.before as { property: string }).property,
23
+ before: (change.before as { value: string }).value,
24
+ after: (change.after as { value: string }).value,
25
+ };
26
+ case 'text':
27
+ return {
28
+ ...base,
29
+ before: change.before as string,
30
+ after: change.after as string,
31
+ };
32
+ case 'move':
33
+ return {
34
+ ...base,
35
+ before: `${(change.before as { parent: string }).parent}[${
36
+ (change.before as { index: number }).index
37
+ }]`,
38
+ after: `${(change.after as { parent: string }).parent}[${
39
+ (change.after as { index: number }).index
40
+ }]`,
41
+ };
42
+ default:
43
+ return {
44
+ ...base,
45
+ before: JSON.stringify(change.before),
46
+ after: JSON.stringify(change.after),
47
+ };
48
+ }
49
+ }),
50
+ annotations: annotations.map((a) => ({
51
+ type: 'annotation',
52
+ target: a.elementPath,
53
+ xpath: a.elementXpath,
54
+ note: a.content,
55
+ })),
56
+ };
57
+
58
+ return toYAML(changeset);
59
+ }
60
+
61
+ function toYAML(obj: unknown, indent = 0): string {
62
+ const spaces = ' '.repeat(indent);
63
+
64
+ if (obj === null || obj === undefined) return 'null';
65
+ if (typeof obj === 'boolean') return obj ? 'true' : 'false';
66
+ if (typeof obj === 'number') return String(obj);
67
+ if (typeof obj === 'string') {
68
+ // Escape strings that need quoting
69
+ if (/[\n:{}[\],&*#?|\-<>=!%@`]/.test(obj) || obj.trim() !== obj) {
70
+ return `"${obj.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
71
+ }
72
+ return obj || '""';
73
+ }
74
+
75
+ if (Array.isArray(obj)) {
76
+ if (obj.length === 0) return '[]';
77
+ return obj
78
+ .map((item) => {
79
+ const itemYaml = toYAML(item, indent + 1);
80
+ if (typeof item === 'object' && item !== null) {
81
+ return `${spaces}- ${itemYaml.trimStart()}`;
82
+ }
83
+ return `${spaces}- ${itemYaml}`;
84
+ })
85
+ .join('\n');
86
+ }
87
+
88
+ if (typeof obj === 'object') {
89
+ const entries = Object.entries(obj);
90
+ if (entries.length === 0) return '{}';
91
+ return entries
92
+ .map(([key, value]) => {
93
+ const valueYaml = toYAML(value, indent + 1);
94
+ if (
95
+ typeof value === 'object' &&
96
+ value !== null &&
97
+ !Array.isArray(value)
98
+ ) {
99
+ return `${spaces}${key}:\n${valueYaml}`;
100
+ }
101
+ if (Array.isArray(value) && value.length > 0) {
102
+ return `${spaces}${key}:\n${valueYaml}`;
103
+ }
104
+ return `${spaces}${key}: ${valueYaml}`;
105
+ })
106
+ .join('\n');
107
+ }
108
+
109
+ return String(obj);
110
+ }
111
+
112
+ export function parseYAMLChangeset(yaml: string): BobbinChangeset {
113
+ // Basic YAML parser for the changeset format
114
+ // For production, consider using js-yaml
115
+ const lines = yaml.split('\n');
116
+ const result: BobbinChangeset = {
117
+ version: '1.0',
118
+ timestamp: '',
119
+ changeCount: 0,
120
+ changes: [],
121
+ annotations: [],
122
+ };
123
+
124
+ // Simplified parser - in production use js-yaml
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ if (trimmed.startsWith('version:')) {
128
+ const parts = trimmed.split(':');
129
+ result.version = (parts[1]?.trim() ?? '1.0') as '1.0';
130
+ } else if (trimmed.startsWith('timestamp:')) {
131
+ result.timestamp = trimmed.split(':').slice(1).join(':').trim();
132
+ } else if (trimmed.startsWith('changeCount:')) {
133
+ const parts = trimmed.split(':');
134
+ result.changeCount = parseInt(parts[1]?.trim() ?? '0', 10);
135
+ }
136
+ }
137
+
138
+ return result;
139
+ }