@crtobiasdelsud/portal-ui 1.1.2 → 1.1.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crtobiasdelsud/portal-ui",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Componentes compartidos entre el portal (Next) y el CMS (Vite) — widgets, views, providers para adapters y article pool.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -57,7 +57,9 @@ function loadTwitterWidgets() {
57
57
 
58
58
  function EmbedIframe({ src, service, style }) {
59
59
  const safeSrc = sanitizeResourceUrl(src)
60
- const ref = useRef(null)
60
+ const wrapRef = useRef(null) // placeholder observado por IO
61
+ const contentRef = useRef(null) // contenedor real del tweet/iframe (post-inView)
62
+ const [inView, setInView] = useState(false)
61
63
  const [autoHeight, setAutoHeight] = useState(null)
62
64
  const [twitterDone, setTwitterDone] = useState(false)
63
65
  if (!safeSrc) return null
@@ -68,10 +70,32 @@ function EmbedIframe({ src, service, style }) {
68
70
  // solo → altura exacta, sin blanco ni scroll.
69
71
  const tweetId = service === 'twitter' ? (String(src).match(/[?&]id=(\d+)/)?.[1] || null) : null
70
72
 
73
+ // Lazy load: difiere TODO (iframe src, widgets.js de Twitter, listener de
74
+ // Instagram MEASURE) hasta que el embed esté cerca del viewport. Sin esto,
75
+ // tweets / instagram / youtube debajo del fold cargan junto con la nota y
76
+ // pegan al LCP. El rootMargin de 400px precarga antes que el usuario llegue.
71
77
  useEffect(() => {
72
- if (!tweetId) return
78
+ if (inView) return
79
+ if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
80
+ const el = wrapRef.current
81
+ if (!el) return
82
+ const io = new IntersectionObserver(
83
+ (entries) => {
84
+ if (entries.some(e => e.isIntersecting)) {
85
+ setInView(true)
86
+ io.disconnect()
87
+ }
88
+ },
89
+ { rootMargin: '400px 0px' },
90
+ )
91
+ io.observe(el)
92
+ return () => io.disconnect()
93
+ }, [inView])
94
+
95
+ useEffect(() => {
96
+ if (!inView || !tweetId) return
73
97
  let cancelled = false
74
- const el = ref.current
98
+ const el = contentRef.current
75
99
  if (!el) return
76
100
  loadTwitterWidgets().then((twttr) => {
77
101
  if (cancelled || !twttr?.widgets || !el) return
@@ -81,16 +105,16 @@ function EmbedIframe({ src, service, style }) {
81
105
  .then(() => { if (!cancelled) setTwitterDone(true) })
82
106
  })
83
107
  return () => { cancelled = true }
84
- }, [tweetId])
108
+ }, [inView, tweetId])
85
109
 
86
110
  useEffect(() => {
87
111
  // Servicios que se sabe que postean MEASURE: Instagram. (Twitter se maneja
88
112
  // arriba con widgets.js. TikTok no postea altura.)
89
- if (service !== 'instagram') return
113
+ if (!inView || service !== 'instagram') return
90
114
 
91
115
  function handleMessage(event) {
92
116
  if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
93
- if (event.source !== ref.current?.contentWindow) return
117
+ if (event.source !== contentRef.current?.contentWindow) return
94
118
 
95
119
  let data = event.data
96
120
  if (typeof data === 'string') {
@@ -102,18 +126,24 @@ function EmbedIframe({ src, service, style }) {
102
126
 
103
127
  window.addEventListener('message', handleMessage)
104
128
  return () => window.removeEventListener('message', handleMessage)
105
- }, [service])
129
+ }, [inView, service])
130
+
131
+ // Placeholder antes de inView: ocupa el lugar (height del callsite) pero no
132
+ // dispara ningún fetch. Reserva el espacio → no genera CLS al "hidratar".
133
+ if (!inView) {
134
+ return <div ref={wrapRef} style={style} aria-hidden="true" />
135
+ }
106
136
 
107
137
  // Twitter via widget: contenedor que crece con el tweet. `minHeight` es solo
108
138
  // placeholder anti-CLS hasta que widgets.js mida; después el widget fija su
109
139
  // propia altura exacta. Si no hubo tweetId (URL rara), cae al iframe normal.
110
140
  if (tweetId) {
111
141
  const { height, ...rest } = style || {}
112
- return <div ref={ref} style={{ ...rest, minHeight: twitterDone ? undefined : height }} />
142
+ return <div ref={contentRef} style={{ ...rest, minHeight: twitterDone ? undefined : height }} />
113
143
  }
114
144
 
115
145
  const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
116
- return <iframe ref={ref} src={safeSrc} style={finalStyle} allowFullScreen />
146
+ return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
117
147
  }
118
148
 
119
149
  // Fixed class names for AMP (no CSS Modules hashing)
@@ -38,16 +38,39 @@ function resolveEmbedSrc({ service, embed, source }) {
38
38
 
39
39
  function EmbedIframe({ src, service, style }) {
40
40
  const safeSrc = sanitizeResourceUrl(src)
41
- const ref = useRef(null)
41
+ const wrapRef = useRef(null) // placeholder observado por IO
42
+ const contentRef = useRef(null) // iframe real (post-inView)
43
+ const [inView, setInView] = useState(false)
42
44
  const [autoHeight, setAutoHeight] = useState(null)
43
45
  if (!safeSrc) return null
44
46
 
47
+ // Lazy load: difiere el iframe src y el listener de Instagram hasta que el
48
+ // embed esté cerca del viewport (400px de margen para precargar). Sin esto
49
+ // los embeds abajo del fold cargan junto con la nota y suben el LCP.
45
50
  useEffect(() => {
46
- if (service !== 'instagram') return
51
+ if (inView) return
52
+ if (typeof IntersectionObserver === 'undefined') { setInView(true); return }
53
+ const el = wrapRef.current
54
+ if (!el) return
55
+ const io = new IntersectionObserver(
56
+ (entries) => {
57
+ if (entries.some(e => e.isIntersecting)) {
58
+ setInView(true)
59
+ io.disconnect()
60
+ }
61
+ },
62
+ { rootMargin: '400px 0px' },
63
+ )
64
+ io.observe(el)
65
+ return () => io.disconnect()
66
+ }, [inView])
67
+
68
+ useEffect(() => {
69
+ if (!inView || service !== 'instagram') return
47
70
 
48
71
  function handleMessage(event) {
49
72
  if (typeof event.origin !== 'string' || !event.origin.includes('instagram.com')) return
50
- if (event.source !== ref.current?.contentWindow) return
73
+ if (event.source !== contentRef.current?.contentWindow) return
51
74
 
52
75
  let data = event.data
53
76
  if (typeof data === 'string') {
@@ -59,10 +82,15 @@ function EmbedIframe({ src, service, style }) {
59
82
 
60
83
  window.addEventListener('message', handleMessage)
61
84
  return () => window.removeEventListener('message', handleMessage)
62
- }, [service])
85
+ }, [inView, service])
86
+
87
+ // Placeholder antes de inView: ocupa el lugar pero no dispara fetch del iframe.
88
+ if (!inView) {
89
+ return <div ref={wrapRef} style={style} aria-hidden="true" />
90
+ }
63
91
 
64
92
  const finalStyle = autoHeight != null ? { ...style, height: autoHeight } : style
65
- return <iframe ref={ref} src={safeSrc} style={finalStyle} allowFullScreen />
93
+ return <iframe ref={contentRef} src={safeSrc} style={finalStyle} allowFullScreen />
66
94
  }
67
95
 
68
96
  // Fixed class names for AMP (no CSS Modules hashing)
@@ -1,12 +1,21 @@
1
- // Helper compartido por cabezales, hero, bajada, etc. La volanta se renderiza
2
- // inline con el título separada por ". " (en cabezales el espacio lo da el
3
- // `margin-right` del CSS; en bajada/leeAdemas/loQueSeLee es un literal en JSX).
4
- // Si quien edita la nota ya cerró la volanta con un signo (".", "!", "?", "…"),
5
- // no agregamos otro punto — devolvemos el texto tal cual.
1
+ // Helper compartido por cabezales, hero, bajada, etc. Normaliza la volanta
2
+ // para que el render con el título siempre sea: «volanta + punto + espacio + titulo».
3
+ // El espacio entre volanta y titulo lo provee el callsite (literal " " en JSX,
4
+ // o `margin-right` en CSS).
5
+ //
6
+ // Normalización:
7
+ // 1. Quita whitespace trailing ("postgres " → "postgres").
8
+ // 2. Colapsa whitespace entre la palabra y un signo final ("postgres ." → "postgres.").
9
+ // 3. Si no termina en signo de cierre (.!?…), agrega ".".
10
+ //
11
+ // Resultado: "Política." / "Política." (sin doble) / "EXCLUSIVO!" / "" .
6
12
 
7
- const SENTENCE_END = /[.!?…]\s*$/
13
+ const SENTENCE_END = /[.!?…]$/
8
14
 
9
15
  export function volantaWithStop(volanta) {
10
16
  if (!volanta) return ''
11
- return SENTENCE_END.test(volanta) ? volanta : `${volanta}.`
17
+ // Colapsa "palabra <space> . <space>" "palabra.", y limpia trailing.
18
+ const clean = volanta.replace(/\s+([.!?…])\s*$/, '$1').trimEnd()
19
+ if (!clean) return ''
20
+ return SENTENCE_END.test(clean) ? clean : `${clean}.`
12
21
  }