@0xgf/boneyard 1.0.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,380 @@
1
+ const DEFAULT_LEAF_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr']);
2
+ /**
3
+ * Snapshot the exact visual layout of a rendered DOM element as skeleton bones.
4
+ * Walks the DOM, finds every visible leaf element, and captures its exact
5
+ * bounding rect relative to the root. No layout engine, no heuristics —
6
+ * just pixel-perfect positions read directly from the browser.
7
+ *
8
+ * const { bones, width, height } = snapshotBones(el, 'my-component', config)
9
+ */
10
+ export function snapshotBones(el, name = 'component', config) {
11
+ const rootRect = el.getBoundingClientRect();
12
+ const bones = [];
13
+ const leafTags = config?.leafTags ? new Set(config.leafTags) : DEFAULT_LEAF_TAGS;
14
+ const captureRoundedBorders = config?.captureRoundedBorders ?? true;
15
+ const excludeTags = config?.excludeTags ? new Set(config.excludeTags) : null;
16
+ const excludeSelectors = config?.excludeSelectors ?? null;
17
+ function walk(node) {
18
+ const style = getComputedStyle(node);
19
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
20
+ return;
21
+ const tag = node.tagName.toLowerCase();
22
+ // Exclusions — skip element and all descendants
23
+ if (excludeTags?.has(tag))
24
+ return;
25
+ if (excludeSelectors?.some(sel => node.matches(sel)))
26
+ return;
27
+ const children = [...node.children].filter(child => {
28
+ const cs = getComputedStyle(child);
29
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0';
30
+ });
31
+ const isMedia = tag === 'img' || tag === 'svg' || tag === 'video' || tag === 'canvas';
32
+ const isFormEl = tag === 'input' || tag === 'button' || tag === 'textarea' || tag === 'select';
33
+ const isLeaf = children.length === 0 || isMedia || isFormEl || leafTags.has(tag);
34
+ // Container emits a bone if it has any non-transparent background, a background image,
35
+ // or (when captureRoundedBorders is true) a visible border on a rounded element.
36
+ // Every background color counts — white cards are still cards.
37
+ const bg = style.backgroundColor;
38
+ const hasBg = bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
39
+ const hasBgImage = style.backgroundImage !== 'none';
40
+ const borderTopWidth = parseFloat(style.borderTopWidth) || 0;
41
+ const hasBorder = captureRoundedBorders && borderTopWidth > 0 && style.borderTopColor !== 'rgba(0, 0, 0, 0)' && style.borderTopColor !== 'transparent';
42
+ const hasBorderRadius = (parseFloat(style.borderTopLeftRadius) || 0) > 0;
43
+ const hasVisualSurface = hasBg || hasBgImage || (hasBorder && hasBorderRadius);
44
+ if (isLeaf) {
45
+ const rect = node.getBoundingClientRect();
46
+ if (rect.width < 1 || rect.height < 1)
47
+ return;
48
+ const br = parseBorderRadius(style, node);
49
+ bones.push({
50
+ x: Math.round(rect.left - rootRect.left),
51
+ y: Math.round(rect.top - rootRect.top),
52
+ w: Math.round(rect.width),
53
+ h: Math.round(rect.height),
54
+ r: br ?? 8,
55
+ });
56
+ return;
57
+ }
58
+ // Container with visible background: emit a lighter bone (c: true) so that
59
+ // child bones rendered on top stand out against it, then recurse.
60
+ if (hasVisualSurface) {
61
+ const rect = node.getBoundingClientRect();
62
+ if (rect.width >= 1 && rect.height >= 1) {
63
+ const br = parseBorderRadius(style, node);
64
+ bones.push({
65
+ x: Math.round(rect.left - rootRect.left),
66
+ y: Math.round(rect.top - rootRect.top),
67
+ w: Math.round(rect.width),
68
+ h: Math.round(rect.height),
69
+ r: br ?? 8,
70
+ c: true, // container bone — rendered at reduced opacity
71
+ });
72
+ }
73
+ }
74
+ // Recurse into children regardless — their bones overlay the container bone
75
+ for (const child of children) {
76
+ walk(child);
77
+ }
78
+ }
79
+ // Don't snapshot the root itself — walk its children
80
+ for (const child of el.children) {
81
+ walk(child);
82
+ }
83
+ return {
84
+ name,
85
+ viewportWidth: Math.round(rootRect.width),
86
+ width: Math.round(rootRect.width),
87
+ height: Math.round(rootRect.height),
88
+ bones,
89
+ };
90
+ }
91
+ /**
92
+ * Extract a SkeletonDescriptor from a rendered DOM element.
93
+ * Reads computed styles — no config needed. Just point it at your component.
94
+ */
95
+ export function fromElement(el) {
96
+ return extractNode(el);
97
+ }
98
+ function extractNode(el) {
99
+ const style = getComputedStyle(el);
100
+ const desc = {};
101
+ // Bail on layouts we can't recompute — snapshot as fixed-size leaf
102
+ if (style.display === 'grid' || style.display === 'inline-grid') {
103
+ return snapshotAsLeaf(el, style, desc);
104
+ }
105
+ if (style.position === 'absolute' || style.position === 'fixed') {
106
+ return snapshotAsLeaf(el, style, desc);
107
+ }
108
+ // Layout
109
+ if (style.display === 'flex' || style.display === 'inline-flex') {
110
+ desc.display = 'flex';
111
+ desc.flexDirection = (style.flexDirection === 'column' || style.flexDirection === 'column-reverse') ? 'column' : 'row';
112
+ if (style.alignItems && style.alignItems !== 'normal' && style.alignItems !== 'stretch')
113
+ desc.alignItems = style.alignItems;
114
+ if (style.justifyContent && style.justifyContent !== 'normal' && style.justifyContent !== 'flex-start')
115
+ desc.justifyContent = style.justifyContent;
116
+ // Read row-gap and column-gap separately (Tailwind gap-x-*, gap-y-*)
117
+ const rowGap = parseFloat(style.rowGap);
118
+ const colGap = parseFloat(style.columnGap);
119
+ if (rowGap > 0 && colGap > 0 && rowGap === colGap) {
120
+ desc.gap = rowGap;
121
+ }
122
+ else {
123
+ if (rowGap > 0)
124
+ desc.rowGap = rowGap;
125
+ if (colGap > 0)
126
+ desc.columnGap = colGap;
127
+ }
128
+ }
129
+ // Spacing
130
+ const pad = extractSides(style, 'padding');
131
+ if (pad)
132
+ desc.padding = pad;
133
+ const mar = extractSides(style, 'margin');
134
+ if (mar)
135
+ desc.margin = mar;
136
+ // Border radius
137
+ const br = parseBorderRadius(style, el);
138
+ if (br !== undefined)
139
+ desc.borderRadius = br;
140
+ // Max width
141
+ const maxW = parseFloat(style.maxWidth);
142
+ if (maxW > 0 && isFinite(maxW))
143
+ desc.maxWidth = maxW;
144
+ // Measure element dimensions
145
+ const rect = el.getBoundingClientRect();
146
+ const w = rect.width;
147
+ const h = rect.height;
148
+ const parentW = el.parentElement ? el.parentElement.getBoundingClientRect().width : w;
149
+ // Leaf detection — width is handled inside extractLeaf per element type
150
+ if (isLeafElement(el, style)) {
151
+ return extractLeaf(el, style, desc, w, h, parentW);
152
+ }
153
+ // Fixed-size containers need explicit width so the layout engine doesn't stretch them
154
+ if (isFixedSize(el, style, w, parentW) && w > 0) {
155
+ desc.width = Math.round(w);
156
+ }
157
+ // Container: recurse into children
158
+ const children = [];
159
+ for (const child of el.children) {
160
+ const childStyle = getComputedStyle(child);
161
+ if (childStyle.display === 'none' || childStyle.visibility === 'hidden' || childStyle.opacity === '0')
162
+ continue;
163
+ children.push(extractNode(child));
164
+ }
165
+ if (children.length > 0)
166
+ desc.children = children;
167
+ return desc;
168
+ }
169
+ /** Is this element explicitly sized (not stretching to fill its parent)? */
170
+ function isFixedSize(el, style, w, parentW) {
171
+ // flex-grow > 0 means the element is designed to stretch — never treat as fixed
172
+ if (parseFloat(style.flexGrow) > 0)
173
+ return false;
174
+ // If flex-shrink: 0, it's explicitly sized (e.g. shrink-0 avatars, icons)
175
+ if (style.flexShrink === '0')
176
+ return true;
177
+ // Check if the parent is a flex row — if so, only treat as fixed if the
178
+ // element doesn't participate in flex distribution
179
+ const parent = el.parentElement;
180
+ if (parent) {
181
+ const parentStyle = getComputedStyle(parent);
182
+ const parentIsFlex = parentStyle.display === 'flex' || parentStyle.display === 'inline-flex';
183
+ const parentIsRow = parentIsFlex && (parentStyle.flexDirection === 'row' || parentStyle.flexDirection === 'row-reverse');
184
+ if (parentIsRow) {
185
+ // In a flex row, only fixed if flex-basis is explicit or flex-shrink: 0
186
+ // Default flex items can grow/shrink, so don't assume fixed
187
+ return false;
188
+ }
189
+ }
190
+ // Block-level element that's narrower than parent — likely has explicit width
191
+ if (w > 0 && parentW > 0 && w < parentW * 0.8)
192
+ return true;
193
+ return false;
194
+ }
195
+ function isLeafElement(el, style) {
196
+ const tag = el.tagName.toLowerCase();
197
+ if (tag === 'img' || tag === 'video' || tag === 'canvas' || tag === 'svg')
198
+ return true;
199
+ if (tag === 'input' || tag === 'button' || tag === 'textarea' || tag === 'select')
200
+ return true;
201
+ if (el.children.length === 0)
202
+ return true;
203
+ if (style.backgroundImage && style.backgroundImage !== 'none' && !el.querySelector('*:not(br)'))
204
+ return true;
205
+ return false;
206
+ }
207
+ function extractLeaf(el, style, desc, w, h, parentW) {
208
+ const tag = el.tagName.toLowerCase();
209
+ // Images and media
210
+ if (tag === 'img' || tag === 'video' || tag === 'canvas') {
211
+ return applyDimensions(el, style, desc, w, h, parentW);
212
+ }
213
+ // SVG icons
214
+ if (tag === 'svg') {
215
+ if (!desc.width && w > 0)
216
+ desc.width = Math.round(w);
217
+ desc.height = Math.round(h > 0 ? h : 24);
218
+ return desc;
219
+ }
220
+ // Background image/gradient containers (hero images, avatars)
221
+ if (style.backgroundImage && style.backgroundImage !== 'none' && el.children.length === 0) {
222
+ return applyDimensions(el, style, desc, w, h, parentW);
223
+ }
224
+ // Buttons and inputs
225
+ if (tag === 'button' || tag === 'input' || tag === 'textarea' || tag === 'select') {
226
+ desc.leaf = true;
227
+ desc.height = Math.round(h > 0 ? h : 40);
228
+ return desc;
229
+ }
230
+ // Text nodes
231
+ const text = el.textContent?.trim();
232
+ if (text) {
233
+ desc.text = text;
234
+ desc.font = buildFontString(style);
235
+ const lh = parseFloat(style.lineHeight);
236
+ if (lh > 0 && isFinite(lh))
237
+ desc.lineHeight = Math.round(lh * 100) / 100;
238
+ return desc;
239
+ }
240
+ // Fallback — capture both dimensions for fixed-size empty elements
241
+ if (isFixedSize(el, style, w, parentW) && w > 0) {
242
+ desc.width = Math.round(w);
243
+ }
244
+ desc.height = Math.round(h > 0 ? h : 20);
245
+ return desc;
246
+ }
247
+ /** Apply width/height or aspectRatio depending on whether element is full-width or fixed-size */
248
+ function applyDimensions(el, style, desc, w, h, parentW) {
249
+ // Check CSS aspect-ratio first
250
+ const ar = style.aspectRatio;
251
+ if (ar && ar !== 'auto') {
252
+ const parsed = parseAspectRatio(ar);
253
+ if (parsed) {
254
+ desc.aspectRatio = parsed;
255
+ return desc;
256
+ }
257
+ }
258
+ // Fixed-size element (avatar, icon) — use explicit dimensions
259
+ if (desc.width || isFixedSize(el, style, w, parentW)) {
260
+ if (!desc.width && w > 0)
261
+ desc.width = Math.round(w);
262
+ desc.height = Math.round(h > 0 ? h : w);
263
+ return desc;
264
+ }
265
+ // Full-width responsive element — use aspect ratio
266
+ if (w > 0 && h > 0) {
267
+ desc.aspectRatio = Math.round((w / h) * 1000) / 1000;
268
+ }
269
+ else {
270
+ desc.height = Math.round(h > 0 ? h : 150);
271
+ }
272
+ return desc;
273
+ }
274
+ function buildFontString(style) {
275
+ const weight = style.fontWeight;
276
+ const size = style.fontSize;
277
+ const family = style.fontFamily.split(',')[0].trim().replace(/^["']|["']$/g, '');
278
+ return `${weight} ${size} ${family}`;
279
+ }
280
+ function extractSides(style, prop) {
281
+ const top = parseFloat(style.getPropertyValue(`${prop}-top`)) || 0;
282
+ const right = parseFloat(style.getPropertyValue(`${prop}-right`)) || 0;
283
+ const bottom = parseFloat(style.getPropertyValue(`${prop}-bottom`)) || 0;
284
+ const left = parseFloat(style.getPropertyValue(`${prop}-left`)) || 0;
285
+ if (top === 0 && right === 0 && bottom === 0 && left === 0)
286
+ return undefined;
287
+ if (top === right && right === bottom && bottom === left)
288
+ return top;
289
+ const sides = {};
290
+ if (top)
291
+ sides.top = top;
292
+ if (right)
293
+ sides.right = right;
294
+ if (bottom)
295
+ sides.bottom = bottom;
296
+ if (left)
297
+ sides.left = left;
298
+ return sides;
299
+ }
300
+ /**
301
+ * Parse border-radius from computed style, preserving per-corner values.
302
+ * Returns: '50%' for circles, a number for uniform radius,
303
+ * or a CSS string like '8px 8px 0px 8px' for asymmetric corners.
304
+ *
305
+ * Important distinction:
306
+ * - `border-radius: 50%` on a square → circle → returns '50%'
307
+ * - `border-radius: 9999px` on a rectangle → pill → returns 9999 (large number)
308
+ * - `border-radius: 50%` on a rectangle → oval → returns '50%'
309
+ */
310
+ function parseBorderRadius(style, el) {
311
+ const tl = parseFloat(style.borderTopLeftRadius) || 0;
312
+ const tr = parseFloat(style.borderTopRightRadius) || 0;
313
+ const br = parseFloat(style.borderBottomRightRadius) || 0;
314
+ const bl = parseFloat(style.borderBottomLeftRadius) || 0;
315
+ // No radius
316
+ if (tl === 0 && tr === 0 && br === 0 && bl === 0)
317
+ return undefined;
318
+ // Detect if element is roughly square (circle candidate)
319
+ const isSquarish = el ? (() => {
320
+ const rect = el.getBoundingClientRect();
321
+ return rect.width > 0 && rect.height > 0 && Math.abs(rect.width - rect.height) < 4;
322
+ })() : false;
323
+ // Check for 50% (actual percentage-based)
324
+ const raw = style.borderRadius;
325
+ if (raw === '50%')
326
+ return '50%';
327
+ // Large pixel values (9999px / rounded-full): circle if square, pill if rectangle
328
+ const maxCorner = Math.max(tl, tr, br, bl);
329
+ if (maxCorner > 9998) {
330
+ return isSquarish ? '50%' : 9999;
331
+ }
332
+ // All corners equal — return single number (skip if default 8)
333
+ if (tl === tr && tr === br && br === bl) {
334
+ return tl !== 8 ? tl : undefined;
335
+ }
336
+ // Asymmetric corners — return full CSS string
337
+ return `${tl}px ${tr}px ${br}px ${bl}px`;
338
+ }
339
+ /**
340
+ * Snapshot an element we can't recompute layout for (grid, absolute, etc.)
341
+ * Captures bounding box and recurses into children that ARE supported,
342
+ * positioning them relative to this element's bounds.
343
+ */
344
+ function snapshotAsLeaf(el, style, desc) {
345
+ const rect = el.getBoundingClientRect();
346
+ desc.width = Math.round(rect.width);
347
+ desc.height = Math.round(rect.height);
348
+ // Border radius
349
+ const br = parseBorderRadius(style, el);
350
+ if (br !== undefined)
351
+ desc.borderRadius = br;
352
+ // If it has children, try to extract them — we just fix this node's own dimensions
353
+ // so the layout engine treats it as a fixed-size container
354
+ const children = [];
355
+ for (const child of el.children) {
356
+ const childStyle = getComputedStyle(child);
357
+ if (childStyle.display === 'none' || childStyle.visibility === 'hidden' || childStyle.opacity === '0')
358
+ continue;
359
+ children.push(extractNode(child));
360
+ }
361
+ if (children.length > 0) {
362
+ desc.display = 'flex';
363
+ desc.flexDirection = 'column';
364
+ desc.children = children;
365
+ }
366
+ return desc;
367
+ }
368
+ function parseAspectRatio(ar) {
369
+ const parts = ar.split('/');
370
+ if (parts.length === 2) {
371
+ const num = parseFloat(parts[0]);
372
+ const den = parseFloat(parts[1]);
373
+ if (num > 0 && den > 0)
374
+ return Math.round((num / den) * 1000) / 1000;
375
+ }
376
+ const val = parseFloat(ar);
377
+ if (val > 0 && isFinite(val))
378
+ return val;
379
+ return undefined;
380
+ }
@@ -0,0 +1,54 @@
1
+ export type { Bone, SkeletonResult, ResponsiveBones, SkeletonDescriptor, ResponsiveDescriptor, SnapshotConfig } from './types.js';
2
+ /**
3
+ * Snapshot exact pixel positions of a rendered element as skeleton bones.
4
+ * Reads `getBoundingClientRect()` on every visible element — no simulation,
5
+ * just what the browser already computed.
6
+ *
7
+ * Use this to pre-generate bones at dev time, save as JSON, and pass as
8
+ * `initialBones` to `<Skeleton>` for zero first-load flash.
9
+ *
10
+ * @example In a browser console or dev script:
11
+ * ```ts
12
+ * import { snapshotBones } from '@0xgf/boneyard'
13
+ * const bones = snapshotBones(document.querySelector('.my-card'))
14
+ * console.log(JSON.stringify(bones, null, 2))
15
+ * // → paste into my-card.bones.json
16
+ * ```
17
+ */
18
+ export { snapshotBones } from './extract.js';
19
+ /**
20
+ * Extract a skeleton descriptor from a rendered DOM element.
21
+ * Captures layout structure as plain JSON — use with `computeLayout` for
22
+ * SSR or build-time paths where a live browser DOM isn't available.
23
+ */
24
+ export { fromElement } from './extract.js';
25
+ /**
26
+ * Extract responsive descriptors at multiple breakpoints.
27
+ * Resizes the container to each width, extracts a descriptor per breakpoint.
28
+ * Returns a `ResponsiveDescriptor` you can serialize and ship as JSON.
29
+ */
30
+ export { extractResponsive } from './responsive.js';
31
+ /**
32
+ * Compute skeleton bone positions from a descriptor at a given width.
33
+ * Uses @chenglou/pretext for text measurement — no DOM needed.
34
+ * Ideal for SSR or workers where `snapshotBones` isn't available.
35
+ */
36
+ export { computeLayout } from './layout.js';
37
+ /**
38
+ * Render a `SkeletonResult` to an HTML string.
39
+ * Use for `innerHTML`, SSR, edge functions, etc.
40
+ */
41
+ export { renderBones } from './runtime.js';
42
+ /**
43
+ * All-in-one convenience: extract descriptor → compute layout → render HTML.
44
+ *
45
+ * container.innerHTML = skeleton(element)
46
+ *
47
+ * For React, prefer `<Skeleton>` from '@0xgf/boneyard/react' — it calls
48
+ * `snapshotBones()` directly and handles caching automatically.
49
+ */
50
+ export declare function skeleton(el: Element, options?: {
51
+ width?: number;
52
+ color?: string;
53
+ animate?: boolean;
54
+ }): string;
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ import { fromElement } from './extract.js';
2
+ import { computeLayout } from './layout.js';
3
+ import { renderBones } from './runtime.js';
4
+ /**
5
+ * Snapshot exact pixel positions of a rendered element as skeleton bones.
6
+ * Reads `getBoundingClientRect()` on every visible element — no simulation,
7
+ * just what the browser already computed.
8
+ *
9
+ * Use this to pre-generate bones at dev time, save as JSON, and pass as
10
+ * `initialBones` to `<Skeleton>` for zero first-load flash.
11
+ *
12
+ * @example In a browser console or dev script:
13
+ * ```ts
14
+ * import { snapshotBones } from '@0xgf/boneyard'
15
+ * const bones = snapshotBones(document.querySelector('.my-card'))
16
+ * console.log(JSON.stringify(bones, null, 2))
17
+ * // → paste into my-card.bones.json
18
+ * ```
19
+ */
20
+ export { snapshotBones } from './extract.js';
21
+ /**
22
+ * Extract a skeleton descriptor from a rendered DOM element.
23
+ * Captures layout structure as plain JSON — use with `computeLayout` for
24
+ * SSR or build-time paths where a live browser DOM isn't available.
25
+ */
26
+ export { fromElement } from './extract.js';
27
+ /**
28
+ * Extract responsive descriptors at multiple breakpoints.
29
+ * Resizes the container to each width, extracts a descriptor per breakpoint.
30
+ * Returns a `ResponsiveDescriptor` you can serialize and ship as JSON.
31
+ */
32
+ export { extractResponsive } from './responsive.js';
33
+ /**
34
+ * Compute skeleton bone positions from a descriptor at a given width.
35
+ * Uses @chenglou/pretext for text measurement — no DOM needed.
36
+ * Ideal for SSR or workers where `snapshotBones` isn't available.
37
+ */
38
+ export { computeLayout } from './layout.js';
39
+ /**
40
+ * Render a `SkeletonResult` to an HTML string.
41
+ * Use for `innerHTML`, SSR, edge functions, etc.
42
+ */
43
+ export { renderBones } from './runtime.js';
44
+ /**
45
+ * All-in-one convenience: extract descriptor → compute layout → render HTML.
46
+ *
47
+ * container.innerHTML = skeleton(element)
48
+ *
49
+ * For React, prefer `<Skeleton>` from '@0xgf/boneyard/react' — it calls
50
+ * `snapshotBones()` directly and handles caching automatically.
51
+ */
52
+ export function skeleton(el, options) {
53
+ const structure = fromElement(el);
54
+ const w = options?.width ?? el.getBoundingClientRect().width;
55
+ const result = computeLayout(structure, w);
56
+ return renderBones(result, options?.color, options?.animate);
57
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Layout engine — uses @chenglou/pretext for exact text measurement.
3
+ *
4
+ * Takes a SkeletonDescriptor (developer-defined component structure) and a
5
+ * width, then computes pixel-perfect bone positions using pretext for text
6
+ * and box-model arithmetic for containers.
7
+ *
8
+ * No DOM, no puppeteer, no build step. Describe your component, get bones.
9
+ */
10
+ import type { SkeletonDescriptor, SkeletonResult } from './types.js';
11
+ /**
12
+ * Compute skeleton bones from a descriptor at a given width.
13
+ * Uses pretext for all text measurement — no DOM needed.
14
+ */
15
+ export declare function computeLayout(desc: SkeletonDescriptor, width: number, name?: string): SkeletonResult;