@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -2
  3. package/dist/_chunks/core.js +2314 -0
  4. package/dist/_chunks/core.js.map +1 -0
  5. package/dist/_chunks/index.d.ts +431 -0
  6. package/dist/core/index.cjs +67 -0
  7. package/dist/core/index.d.cts +2 -0
  8. package/dist/core/index.d.ts +2 -0
  9. package/dist/core/index.js +2 -0
  10. package/dist/element/index.cjs +47 -0
  11. package/dist/element/index.cjs.map +1 -0
  12. package/dist/element/index.d.cts +24 -0
  13. package/dist/element/index.d.ts +24 -0
  14. package/dist/element/index.js +46 -0
  15. package/dist/element/index.js.map +1 -0
  16. package/dist/index.cjs +512 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +204 -0
  19. package/dist/index.d.ts +204 -0
  20. package/dist/index.js +441 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/nuxt/index.cjs +36 -0
  23. package/dist/nuxt/index.cjs.map +1 -0
  24. package/dist/nuxt/index.d.cts +30 -0
  25. package/dist/nuxt/index.d.ts +30 -0
  26. package/dist/nuxt/index.js +36 -0
  27. package/dist/nuxt/index.js.map +1 -0
  28. package/dist/resolver/index.cjs +26 -0
  29. package/dist/resolver/index.cjs.map +1 -0
  30. package/dist/resolver/index.d.cts +19 -0
  31. package/dist/resolver/index.d.ts +19 -0
  32. package/dist/resolver/index.js +26 -0
  33. package/dist/resolver/index.js.map +1 -0
  34. package/dist/styles.css +136 -0
  35. package/package.json +144 -3
  36. package/src/components/ASmartSkeleton.vue +228 -0
  37. package/src/components/ASmartSkeletonBox.vue +23 -0
  38. package/src/components/ASmartSkeletonProvider.vue +34 -0
  39. package/src/components/ASmartSkeletonSuspense.vue +142 -0
  40. package/src/composables/useSmartSkeleton.ts +20 -0
  41. package/src/composables/useStyles.ts +8 -0
  42. package/src/core/boot.ts +63 -0
  43. package/src/core/canvas.ts +7 -0
  44. package/src/core/constants.ts +76 -0
  45. package/src/core/debug.ts +154 -0
  46. package/src/core/dev.ts +53 -0
  47. package/src/core/fade.ts +490 -0
  48. package/src/core/ghost.ts +236 -0
  49. package/src/core/images.ts +28 -0
  50. package/src/core/index.ts +71 -0
  51. package/src/core/inline.generated.ts +9 -0
  52. package/src/core/measure.ts +571 -0
  53. package/src/core/memory.ts +146 -0
  54. package/src/core/motion.ts +12 -0
  55. package/src/core/plan.ts +368 -0
  56. package/src/core/reveal.ts +89 -0
  57. package/src/core/reveals.ts +39 -0
  58. package/src/core/shapes.ts +84 -0
  59. package/src/core/shimmer.ts +229 -0
  60. package/src/core/skeleton.ts +43 -0
  61. package/src/core/snapshot.ts +159 -0
  62. package/src/core/styles.ts +187 -0
  63. package/src/core/sweep.ts +203 -0
  64. package/src/core/theme.ts +162 -0
  65. package/src/element/index.ts +59 -0
  66. package/src/index.ts +27 -0
  67. package/src/nuxt/index.ts +59 -0
  68. package/src/resolver/index.ts +35 -0
  69. package/src/types.ts +100 -0
  70. package/src/vue/Skeleton.ts +275 -0
  71. package/src/vue/Suspense.ts +157 -0
  72. package/src/vue/config.ts +78 -0
  73. package/src/vue/index.ts +7 -0
  74. package/web-types.json +256 -0
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ import { defineComponent, h, onBeforeUnmount, onMounted, ref, Suspense, type PropType } from 'vue';
3
+ import { ATTR_PANE, ATTR_REVEAL, boxOf, fadeIn, fadeOut, Memory, type RevealMode } from '../core';
4
+ import { useSmartSkeleton } from '../composables/useSmartSkeleton';
5
+ import { useStyles } from '../composables/useStyles';
6
+ import { Loading } from './ASmartSkeleton.vue';
7
+
8
+ type State = { waited: boolean; exit: (() => void) | null };
9
+ const state = { type: Object as PropType<State>, required: true } as const;
10
+
11
+ export const Fallback = defineComponent({
12
+ name: 'ASmartSkeletonFallback',
13
+ props: { id: String, label: String, reveal: [String, Object] as PropType<RevealMode>, state },
14
+ setup(props, { slots }) {
15
+ const element = ref<HTMLElement | null>(null);
16
+ const config = useSmartSkeleton();
17
+
18
+ onMounted(() => {
19
+ const pane = element.value;
20
+ const reveal = pane?.parentElement;
21
+ if (!pane || !reveal) return;
22
+ // eslint-disable-next-line vue/no-mutating-props
23
+ props.state.waited = true;
24
+ fadeIn(pane, props.reveal ?? config.reveal, { initial: true });
25
+ reveal.setAttribute('aria-busy', 'true');
26
+ });
27
+
28
+ onBeforeUnmount(() => {
29
+ const pane = element.value;
30
+ const reveal = pane?.parentElement;
31
+ if (!pane || !reveal) return;
32
+ reveal.removeAttribute('aria-busy');
33
+
34
+ const box = boxOf(pane);
35
+ const copy = pane.cloneNode(true) as HTMLElement;
36
+ // eslint-disable-next-line vue/no-mutating-props
37
+ props.state.exit = () => {
38
+ Object.assign(copy.style, {
39
+ position: 'absolute',
40
+ top: `${box.top}px`,
41
+ left: `${box.left}px`,
42
+ width: `${box.width}px`,
43
+ });
44
+ reveal.prepend(copy);
45
+ fadeOut(copy).then(() => copy.remove());
46
+ };
47
+ });
48
+
49
+ return () =>
50
+ h(
51
+ 'div',
52
+ { ref: element, [ATTR_PANE]: '' },
53
+ h(Loading, { id: props.id, exact: false, label: props.label }, slots.default)
54
+ );
55
+ },
56
+ });
57
+
58
+ export const BoundaryContent = defineComponent({
59
+ name: 'ASmartSkeletonBoundaryContent',
60
+ props: { id: String, reveal: [String, Object] as PropType<RevealMode>, state },
61
+ emits: ['reveal'],
62
+ setup(props, { slots, emit }) {
63
+ const element = ref<HTMLElement | null>(null);
64
+ const config = useSmartSkeleton();
65
+ let memory: Memory | null = null;
66
+
67
+ onMounted(() => {
68
+ const pane = element.value;
69
+ if (!pane) return;
70
+ const waited = props.state.waited;
71
+ const arrival = fadeIn(pane, props.reveal ?? config.reveal, {
72
+ initial: !waited,
73
+ });
74
+ if (waited) arrival.then(() => emit('reveal'));
75
+
76
+ props.state.exit?.();
77
+ // eslint-disable-next-line vue/no-mutating-props
78
+ props.state.exit = null;
79
+ memory = new Memory(pane, { id: props.id });
80
+ });
81
+ onBeforeUnmount(() => memory?.destroy());
82
+
83
+ return () => h('div', { ref: element, [ATTR_PANE]: '' }, slots.default?.());
84
+ },
85
+ });
86
+
87
+ /**
88
+ * A Suspense boundary whose fallback is a measured skeleton, crossfading into the content.
89
+ *
90
+ * ```vue
91
+ * <ASmartSkeletonSuspense>
92
+ * <LoadedQuote />
93
+ * <template #skeleton><Quote :quote="SAMPLE_QUOTE" /></template>
94
+ * </ASmartSkeletonSuspense>
95
+ * ```
96
+ */
97
+ export default defineComponent({
98
+ name: 'ASmartSkeletonSuspense',
99
+ props: {
100
+ /** Remembers the shape under this key across visits. */
101
+ id: String,
102
+ /** Read to screen readers while suspended, in place of the skeleton. */
103
+ label: String,
104
+ /** Overrides the provider's reveal for this boundary alone. */
105
+ reveal: [String, Object] as PropType<RevealMode>,
106
+ },
107
+ /** `reveal`: content has come in over the skeleton. */
108
+ emits: ['reveal'],
109
+ setup(props, { slots, emit }) {
110
+ useStyles();
111
+ const shared: State = { waited: false, exit: null };
112
+
113
+ return () =>
114
+ h('div', { [ATTR_REVEAL]: '' }, [
115
+ h(Suspense, null, {
116
+ default: () =>
117
+ h(
118
+ BoundaryContent,
119
+ {
120
+ id: props.id,
121
+ reveal: props.reveal,
122
+ state: shared,
123
+ onReveal: () => emit('reveal'),
124
+ },
125
+ slots.default
126
+ ),
127
+ fallback: () =>
128
+ h(
129
+ Fallback,
130
+ {
131
+ id: props.id,
132
+ label: props.label,
133
+ reveal: props.reveal,
134
+ state: shared,
135
+ },
136
+ slots.skeleton
137
+ ),
138
+ }),
139
+ ]);
140
+ },
141
+ });
142
+ </script>
@@ -0,0 +1,20 @@
1
+ import { inject, type InjectionKey } from 'vue';
2
+ import type { SmartSkeletonConfig } from '../types';
3
+
4
+ export const SMART_SKELETON_CONFIG_KEY: InjectionKey<SmartSkeletonConfig> =
5
+ Symbol('a-smart-skeleton');
6
+
7
+ /** Per server render: whether the full inline script has gone out yet, so the skeletons after it send a stub. */
8
+ export const SMART_SKELETON_BOOT_KEY: InjectionKey<{ sent: boolean }> =
9
+ Symbol('a-smart-skeleton.boot');
10
+
11
+ /** The provider settings in scope for this component. Empty outside a provider. */
12
+ export const useSmartSkeleton = (): SmartSkeletonConfig => inject(SMART_SKELETON_CONFIG_KEY, {});
13
+
14
+ /** Whether this skeleton should send the stub — claiming the full script if it's the first. */
15
+ export function claimStub(): boolean {
16
+ const boot = inject(SMART_SKELETON_BOOT_KEY, null);
17
+ const stub = !!boot?.sent;
18
+ if (boot) boot.sent = true;
19
+ return stub;
20
+ }
@@ -0,0 +1,8 @@
1
+ import { onBeforeMount, onUnmounted } from 'vue';
2
+ import { addStyles, removeStyles } from '../core';
3
+
4
+ /** Adds the stylesheet on mount and removes it on unmount — reference counted, so siblings share one copy. */
5
+ export function useStyles(): void {
6
+ onBeforeMount(addStyles);
7
+ onUnmounted(removeStyles);
8
+ }
@@ -0,0 +1,63 @@
1
+ import { ATTR_LAYER, ATTR_GHOST, BOOT, GHOSTED, UNCOVER } from './constants';
2
+ import { measureSkeleton } from './measure';
3
+ import { drawSkeleton } from './shimmer';
4
+
5
+ /*
6
+ * Inlined after a server-rendered skeleton (see build-inline.mjs). It measures in its
7
+ * ResizeObserver's first callback, after layout and before paint, so the first paint
8
+ * already shows the skeleton. It touches nothing a framework will hydrate: the skeleton
9
+ * lives in a shadow root. Sent once a page where a provider can tell; the skeletons after
10
+ * it carry a stub that calls the `boot` this leaves on window, or queues until it lands.
11
+ */
12
+
13
+ // A path from the document root, so a painted chip's colour is hidden without an attribute hydration would see as a mismatch
14
+ const pathOf = (element: Element): string =>
15
+ element.parentElement
16
+ ? `${pathOf(element.parentElement)} > :nth-child(${[...element.parentElement.children].indexOf(element) + 1})`
17
+ : ':root';
18
+
19
+ type Root = HTMLElement & { [GHOSTED]?: boolean; [UNCOVER]?: () => void };
20
+
21
+ function boot(script: Element | null) {
22
+ const root = script?.previousElementSibling as Root | null | undefined;
23
+ const ghost = root?.querySelector<HTMLElement>(`:scope > [${ATTR_GHOST}]`);
24
+ const layer = root?.querySelector<HTMLElement>(`:scope > [${ATTR_LAYER}]`);
25
+ if (!root || !ghost || !layer) return;
26
+
27
+ const sheet =
28
+ typeof CSSStyleSheet === 'function' && 'replaceSync' in CSSStyleSheet.prototype
29
+ ? new CSSStyleSheet()
30
+ : null;
31
+ if (sheet) {
32
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
33
+ root[UNCOVER] = () => {
34
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
35
+ (adopted) => adopted !== sheet
36
+ );
37
+ };
38
+ }
39
+ const cover = (covered: Set<Element>) =>
40
+ sheet?.replaceSync(
41
+ covered.size
42
+ ? `${[...covered].map(pathOf).join(', ')} { visibility: hidden !important; }`
43
+ : ''
44
+ );
45
+
46
+ const draw = () => {
47
+ if (!layer.isConnected || root[GHOSTED]) return observer.disconnect();
48
+ // Streamed HTML is parsed into a hidden container first: nothing to measure until it's moved into place
49
+ if (!ghost.getClientRects().length) return;
50
+ drawSkeleton(layer, measureSkeleton(ghost, layer, { mark: false, onCovered: cover }));
51
+ };
52
+ // Measures in the first callback, after layout and before paint, then until Ghost takes over
53
+ const observer = new ResizeObserver(draw);
54
+ observer.observe(root);
55
+ document.fonts?.ready.then(draw);
56
+ }
57
+
58
+ const host = self as unknown as Record<symbol, unknown>;
59
+ const key = Symbol.for(BOOT);
60
+ const queued = host[key];
61
+ host[key] = boot;
62
+ if (Array.isArray(queued)) queued.forEach(boot);
63
+ boot(document.currentScript);
@@ -0,0 +1,7 @@
1
+ let context: CanvasRenderingContext2D | null | undefined;
2
+
3
+ // jsdom has no canvas, and logs an error for every context asked of it
4
+ export const textContext = () =>
5
+ (context ??= /jsdom/.test(navigator.userAgent)
6
+ ? null
7
+ : document.createElement('canvas').getContext('2d'));
@@ -0,0 +1,76 @@
1
+ export const ATTR_REVEAL = 'ask-reveal';
2
+ export const ATTR_PANE = 'ask-pane';
3
+ export const ATTR_LOADING = 'ask-loading';
4
+ export const ATTR_GHOST = 'ask-ghost'; // The framework's render of the content, laid out but never seen
5
+ export const ATTR_SNAPSHOT = 'ask-snapshot'; // A remembered or fallback shape, standing in for the ghost
6
+ export const ATTR_LAYER = 'ask-layer';
7
+ export const ATTR_RECT = 'ask-rect';
8
+ export const ATTR_BAR = 'ask-bar';
9
+ export const ATTR_INACTIVE = 'ask-inactive';
10
+ export const ATTR_EXITING = 'ask-exiting'; // On a pane fading out — its skeleton holds still
11
+ export const ATTR_COVERED = 'ask-covered'; // Drawn as one block by a bone, so its own paint is hidden
12
+ export const ATTR_OWN = 'ask-own'; // A subtree that draws its own skeleton — measuring stops at it
13
+ export const ATTR_IGNORE = 'ask-ignore';
14
+ export const ATTR_BLOCK = 'ask-block';
15
+
16
+ export const GHOSTED = Symbol.for('a-smart-skeleton.ghost'); // On a loading root once its Ghost runs: the inline script stands down
17
+ export const UNCOVER = Symbol.for('a-smart-skeleton.uncover'); // On a served loading root: drops the inline script's sheet hiding covered paint, once Ghost marks it
18
+ export const BOOT = 'a-smart-skeleton.boot'; // Symbol.for key on window: the inline script's measuring, sent once a page — later skeletons' stubs call it, or queue for it
19
+ export const ATTR_STATUS = 'ask-status'; // Read to screen readers in place of the skeleton
20
+ export const ATTR_XRAY = 'ask-xray'; // Debug, on any ancestor: shows the ghost through its bones
21
+ export const ATTR_DEBUG = 'ask-debug'; // Debug outlines for this subtree alone
22
+
23
+ // Themed by ASmartSkeletonProvider; the fallbacks are translucent so one grey reads on light and dark pages alike
24
+ export const BASE = 'var(--ask-base, rgb(128 128 128 / 0.2))';
25
+ export const HIGHLIGHT = 'var(--ask-highlight, rgb(255 255 255 / 0.12))';
26
+ export const FADE_DURATION = 200;
27
+ export const CASCADE_SPREAD = 320; // From the first piece of a sequenced reveal to the last
28
+ export const RESIZE_DURATION = 280; // A reveal easing to its new height, when its skeleton guessed the content wrong
29
+ export const IMAGE_TIMEOUT = 10000; // Longest a reveal holds for an image — a stalled request mustn't keep a skeleton up for good
30
+ // One loop of each animation, by `speed`; a shimmer's is across a 1280px-wide screen
31
+ export const DURATIONS = {
32
+ shimmer: { slow: 4000, fast: 2000 },
33
+ pulse: { slow: 2400, fast: 1200 },
34
+ } as const;
35
+
36
+ // Leaves that hold text, so an empty one still stands for a line of it
37
+ export const TEXT_TAGS = [
38
+ 'a',
39
+ 'b',
40
+ 'code',
41
+ 'dd',
42
+ 'dt',
43
+ 'em',
44
+ 'h1',
45
+ 'h2',
46
+ 'h3',
47
+ 'h4',
48
+ 'h5',
49
+ 'h6',
50
+ 'i',
51
+ 'label',
52
+ 'li',
53
+ 'p',
54
+ 'small',
55
+ 'span',
56
+ 'strong',
57
+ 'td',
58
+ 'th',
59
+ 'time',
60
+ ];
61
+
62
+ // Drawn as one block at their box — there is no text inside to measure
63
+ export const MEDIA_TAGS = [
64
+ 'audio',
65
+ 'canvas',
66
+ 'embed',
67
+ 'iframe',
68
+ 'img',
69
+ 'input',
70
+ 'object',
71
+ 'picture',
72
+ 'select',
73
+ 'svg',
74
+ 'textarea',
75
+ 'video',
76
+ ];
@@ -0,0 +1,154 @@
1
+ import { ATTR_DEBUG, ATTR_XRAY } from './constants';
2
+
3
+ type Entry = {
4
+ state: 'loading' | 'shown';
5
+ source?: string;
6
+ moved?: { width: number; height: number };
7
+ };
8
+
9
+ const entries = new Map<Element, Entry>();
10
+ let enabled: boolean | undefined;
11
+ let host: HTMLElement | null = null;
12
+ let frame = 0;
13
+
14
+ function fromPage() {
15
+ try {
16
+ return (
17
+ new URLSearchParams(location.search).has('ask-debug') ||
18
+ localStorage.getItem('a-smart-skeleton:debug') === '1'
19
+ );
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ const everywhere = () => (enabled ??= typeof window !== 'undefined' && fromPage());
26
+
27
+ /** On with `?ask-debug`, `a-smart-skeleton:debug` = "1" in localStorage or `setDebug(true)`; for `element`, also inside `[ask-debug]`. */
28
+ export const debugging = (element?: Element | null) =>
29
+ everywhere() || !!element?.closest(`[${ATTR_DEBUG}]`);
30
+
31
+ export function setDebug(on: boolean) {
32
+ enabled = on;
33
+ if (on) start();
34
+ else stop();
35
+ }
36
+
37
+ export function reportLoading(reveal: Element | null, source: string) {
38
+ if (!reveal || !debugging(reveal)) return;
39
+ entries.set(reveal, { state: 'loading', source });
40
+ show();
41
+ }
42
+
43
+ export function reportReveal(reveal: Element | null, moved: { width: number; height: number }) {
44
+ if (!reveal || !debugging(reveal)) return;
45
+ entries.set(reveal, { ...entries.get(reveal), state: 'shown', moved });
46
+ show();
47
+ }
48
+
49
+ // Drawn at once, not on the next frame: a throttled page, like a test's iframe, may wait long for one
50
+ function show() {
51
+ start();
52
+ cancelAnimationFrame(frame);
53
+ draw();
54
+ }
55
+
56
+ const PANEL_CSS = `
57
+ :host { all: initial; }
58
+ .box { position: fixed; box-sizing: border-box; border: 1px dashed; border-radius: 4px; }
59
+ .label {
60
+ position: absolute; left: -1px; bottom: 100%; margin-bottom: 2px;
61
+ padding: 1px 6px; border-radius: 4px; white-space: nowrap;
62
+ font: 500 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff;
63
+ }
64
+ .loading { border-color: #3b82f6; } .loading .label { background: #3b82f6; }
65
+ .still { border-color: #22c55e; } .still .label { background: #16a34a; }
66
+ .moved { border-color: #ef4444; border-style: solid; } .moved .label { background: #dc2626; }
67
+ .panel {
68
+ position: fixed; right: 12px; bottom: 12px; display: flex; gap: 8px; align-items: center;
69
+ padding: 6px 10px; border-radius: 8px; background: #111; color: #fff; pointer-events: auto;
70
+ font: 500 12px/16px ui-sans-serif, system-ui, sans-serif; box-shadow: 0 4px 16px rgb(0 0 0 / 0.3);
71
+ }
72
+ .panel label { display: flex; gap: 4px; align-items: center; cursor: pointer; }
73
+ `;
74
+
75
+ function start() {
76
+ if (typeof document === 'undefined') return;
77
+ if (!host?.isConnected) {
78
+ host = document.createElement('div');
79
+ host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
80
+ const shadow = host.attachShadow({ mode: 'open' });
81
+ shadow.innerHTML = `<style>${PANEL_CSS}</style><div class="boxes"></div>`;
82
+ document.body.append(host);
83
+ }
84
+ // The panel, and its x-ray toggle, are for the whole page only
85
+ const shadow = host.shadowRoot!;
86
+ if (enabled && !shadow.querySelector('.panel')) {
87
+ const panel = document.createElement('div');
88
+ panel.className = 'panel';
89
+ panel.innerHTML = `a-smart-skeleton debug <label><input type="checkbox" class="xray"> x-ray</label>`;
90
+ panel.querySelector('.xray')!.addEventListener('change', (event) => {
91
+ document.documentElement.toggleAttribute(
92
+ ATTR_XRAY,
93
+ (event.target as HTMLInputElement).checked
94
+ );
95
+ });
96
+ shadow.append(panel);
97
+ }
98
+ if (!frame) frame = requestAnimationFrame(draw);
99
+ }
100
+
101
+ function stop() {
102
+ host?.shadowRoot?.querySelector('.panel')?.remove();
103
+ document.documentElement.removeAttribute(ATTR_XRAY);
104
+ if ([...entries.keys()].some((reveal) => debugging(reveal))) return;
105
+ cancelAnimationFrame(frame);
106
+ frame = 0;
107
+ host?.remove();
108
+ host = null;
109
+ }
110
+
111
+ const describe = ({ state, source, moved }: Entry) => {
112
+ const from = source ? ` · ${source}` : '';
113
+ if (state === 'loading') return { kind: 'loading', text: `skeleton${from}` };
114
+ if (!moved || (moved.width === 0 && moved.height === 0)) {
115
+ return { kind: 'still', text: `✓ no movement${from}` };
116
+ }
117
+ return {
118
+ kind: 'moved',
119
+ text: `✗ moved ${moved.width}×${moved.height}px${from}`,
120
+ };
121
+ };
122
+
123
+ // Follows every tracked reveal each frame, so scrolling and layout never leave a box behind
124
+ function draw() {
125
+ frame = 0;
126
+ if (!host?.shadowRoot) return;
127
+ const boxes = host.shadowRoot.querySelector('.boxes')!;
128
+
129
+ for (const reveal of entries.keys()) {
130
+ if (!reveal.isConnected || !debugging(reveal)) entries.delete(reveal);
131
+ }
132
+
133
+ const items = [...entries];
134
+ while (boxes.children.length > items.length) boxes.lastElementChild!.remove();
135
+ items.forEach(([reveal, entry], index) => {
136
+ let box = boxes.children[index] as HTMLElement | undefined;
137
+ if (!box) {
138
+ box = boxes.appendChild(document.createElement('div'));
139
+ box.innerHTML = `<span class="label"></span>`;
140
+ }
141
+ const rect = reveal.getBoundingClientRect();
142
+ const { kind, text } = describe(entry);
143
+ box.className = `box ${kind}`;
144
+ Object.assign(box.style, {
145
+ left: `${rect.left - 2}px`,
146
+ top: `${rect.top - 2}px`,
147
+ width: `${rect.width + 4}px`,
148
+ height: `${rect.height + 4}px`,
149
+ });
150
+ box.firstElementChild!.textContent = text;
151
+ });
152
+
153
+ if (enabled || entries.size) frame = requestAnimationFrame(draw);
154
+ }
@@ -0,0 +1,53 @@
1
+ import { fill } from './snapshot';
2
+
3
+ declare const process: { env: { NODE_ENV?: string } };
4
+
5
+ let dev = false;
6
+ try {
7
+ // Bundlers replace the whole expression; with none, `process` doesn't exist
8
+ dev = process.env.NODE_ENV !== 'production';
9
+ } catch {
10
+ dev = false;
11
+ }
12
+
13
+ export const isDev = dev;
14
+
15
+ const warned = new Set<string>();
16
+
17
+ export function warnOnce(id: string, message: string) {
18
+ if (!isDev || warned.has(id)) return;
19
+ warned.add(id);
20
+ // eslint-disable-next-line no-console
21
+ console.warn(`[a-smart-skeleton] ${message}`);
22
+ }
23
+
24
+ const URL_LIKE = /^(https?:|\/|data:|blob:)/;
25
+
26
+ /**
27
+ * Warns once per app if the page has a meta CSP that restricts inline scripts and
28
+ * the consumer did not pass a nonce through the provider. Silent when CSP is absent
29
+ * or when the loading pane isn't on the client yet — we can't read CSP headers from
30
+ * script, so the meta-tag check is a best-effort signal to developers.
31
+ */
32
+ export function checkNonce(nonce: string | undefined): void {
33
+ if (!isDev || typeof document === 'undefined' || nonce) return;
34
+ const meta = document.querySelector('meta[http-equiv="Content-Security-Policy" i]');
35
+ const content = meta?.getAttribute('content') ?? '';
36
+ if (!/script-src/i.test(content)) return;
37
+ if (/'unsafe-inline'/.test(content) || /\*/.test(content)) return;
38
+ warnOnce(
39
+ 'csp:missing-nonce',
40
+ 'ASmartSkeleton ships an inline boot script that measures the server-rendered skeleton before first paint. Your page has a Content-Security-Policy with script-src but no nonce was passed to <ASmartSkeletonProvider :nonce="…">, so the browser will block it and first paint will be blank. Pass the same nonce you used for the response header.'
41
+ );
42
+ }
43
+
44
+ /** Same keys and lengths as `data`, with every string replaced by filler. */
45
+ export function sampleFrom(data: unknown): unknown {
46
+ if (typeof data === 'string') return URL_LIKE.test(data) ? '' : fill(data);
47
+ if (Array.isArray(data)) return data.map(sampleFrom);
48
+ if (data instanceof Date) return data;
49
+ if (data && typeof data === 'object') {
50
+ return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, sampleFrom(value)]));
51
+ }
52
+ return data;
53
+ }