@brett_lamy/docstream 0.1.0 → 0.3.1

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,83 @@
1
+ import { EventType, IncrementalSource } from 'rrweb';
2
+ import type { eventWithTime } from 'rrweb';
3
+
4
+ const TRANSPARENT_PIXEL =
5
+ 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
6
+ const EMPTY_STYLESHEET = 'data:text/css,';
7
+
8
+ function isLocalNetworkUrl(value: string): boolean {
9
+ let url: URL;
10
+ try {
11
+ url = new URL(value);
12
+ } catch {
13
+ return false;
14
+ }
15
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
16
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
17
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
18
+ if (host === '0.0.0.0' || host === '::1' || host.startsWith('127.') || host.startsWith('10.')) {
19
+ return true;
20
+ }
21
+ if (host.startsWith('192.168.')) return true;
22
+ const private172 = host.match(/^172\.(\d{1,3})\./);
23
+ if (private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31) return true;
24
+ return host.includes(':') && (host.startsWith('fc') || host.startsWith('fd'));
25
+ }
26
+
27
+ function sanitizeCss(value: string): string {
28
+ return value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, quote: string, raw: string) =>
29
+ isLocalNetworkUrl(raw.trim()) ? `url(${quote}${TRANSPARENT_PIXEL}${quote})` : whole,
30
+ );
31
+ }
32
+
33
+ function sanitizeAttributes(attributes: Record<string, unknown>): void {
34
+ for (const key of Object.keys(attributes)) {
35
+ const value = attributes[key];
36
+ if (typeof value !== 'string') continue;
37
+ const lower = key.toLowerCase();
38
+ if (lower === 'src' || lower === 'poster' || lower === 'background') {
39
+ if (isLocalNetworkUrl(value)) attributes[key] = TRANSPARENT_PIXEL;
40
+ } else if (lower === 'data') {
41
+ if (isLocalNetworkUrl(value)) attributes[key] = 'about:blank';
42
+ } else if (lower === 'style' || lower === '_csstext') {
43
+ attributes[key] = sanitizeCss(value);
44
+ } else if (lower === 'href' || lower === 'xlink:href') {
45
+ if (isLocalNetworkUrl(value)) attributes[key] = EMPTY_STYLESHEET;
46
+ }
47
+ }
48
+ }
49
+
50
+ function walkNode(node: unknown): void {
51
+ if (!node || typeof node !== 'object') return;
52
+ const value = node as { attributes?: unknown; childNodes?: unknown };
53
+ if (value.attributes && typeof value.attributes === 'object') {
54
+ sanitizeAttributes(value.attributes as Record<string, unknown>);
55
+ }
56
+ if (Array.isArray(value.childNodes)) value.childNodes.forEach(walkNode);
57
+ }
58
+
59
+ /** Prevent a replay iframe from making the viewer fetch localhost/private assets. */
60
+ export function sanitizeReplayEvent(event: eventWithTime): eventWithTime {
61
+ try {
62
+ if (event.type === EventType.FullSnapshot) {
63
+ walkNode((event.data as { node?: unknown }).node);
64
+ } else if (event.type === EventType.IncrementalSnapshot) {
65
+ const data = event.data as {
66
+ source?: number;
67
+ adds?: Array<{ node?: unknown }>;
68
+ attributes?: Array<{ attributes?: unknown }>;
69
+ };
70
+ if (data.source === IncrementalSource.Mutation) {
71
+ data.adds?.forEach((item) => walkNode(item.node));
72
+ data.attributes?.forEach((item) => {
73
+ if (item.attributes && typeof item.attributes === 'object') {
74
+ sanitizeAttributes(item.attributes as Record<string, unknown>);
75
+ }
76
+ });
77
+ }
78
+ }
79
+ } catch {
80
+ // A malformed asset must never take down the player.
81
+ }
82
+ return event;
83
+ }
@@ -0,0 +1,87 @@
1
+ .replay-preview {
2
+ width: 100%;
3
+ min-width: 0;
4
+ color: var(--replay-preview-fg, #e5e7eb);
5
+ background: var(--replay-preview-bg, #0f172a);
6
+ border: 1px solid var(--replay-preview-border, rgba(148, 163, 184, 0.28));
7
+ border-radius: 12px;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .replay-preview__frame {
12
+ display: block;
13
+ width: 100%;
14
+ aspect-ratio: 16 / 9;
15
+ border: 0;
16
+ background: #0f172a;
17
+ }
18
+
19
+ .replay-preview__invalid,
20
+ .replay-preview__empty,
21
+ .replay-preview__error {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ min-height: 140px;
26
+ padding: 20px;
27
+ color: var(--replay-preview-muted, #94a3b8);
28
+ font: 13px/1.5 ui-sans-serif, system-ui, sans-serif;
29
+ text-align: center;
30
+ }
31
+
32
+ .replay-preview__invalid {
33
+ color: var(--replay-preview-link, #7dd3fc);
34
+ }
35
+
36
+ .replay-preview__error {
37
+ color: #fda4af;
38
+ }
39
+
40
+ .replay-preview__native {
41
+ width: 100%;
42
+ background: #0f172a;
43
+ }
44
+
45
+ .replay-preview__stage {
46
+ position: relative;
47
+ width: 100%;
48
+ min-height: 160px;
49
+ overflow: hidden;
50
+ background: #020617;
51
+ }
52
+
53
+ .replay-preview__scaler {
54
+ position: absolute;
55
+ inset: 0 auto auto 0;
56
+ }
57
+
58
+ .replay-preview__player {
59
+ position: relative;
60
+ overflow: hidden;
61
+ }
62
+
63
+ .replay-preview__controls {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ padding: 8px 10px;
68
+ border-top: 1px solid rgba(148, 163, 184, 0.2);
69
+ background: #111827;
70
+ font: 12px/1.2 ui-sans-serif, system-ui, sans-serif;
71
+ }
72
+
73
+ .replay-preview__controls button,
74
+ .replay-preview__controls select {
75
+ border: 1px solid rgba(148, 163, 184, 0.3);
76
+ border-radius: 6px;
77
+ padding: 5px 8px;
78
+ color: inherit;
79
+ background: #1e293b;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .replay-preview__controls input[type='range'] {
84
+ min-width: 60px;
85
+ flex: 1;
86
+ accent-color: #38bdf8;
87
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Convert a normal Loop QA project URL to its chrome-free public embed route.
3
+ * The application owns auth/public-access decisions; this helper only changes
4
+ * the route shape that Loop QA already exposes for cross-site iframes.
5
+ */
6
+ export function normalizeReplayEmbedUrl(input: string): string | null {
7
+ let url: URL;
8
+ try {
9
+ url = new URL(input);
10
+ } catch {
11
+ return null;
12
+ }
13
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
14
+
15
+ const parts = url.pathname.split('/').filter(Boolean);
16
+ if (parts[0] === 'projects' && parts.length >= 4) {
17
+ const projectId = decodeURIComponent(parts[1] ?? '');
18
+ const kind = parts[2] === 'explorations' ? 'tasks' : parts[2];
19
+ const resourceId = decodeURIComponent(parts[3] ?? '');
20
+ if (
21
+ projectId &&
22
+ resourceId &&
23
+ (kind === 'tasks' || kind === 'journeys' || kind === 'test-runs')
24
+ ) {
25
+ url.pathname = `/p/${encodeURIComponent(projectId)}/${kind}/${encodeURIComponent(resourceId)}`;
26
+ }
27
+ }
28
+
29
+ return url.toString();
30
+ }
31
+
32
+ export function isReplayQaUrl(input: string): boolean {
33
+ const normalized = normalizeReplayEmbedUrl(input);
34
+ if (!normalized) return false;
35
+ try {
36
+ const url = new URL(normalized);
37
+ return url.pathname.startsWith('/p/') || url.hostname.endsWith('.replay.io');
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
package/src/styles.css CHANGED
@@ -11,12 +11,25 @@
11
11
  --gb-accent-foreground: var(--accent-foreground);
12
12
  --gb-primary: var(--primary);
13
13
  --gb-primary-foreground: var(--primary-foreground);
14
- --gb-code-bg: color-mix(in oklab, var(--muted) 82%, var(--foreground) 8%);
14
+ /* Plain rgba/hex values (no color-mix()/oklch()) so the theme renders
15
+ correctly in older Chromium forks; hosts can override any token. */
16
+ --gb-code-bg: rgba(128, 128, 128, 0.13);
15
17
  --gb-code-fg: var(--foreground);
16
- --gb-info: var(--chart-2, var(--primary));
17
- --gb-success: var(--chart-1, var(--primary));
18
- --gb-warning: var(--chart-3, var(--primary));
19
- --gb-danger: var(--destructive);
18
+
19
+ /* Semantic hint palette — solid color plus precomputed tints. Matches the
20
+ editor palette in @brett_lamy/docstream-editor/styles.css. */
21
+ --gb-info: #3b82f6;
22
+ --gb-info-border: rgba(59, 130, 246, 0.45);
23
+ --gb-info-bg: rgba(59, 130, 246, 0.1);
24
+ --gb-success: #22c55e;
25
+ --gb-success-border: rgba(34, 197, 94, 0.45);
26
+ --gb-success-bg: rgba(34, 197, 94, 0.1);
27
+ --gb-warning: #f59e0b;
28
+ --gb-warning-border: rgba(245, 158, 11, 0.5);
29
+ --gb-warning-bg: rgba(245, 158, 11, 0.12);
30
+ --gb-danger: #ef4444;
31
+ --gb-danger-border: rgba(239, 68, 68, 0.45);
32
+ --gb-danger-bg: rgba(239, 68, 68, 0.1);
20
33
  }
21
34
 
22
35
  .docs-article {
@@ -130,8 +143,8 @@
130
143
  }
131
144
 
132
145
  .docs-hint-info {
133
- border-color: color-mix(in oklab, var(--gb-info) 45%, transparent);
134
- background: color-mix(in oklab, var(--gb-info) 12%, transparent);
146
+ border-color: var(--gb-info-border);
147
+ background: var(--gb-info-bg);
135
148
  }
136
149
 
137
150
  .docs-hint-info .docs-hint-icon {
@@ -139,8 +152,8 @@
139
152
  }
140
153
 
141
154
  .docs-hint-success {
142
- border-color: color-mix(in oklab, var(--gb-success) 45%, transparent);
143
- background: color-mix(in oklab, var(--gb-success) 12%, transparent);
155
+ border-color: var(--gb-success-border);
156
+ background: var(--gb-success-bg);
144
157
  }
145
158
 
146
159
  .docs-hint-success .docs-hint-icon {
@@ -148,8 +161,8 @@
148
161
  }
149
162
 
150
163
  .docs-hint-warning {
151
- border-color: color-mix(in oklab, var(--gb-warning) 48%, transparent);
152
- background: color-mix(in oklab, var(--gb-warning) 14%, transparent);
164
+ border-color: var(--gb-warning-border);
165
+ background: var(--gb-warning-bg);
153
166
  }
154
167
 
155
168
  .docs-hint-warning .docs-hint-icon {
@@ -157,8 +170,8 @@
157
170
  }
158
171
 
159
172
  .docs-hint-danger {
160
- border-color: color-mix(in oklab, var(--gb-danger) 45%, transparent);
161
- background: color-mix(in oklab, var(--gb-danger) 12%, transparent);
173
+ border-color: var(--gb-danger-border);
174
+ background: var(--gb-danger-bg);
162
175
  }
163
176
 
164
177
  .docs-hint-danger .docs-hint-icon {
@@ -495,23 +508,23 @@
495
508
  }
496
509
 
497
510
  .oas-method-get {
498
- background: color-mix(in oklab, var(--gb-success) 18%, transparent);
511
+ background: var(--gb-success-bg);
499
512
  color: var(--gb-success);
500
513
  }
501
514
 
502
515
  .oas-method-post {
503
- background: color-mix(in oklab, var(--gb-info) 18%, transparent);
516
+ background: var(--gb-info-bg);
504
517
  color: var(--gb-info);
505
518
  }
506
519
 
507
520
  .oas-method-put,
508
521
  .oas-method-patch {
509
- background: color-mix(in oklab, var(--gb-warning) 20%, transparent);
522
+ background: var(--gb-warning-bg);
510
523
  color: var(--gb-warning);
511
524
  }
512
525
 
513
526
  .oas-method-delete {
514
- background: color-mix(in oklab, var(--gb-danger) 16%, transparent);
527
+ background: var(--gb-danger-bg);
515
528
  color: var(--gb-danger);
516
529
  }
517
530
 
@@ -602,18 +615,18 @@
602
615
  }
603
616
 
604
617
  .oas-code-2 {
605
- background: color-mix(in oklab, var(--gb-success) 18%, transparent);
618
+ background: var(--gb-success-bg);
606
619
  color: var(--gb-success);
607
620
  }
608
621
 
609
622
  .oas-code-3 {
610
- background: color-mix(in oklab, var(--gb-warning) 20%, transparent);
623
+ background: var(--gb-warning-bg);
611
624
  color: var(--gb-warning);
612
625
  }
613
626
 
614
627
  .oas-code-4,
615
628
  .oas-code-5 {
616
- background: color-mix(in oklab, var(--gb-danger) 16%, transparent);
629
+ background: var(--gb-danger-bg);
617
630
  color: var(--gb-danger);
618
631
  }
619
632
 
@@ -715,7 +728,7 @@
715
728
 
716
729
  .hljs-operator,
717
730
  .hljs-punctuation {
718
- color: color-mix(in oklab, var(--gb-code-fg) 78%, var(--gb-primary));
731
+ color: var(--gb-code-fg);
719
732
  }
720
733
 
721
734
  .hljs-deletion {
@@ -725,3 +738,77 @@
725
738
  .hljs-addition {
726
739
  color: var(--gb-success);
727
740
  }
741
+
742
+ .docs-react-demo {
743
+ overflow: hidden;
744
+ border: 1px solid var(--gb-border);
745
+ border-radius: 12px;
746
+ background: var(--gb-panel);
747
+ }
748
+
749
+ .docs-react-demo-header {
750
+ display: flex;
751
+ align-items: center;
752
+ gap: 10px;
753
+ min-height: 42px;
754
+ padding: 8px 12px;
755
+ border-bottom: 1px solid var(--gb-border);
756
+ color: var(--gb-panel-foreground);
757
+ font-size: 13px;
758
+ font-weight: 550;
759
+ }
760
+
761
+ .docs-react-demo-status {
762
+ margin-left: auto;
763
+ color: var(--gb-muted-foreground);
764
+ font-size: 11px;
765
+ text-transform: uppercase;
766
+ letter-spacing: 0.08em;
767
+ }
768
+
769
+ .docs-react-demo-status-ready {
770
+ color: var(--gb-success);
771
+ }
772
+
773
+ .docs-react-demo-status-error {
774
+ color: var(--gb-danger);
775
+ }
776
+
777
+ .docs-react-demo-run {
778
+ border: 1px solid var(--gb-border);
779
+ border-radius: 6px;
780
+ padding: 4px 9px;
781
+ color: var(--gb-panel-foreground);
782
+ font-size: 12px;
783
+ cursor: pointer;
784
+ }
785
+
786
+ .docs-react-demo-run:disabled {
787
+ cursor: wait;
788
+ opacity: 0.55;
789
+ }
790
+
791
+ .docs-react-demo-frame,
792
+ .docs-react-demo-placeholder {
793
+ display: block;
794
+ width: 100%;
795
+ border: 0;
796
+ background: #fff;
797
+ }
798
+
799
+ .docs-react-demo-placeholder {
800
+ display: grid;
801
+ place-items: center;
802
+ color: #64748b;
803
+ font-size: 13px;
804
+ }
805
+
806
+ .docs-react-demo-error {
807
+ margin: 0;
808
+ min-height: 100px;
809
+ overflow: auto;
810
+ padding: 14px;
811
+ color: var(--gb-danger);
812
+ background: var(--gb-code-bg);
813
+ white-space: pre-wrap;
814
+ }
@@ -0,0 +1,62 @@
1
+ import type { CSSProperties } from "react"
2
+
3
+ export interface VideoEmbedProps {
4
+ /** A direct media asset or a public player URL. */
5
+ src: string
6
+ title?: string
7
+ className?: string
8
+ /** CSS aspect-ratio for iframe embeds. Defaults to 16 / 9. */
9
+ aspectRatio?: CSSProperties["aspectRatio"]
10
+ controls?: boolean
11
+ autoplay?: boolean
12
+ loop?: boolean
13
+ muted?: boolean
14
+ poster?: string
15
+ allowFullScreen?: boolean
16
+ }
17
+
18
+ function isDirectVideo(src: string): boolean {
19
+ return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(src)
20
+ }
21
+
22
+ export function VideoEmbed({
23
+ src,
24
+ title = "Video",
25
+ className,
26
+ aspectRatio = "16 / 9",
27
+ controls = true,
28
+ autoplay = false,
29
+ loop = false,
30
+ muted = false,
31
+ poster,
32
+ allowFullScreen = true,
33
+ }: VideoEmbedProps) {
34
+ const mediaClass = `video-embed__media ${className ?? ""}`.trim()
35
+ if (isDirectVideo(src)) {
36
+ return (
37
+ <video
38
+ className={mediaClass}
39
+ src={src}
40
+ title={title}
41
+ controls={controls}
42
+ autoPlay={autoplay}
43
+ loop={loop}
44
+ muted={muted}
45
+ poster={poster}
46
+ playsInline
47
+ />
48
+ )
49
+ }
50
+ return (
51
+ <iframe
52
+ className={mediaClass}
53
+ src={src}
54
+ title={title}
55
+ loading="lazy"
56
+ allow="autoplay; fullscreen"
57
+ allowFullScreen={allowFullScreen}
58
+ style={{ aspectRatio }}
59
+ referrerPolicy="strict-origin-when-cross-origin"
60
+ />
61
+ )
62
+ }
@@ -0,0 +1,2 @@
1
+ export { VideoEmbed } from "./VideoEmbed"
2
+ export type { VideoEmbedProps } from "./VideoEmbed"