libreria-astro-lefebvre 0.1.36 → 0.1.38

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/carbins/Author_2025_Algarve.ts +3 -3
  3. package/src/carbins/Cabecera_2025_Barcelona.ts +1 -1
  4. package/src/carbins/SEO_Schema_Page.ts +16 -0
  5. package/src/carbins/Texto_2026_Alicante.ts +3 -3
  6. package/src/components/Astro/Author_2025_Algarve.astro +1 -14
  7. package/src/components/Astro/Cabecera_2025_Barcelona.astro +6 -5
  8. package/src/components/Astro/Card_2025_Malta.astro +7 -1
  9. package/src/components/Astro/Contenido_2025_Cordoba.astro +1 -14
  10. package/src/components/Astro/Contenido_2025_Granada.astro +1 -14
  11. package/src/components/Astro/Contenido_2025_Montevideo.astro +3 -3
  12. package/src/components/Astro/Contenido_2026_Cabra.astro +6 -2
  13. package/src/components/Astro/Contenido_2026_Estocolmo.astro +6 -2
  14. package/src/components/Astro/Contenido_2026_Jaen.astro +0 -12
  15. package/src/components/Astro/Contenido_2026_Michigan.astro +6 -1
  16. package/src/components/Astro/Contenido_2026_Orcasitas.astro +6 -2
  17. package/src/components/Astro/Contenido_2026_Oslo.astro +6 -0
  18. package/src/components/Astro/Contenido_2026_Ubeda.astro +7 -1
  19. package/src/components/Astro/Contenido_2026_Yakarta.astro +6 -2
  20. package/src/components/Astro/Galeria_2026_Segorbe.astro +6 -2
  21. package/src/components/Astro/Indice_2025_Taiwan.astro +6 -1
  22. package/src/components/Astro/SEO_Schema_Page.astro +95 -49
  23. package/src/components/Astro/Tabla_2026_Cadiz.astro +7 -1
  24. package/src/components/Astro/Texto_2026_Alicante.astro +1 -13
  25. package/src/components/Astro/Titulo_2025_Algeciras.astro +1 -14
  26. package/src/components/Astro/Video_2025_Valencia.astro +6 -2
  27. package/src/lib/functions.js +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libreria-astro-lefebvre",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Librería de componentes Astro, React y Vue para Lefebvre",
5
5
  "author": "Equipo web desarrollo Lefebvre",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ export const metadata: ComponentMetadata = {
4
4
  component_name: 'Author_2025_Algarve',
5
5
  category: 'Contenido de Imagen',
6
6
  name: 'Ficha de autor con imagen y tiempo de lectura 2025',
7
- description: 'Ficha compacta de autor para cabeceras de artículo: imagen a la izquierda (con border-radius configurable), nombre subrayado y profesión a la derecha, y bloque opcional de "tiempo de lectura" al final. Incluye structured data schema.org. Para la corporativa de Lefebvre',
7
+ description: 'Ficha compacta de autor para cabeceras de artículo: imagen a la izquierda (con border-radius configurable), nombre subrayado y profesión a la derecha, y bloque opcional de "tiempo de lectura" al final. Para la corporativa de Lefebvre',
8
8
  framework: 'Astro',
9
9
  priority: 1,
10
10
  tags: ['autor', 'imagen', 'blog', 'cabecera', 'tiempo'],
@@ -12,7 +12,7 @@ export const metadata: ComponentMetadata = {
12
12
  {
13
13
  name: 'nameAuthor',
14
14
  type: 'text',
15
- help: 'Nombre del autor. Se renderiza como párrafo subrayado (Inter 16px color #262626) y admite HTML (set:html). También se usa en el structured data (schema.org) como "name". Conviene que ocupe una sola línea',
15
+ help: 'Nombre del autor. Se renderiza como párrafo subrayado (Inter 16px color #262626) y admite HTML (set:html). Conviene que ocupe una sola línea',
16
16
  label: 'Nombre del autor',
17
17
  mandatory: false,
18
18
  example_value: 'María García López'
@@ -28,7 +28,7 @@ export const metadata: ComponentMetadata = {
28
28
  {
29
29
  name: 'descriptionAuthor',
30
30
  type: 'text',
31
- help: 'Profesión o descripción del autor. Se renderiza como párrafo (Inter 14px color #363942) debajo del nombre y admite HTML (set:html). También se usa en el structured data como "description". Conviene que ocupe una sola línea',
31
+ help: 'Profesión o descripción del autor. Se renderiza como párrafo (Inter 14px color #363942) debajo del nombre y admite HTML (set:html). Conviene que ocupe una sola línea',
32
32
  label: 'Descripción del autor',
33
33
  mandatory: false,
34
34
  example_value: 'Abogada especialista en Derecho Fiscal con 15 años de experiencia'
@@ -50,7 +50,7 @@ export const metadata: ComponentMetadata = {
50
50
  {
51
51
  name: 'siteUrl',
52
52
  type: 'text',
53
- help: 'Origen público del sitio (protocolo + host, sin barra final, p. ej. "https://lefebvre.es"). Se usa para las URLs absolutas del BreadcrumbList en el structured data SEO. Necesario en SSR tras un proxy, donde Astro.url resuelve al host interno (localhost). Si se deja vacío, se usa Astro.site o el host de la petición',
53
+ help: 'Origen público del sitio (protocolo + host, sin barra final, p. ej. "https://lefebvre.es"). Se usa para las URLs absolutas de los niveles intermedios del BreadcrumbList en el structured data SEO. Necesario en SSR tras un proxy, donde Astro.url resuelve al host interno (localhost). Si se deja vacío, se usa Astro.site o el host de la petición',
54
54
  label: 'URL del sitio',
55
55
  mandatory: false,
56
56
  example_value: ''
@@ -149,6 +149,22 @@ export const metadata: ComponentMetadata = {
149
149
  mandatory: false,
150
150
  example_value: 'https://www.lefebvre.es/#organization'
151
151
  },
152
+ {
153
+ name: 'websiteId',
154
+ type: 'text',
155
+ help: '@id del nodo WebSite del sitio (el del @graph del Layout). Si se proporciona, el WebPage de la página añade isPartOf: { "@id": ... } enlazando la página con el sitio/organización.',
156
+ label: '@id del WebSite (isPartOf)',
157
+ mandatory: false,
158
+ example_value: 'https://genia-l.lefebvre.es/#website'
159
+ },
160
+ {
161
+ name: 'breadcrumbId',
162
+ type: 'text',
163
+ help: '@id de la BreadcrumbList de la página (el que emite Cabecera_2025_Barcelona, = {canonical}#breadcrumb). Si se proporciona, el WebPage añade breadcrumb: { "@id": ... }. No pasarlo si la página no tiene breadcrumb (evita referencia colgante).',
164
+ label: '@id del breadcrumb',
165
+ mandatory: false,
166
+ example_value: ''
167
+ },
152
168
  {
153
169
  name: 'keywords',
154
170
  type: 'text',
@@ -4,7 +4,7 @@ export const metadata: ComponentMetadata = {
4
4
  component_name: 'Texto_2026_Alicante',
5
5
  category: 'Contenido de Texto',
6
6
  name: 'Bloque centrado con icono circular, título, descripción y botón CTA 2026',
7
- description: 'Bloque de contenido centrado con cuatro elementos verticales: (1) icono SVG fijo (campana de notificación azul) dentro de un chip circular con fondo #F2F3F8 y padding de 12px; (2) título h3 en Poppins 20px / 24px en desktop semibold; (3) descripción en Inter 16px; (4) botón CTA primario con fondo azul #2134F1, hover invertido (fondo blanco, texto azul). Todos los elementos (icono, descripción y botón) son ocultables independientemente. El título se renderiza siempre si tiene valor. Genera datos estructurados schema.org WebPageElement con title y description',
7
+ description: 'Bloque de contenido centrado con cuatro elementos verticales: (1) icono SVG fijo (campana de notificación azul) dentro de un chip circular con fondo #F2F3F8 y padding de 12px; (2) título h3 en Poppins 20px / 24px en desktop semibold; (3) descripción en Inter 16px; (4) botón CTA primario con fondo azul #2134F1, hover invertido (fondo blanco, texto azul). Todos los elementos (icono, descripción y botón) son ocultables independientemente. El título se renderiza siempre si tiene valor',
8
8
  framework: 'Astro',
9
9
  priority: 1,
10
10
  tags: ['texto', 'icono', 'cta', 'boton', 'centrado'],
@@ -12,7 +12,7 @@ export const metadata: ComponentMetadata = {
12
12
  {
13
13
  name: 'title',
14
14
  type: 'text',
15
- help: 'Título principal del bloque (se renderiza como h3 en Poppins 20px en móvil / 24px en desktop, semibold). Admite HTML. También se inyecta en los datos estructurados (schema.org) como nombre del WebPageElement. Si está vacío, el h3 se renderiza vacío',
15
+ help: 'Título principal del bloque (se renderiza como h3 en Poppins 20px en móvil / 24px en desktop, semibold). Admite HTML. Si está vacío, el h3 se renderiza vacío',
16
16
  label: 'Texto del título',
17
17
  mandatory: false,
18
18
  example_value: '¿Quieres saber más sobre GenIA-L?'
@@ -20,7 +20,7 @@ export const metadata: ComponentMetadata = {
20
20
  {
21
21
  name: 'description',
22
22
  type: 'text',
23
- help: 'Descripción que aparece debajo del título (Inter 16px, line-height 24px). Admite HTML. Solo se renderiza si "Mostrar descripción" está activado. También se inyecta en los datos estructurados (schema.org) como descripción del WebPageElement (con etiquetas HTML eliminadas automáticamente)',
23
+ help: 'Descripción que aparece debajo del título (Inter 16px, line-height 24px). Admite HTML. Solo se renderiza si "Mostrar descripción" está activado',
24
24
  label: 'Texto de la descripción',
25
25
  mandatory: false,
26
26
  example_value: 'Solicita una demo personalizada y descubre cómo la IA jurídica puede transformar tu despacho.'
@@ -36,16 +36,6 @@ const sizeClassMap: Record<number, string> = {
36
36
  const sizeClass = sizeClassMap[imageSize] ?? "max-w-[240px] max-h-[240px]";
37
37
 
38
38
 
39
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
40
-
41
- const structuredData = (nameAuthor || descriptionAuthor) ? `<script type="application/ld+json">
42
- {
43
- "@context": "https://schema.org",
44
- "@type": "WebPageElement",
45
- "name": "${escapeJson(nameAuthor)}"${descriptionAuthor ? `,
46
- "description": "${escapeJson(descriptionAuthor)}"` : ''}
47
- }
48
- </script>` : '';
49
39
 
50
40
  ---
51
41
 
@@ -91,7 +81,4 @@ const structuredData = (nameAuthor || descriptionAuthor) ? `<script type="applic
91
81
  </div>
92
82
  {minutes && <Tiempo_2025_Londres minutes={minutes} />}
93
83
  </div>
94
- )}
95
-
96
-
97
- <Fragment set:html={structuredData} />
84
+ )}
@@ -7,12 +7,10 @@ const {
7
7
  siteUrl = "",
8
8
  } = Astro.props;
9
9
 
10
- // Origen absoluto para el JSON-LD de breadcrumbs. En SSR tras un proxy, Astro.url resuelve
11
- // al host interno (localhost), así que se prioriza `siteUrl` (prop) y `Astro.site` (config).
10
+ // Origen absoluto para las URLs de los niveles INTERMEDIOS del breadcrumb (otras páginas).
11
+ // En SSR tras un proxy, Astro.url resuelve al host interno, así que se prioriza `siteUrl`/`Astro.site`.
12
12
  const origin = (siteUrl || Astro.site?.href || Astro.url.origin).replace(/\/+$/, '');
13
13
 
14
- const currentUrl = new URL(subdirectory + Astro.url.pathname, origin).href;
15
-
16
14
  const homeUrl = new URL(subdirectory + '/', origin).href;
17
15
 
18
16
  const toAbsoluteUrl = (url: string) => {
@@ -27,12 +25,15 @@ const {
27
25
  const allBreadcrumbItems = [
28
26
  { name: homeLabel, id: homeUrl },
29
27
  ...items.map((item: { text: string; url: string }) => ({ name: item.text, id: toAbsoluteUrl(item.url) })),
30
- { name: text, id: currentUrl },
28
+ // La página actual referencia el nodo WebPage por @id RELATIVO (#webpage), el mismo que
29
+ // define SEO_Schema_Page → ambos bloques resuelven al MISMO nodo sin conocer la URL.
30
+ { name: text, id: '#webpage' },
31
31
  ];
32
32
 
33
33
  const breadcrumbStructuredData = {
34
34
  "@context": "https://schema.org",
35
35
  "@type": "BreadcrumbList",
36
+ "@id": '#breadcrumb',
36
37
  "itemListElement": allBreadcrumbItems.map((item, i) => ({
37
38
  "@type": "ListItem",
38
39
  "position": i + 1,
@@ -5,16 +5,22 @@ interface MaltaItem {
5
5
  link?: string;
6
6
  }
7
7
 
8
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
9
+
8
10
  const {
9
11
  items = []
10
12
  }: { items?: MaltaItem[] } = Astro.props;
11
13
 
14
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
15
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
16
+
12
17
  const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
13
18
 
14
19
  const structuredData = `<script type="application/ld+json">
15
20
  {
16
21
  "@context": "https://schema.org",
17
- "@type": "ItemList",
22
+ "@type": "ItemList",${pageId ? `
23
+ "isPartOf": { "@id": "${pageId}" },` : ''}
18
24
  "numberOfItems": ${items.length},
19
25
  "itemListElement": [${items.map((item: MaltaItem, i: number) => `{
20
26
  "@type": "ListItem",
@@ -9,17 +9,6 @@ const {
9
9
  flexOrientationIcoText = "flex-row",
10
10
  } = Astro.props;
11
11
 
12
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
13
-
14
- const structuredData = (title || description) ? `<script type="application/ld+json">
15
- {
16
- "@context": "https://schema.org",
17
- "@type": "WebPageElement",
18
- "name": "${escapeJson(title)}"${description ? `,
19
- "description": "${escapeJson(description)}"` : ''}
20
- }
21
- </script>` : '';
22
-
23
12
  ---
24
13
 
25
14
 
@@ -99,6 +88,4 @@ const structuredData = (title || description) ? `<script type="application/ld+js
99
88
  }
100
89
  <p class="text-[#363942] text-center">{description}</p>
101
90
  </div>
102
- </div>
103
-
104
- <Fragment set:html={structuredData} />
91
+ </div>
@@ -10,17 +10,6 @@ const {
10
10
  widthBlock = "lg:w-1/4 md:w-2/4", /* lg:w-1/3 */
11
11
  } = Astro.props;
12
12
 
13
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
14
-
15
- const structuredData = (title || description) ? `<script type="application/ld+json">
16
- {
17
- "@context": "https://schema.org",
18
- "@type": "WebPageElement",
19
- "name": "${escapeJson(title)}"${description ? `,
20
- "description": "${escapeJson(description)}"` : ''}
21
- }
22
- </script>` : '';
23
-
24
13
  ---
25
14
 
26
15
 
@@ -202,6 +191,4 @@ const structuredData = (title || description) ? `<script type="application/ld+js
202
191
  }
203
192
  <p class="text-[#363942]" set:html={description}></p>
204
193
  </div>
205
- </div>
206
-
207
- <Fragment set:html={structuredData} />
194
+ </div>
@@ -15,7 +15,7 @@ const {
15
15
  const imageUrl = extractImageUrl(image);
16
16
  ---
17
17
 
18
- <a href={link} class="w-full flex flex-col cursor-pointer px-0">
18
+ <div class="w-full flex flex-col px-0">
19
19
  <article class="w-full md:max-w-[400px] h-full flex flex-col rounded-lg border border-gray-200 items-center shadow-article hover:bg-gradient-to-r from-[#001978] via-[#2134F1] to-[#F81BBD] transition-all duration-300 p-px">
20
20
  <div class="w-full bg-white mt-px rounded-tl-[7px] rounded-tr-[7px] h-full">
21
21
  <div class="w-full">
@@ -25,7 +25,7 @@ const imageUrl = extractImageUrl(image);
25
25
  {tag && tag !== '' && <Titulo_2025_Santorini description={tag} />}
26
26
  <div class="mt-2 flex flex-col items-start gap-2 flex-grow">
27
27
  <div class="flex flex-col text-[#262626] h-full">
28
- <h3 class="font-poppins text-xl font-semibold text-[#262626] overflow-hidden text-ellipsis mb-2">{title}</h3>
28
+ <a href={link} class="cursor-pointer no-underline"><h3 class="font-poppins text-xl font-semibold text-[#262626] overflow-hidden text-ellipsis mb-2">{title}</h3></a>
29
29
  <p class="font-inter text-base font-normal text-[#363942] overflow-hidden text-ellipsis mb-2">{description}</p>
30
30
  {author && author !== '' &&
31
31
  <p class="font-inter text-base font-normal mt-6 text-[#363942] text-left"><span class="text-[#363942]">Por {author}</span></p>
@@ -36,7 +36,7 @@ const imageUrl = extractImageUrl(image);
36
36
  </div>
37
37
  <div class="bg-red-100 h-3"></div>
38
38
  </article>
39
- </a>
39
+ </div>
40
40
 
41
41
  <style>
42
42
  .shadow-article {
@@ -2,12 +2,15 @@
2
2
 
3
3
  import Contenido_2026_Menorca from './Contenido_2026_Menorca.astro';
4
4
  import Titulo_2025_Algeciras from './Titulo_2025_Algeciras.astro';
5
- import { extractImageUrl } from '../../lib/functions.js';
5
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
6
6
 
7
7
  const {
8
8
  items = [],
9
9
  } = Astro.props;
10
10
 
11
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
12
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
13
+
11
14
  type TabPoint = { title: string; description: string; icon: string; showIco: boolean };
12
15
  type Tab = { name: string; buttonUrl: string; image: string; points: TabPoint[] };
13
16
 
@@ -38,7 +41,8 @@ const structuredData = `<script type="application/ld+json">
38
41
  "@type": "WebPageElement",
39
42
  "name": "${escapeJson(tabs.map((t: Tab) => t.name).filter(Boolean).join(' · '))}",
40
43
  "mainEntity": {
41
- "@type": "ItemList",
44
+ "@type": "ItemList",${pageId ? `
45
+ "isPartOf": { "@id": "${pageId}" },` : ''}
42
46
  "numberOfItems": ${tabs.length},
43
47
  "itemListElement": [${tabs.map((tab: Tab, index: number) => `{
44
48
  "@type": "ListItem",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
 
3
3
  import Contenido_2025_Montevideo from "./Contenido_2025_Montevideo.astro";
4
- import { extractImageUrl } from '../../lib/functions.js';
4
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
5
5
 
6
6
  interface MontevideoItem {
7
7
  link?: string;
@@ -23,12 +23,16 @@ const {
23
23
  showAutor = false,
24
24
  }: { items?: MontevideoItem[]; etiquetaVisible?: "fecha" | "keywords"; showAutor?: boolean } = Astro.props;
25
25
 
26
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
27
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
28
+
26
29
  const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
27
30
 
28
31
  const structuredData = `<script type="application/ld+json">
29
32
  {
30
33
  "@context": "https://schema.org",
31
- "@type": "ItemList",
34
+ "@type": "ItemList",${pageId ? `
35
+ "isPartOf": { "@id": "${pageId}" },` : ''}
32
36
  "numberOfItems": ${items.length},
33
37
  "itemListElement": [${items.map((item: MontevideoItem, i: number) => `{
34
38
  "@type": "ListItem",
@@ -17,16 +17,6 @@ const {
17
17
 
18
18
  import RRSS_2025_Pisa from "./RRSS_2025_Pisa.astro";
19
19
 
20
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
21
-
22
- const structuredData = (title || description) ? `<script type="application/ld+json">
23
- {
24
- "@context": "https://schema.org",
25
- "@type": "WebPageElement",
26
- "name": "${escapeJson(title)}"${description ? `,
27
- "description": "${escapeJson(description)}"` : ''}
28
- }
29
- </script>` : '';
30
20
 
31
21
  ---
32
22
 
@@ -84,8 +74,6 @@ const structuredData = (title || description) ? `<script type="application/ld+js
84
74
  )}
85
75
  </div>
86
76
 
87
- <Fragment set:html={structuredData} />
88
-
89
77
  <style>
90
78
  .moreDescription h2 {
91
79
  color: #262626;
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Formulario_2025_Teruel from './Formulario_2025_Teruel.astro';
3
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
3
4
 
4
5
  const {
5
6
  orientation = 'left',
@@ -11,12 +12,16 @@ const {
11
12
  items
12
13
  } = Astro.props;
13
14
 
15
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
16
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
17
+
14
18
  const randomId = Math.floor(Math.random() * 1000);
15
19
 
16
20
  const structuredData = `<script type="application/ld+json">
17
21
  {
18
22
  "@context": "https://schema.org",
19
- "@type": "ItemList",
23
+ "@type": "ItemList",${pageId ? `
24
+ "isPartOf": { "@id": "${pageId}" },` : ''}
20
25
  "numberOfItems": ${items.length},
21
26
  "itemListElement": [
22
27
  ${items.map((item, index) => `{
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { extractImageUrl } from '../../lib/functions.js';
2
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
3
3
 
4
4
  interface OrcasitasItem {
5
5
  iframeSrc?: string;
@@ -21,6 +21,9 @@ const {
21
21
  slidesToScroll = 1,
22
22
  }: { loadScript?: boolean; items?: OrcasitasItem[]; slidesToShow?: number; slidesToScroll?: number } = Astro.props;
23
23
 
24
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
25
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
26
+
24
27
  const carouselId = "glider-carousel-"+Math.floor(Math.random()*1000);
25
28
  const buttonsId = "glider-carousel-buttons-"+Math.floor(Math.random()*1000);
26
29
  const prevId = "glider-carousel-prev-"+Math.floor(Math.random()*1000);
@@ -56,7 +59,8 @@ const buildImageItem = (item: OrcasitasItem) => {
56
59
  const structuredData = items.length > 0 ? `<script type="application/ld+json">
57
60
  {
58
61
  "@context": "https://schema.org",
59
- "@type": "ItemList",
62
+ "@type": "ItemList",${pageId ? `
63
+ "isPartOf": { "@id": "${pageId}" },` : ''}
60
64
  "numberOfItems": ${items.length},
61
65
  "itemListElement": [${items.map((item: OrcasitasItem, index: number) => `{
62
66
  "@type": "ListItem",
@@ -1,13 +1,19 @@
1
1
  ---
2
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
3
+
2
4
  const {
3
5
  items
4
6
  } = Astro.props;
5
7
 
8
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
9
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
10
+
6
11
  const randomId = Math.floor(Math.random() * 1000);
7
12
 
8
13
  const structuredData = JSON.stringify({
9
14
  "@context": "https://schema.org",
10
15
  "@type": "ItemList",
16
+ ...(pageId ? { isPartOf: { "@id": pageId } } : {}),
11
17
  "numberOfItems": items.length,
12
18
  "itemListElement": items.map((item, index) => ({
13
19
  "@type": "ListItem",
@@ -1,12 +1,18 @@
1
1
  ---
2
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
3
+
2
4
  const {
3
5
  items = [],
4
6
  } = Astro.props;
5
7
 
8
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
9
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
10
+
6
11
  const structuredData = `<script type="application/ld+json">
7
12
  {
8
13
  "@context": "https://schema.org",
9
- "@type": "ItemList",
14
+ "@type": "ItemList",${pageId ? `
15
+ "isPartOf": { "@id": "${pageId}" },` : ''}
10
16
  "numberOfItems": ${items.length},
11
17
  "itemListElement": [
12
18
  ${items.map((item, index) => `{
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { extractImageUrl } from '../../lib/functions.js';
2
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
3
3
 
4
4
  interface VideoItem {
5
5
  name?: string;
@@ -17,6 +17,9 @@ const {
17
17
  items = [],
18
18
  }: { items?: VideoItem[] } = Astro.props;
19
19
 
20
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
21
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
22
+
20
23
  const idAccordion = 'js-acordeon-' + Math.random().toString(36).substring(2, 15);
21
24
 
22
25
  const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
@@ -24,7 +27,8 @@ const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g,
24
27
  const structuredData = items.length > 0 ? `<script type="application/ld+json">
25
28
  {
26
29
  "@context": "https://schema.org",
27
- "@type": "ItemList",
30
+ "@type": "ItemList",${pageId ? `
31
+ "isPartOf": { "@id": "${pageId}" },` : ''}
28
32
  "numberOfItems": ${items.length},
29
33
  "itemListElement": [${items.map((item: VideoItem, index: number) => `{
30
34
  "@type": "ListItem",
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { extractImageUrl } from '../../lib/functions.js';
2
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
3
3
 
4
4
  interface GaleriaItem {
5
5
  image?: string;
@@ -11,6 +11,9 @@ const {
11
11
  items = []
12
12
  }: { columns?: string; items?: GaleriaItem[] } = Astro.props;
13
13
 
14
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
15
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
16
+
14
17
  const lgColsClass = {
15
18
  '2': 'lg:columns-2',
16
19
  '3': 'lg:columns-3',
@@ -24,7 +27,8 @@ const escapeJson = (s = '') => String(s).replace(/<[^>]*>/g, '').replace(/\\/g,
24
27
  const structuredData = `<script type="application/ld+json">
25
28
  {
26
29
  "@context": "https://schema.org",
27
- "@type": "ItemList",
30
+ "@type": "ItemList",${pageId ? `
31
+ "isPartOf": { "@id": "${pageId}" },` : ''}
28
32
  "numberOfItems": ${visibleItems.length},
29
33
  "itemListElement": [${visibleItems.map((item: GaleriaItem, i: number) => `{
30
34
  "@type": "ListItem",
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
2
3
 
3
4
  const {
4
5
  titulo,
@@ -7,6 +8,9 @@ const {
7
8
  siteUrl = '',
8
9
  } = Astro.props;
9
10
 
11
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
12
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
13
+
10
14
  interface IndiceItem {
11
15
  enlace: string;
12
16
  titulo: string;
@@ -27,7 +31,8 @@ const baseUrl = new URL(subdirectory + Astro.url.pathname, origin).href.split('#
27
31
  const structuredData = `<script type="application/ld+json">
28
32
  {
29
33
  "@context": "https://schema.org",
30
- "@type": "ItemList",
34
+ "@type": "ItemList",${pageId ? `
35
+ "isPartOf": { "@id": "${pageId}" },` : ''}
31
36
  "name": "${escapeJson(titulo)}",
32
37
  "numberOfItems": ${indiceItems.length},
33
38
  "itemListElement": [${indiceItems.map((item, i) => `{
@@ -33,6 +33,8 @@ interface Props {
33
33
  publisherLogo?: string;
34
34
  publisherUrl?: string;
35
35
  publisherId?: string;
36
+ websiteId?: string;
37
+ breadcrumbId?: string;
36
38
  keywords?: string;
37
39
  inLanguage?: string;
38
40
  articleSection?: string;
@@ -60,6 +62,8 @@ const {
60
62
  publisherLogo = 'https://assets.lefebvre.es/media/logos-2/svg/lefebvre.svg',
61
63
  publisherUrl = 'https://lefebvre.es',
62
64
  publisherId,
65
+ websiteId,
66
+ breadcrumbId,
63
67
  keywords,
64
68
  inLanguage = 'es',
65
69
  articleSection,
@@ -73,75 +77,117 @@ const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g,
73
77
 
74
78
  const isArticleType = (['Article', 'BlogPosting', 'NewsArticle'] as PageType[]).includes(pageType);
75
79
 
76
- const schema: Record<string, unknown> = {
77
- '@context': 'https://schema.org',
78
- '@type': pageType,
79
- name,
80
- description,
81
- url,
82
- inLanguage,
83
- };
80
+ // @id por-página RELATIVOS de fragmento: cualquier bloque del mismo documento (incluido el HTML
81
+ // inyectado por el render API del CMS) puede referenciarlos sin conocer la URL; Google los resuelve
82
+ // contra la página. Los @id de sitio/cross-página (websiteId, publisherId, authorId/#person) siguen
83
+ // absolutos y se reciben por props.
84
+ const webPageNodeId = '#webpage';
85
+ const primaryImageId = '#primaryimage';
86
+ const articleNodeId = '#article';
84
87
 
85
- if (image) schema.image = image;
86
- if (keywords) schema.keywords = keywords;
88
+ // Nodo ImageObject de la imagen principal (si hay), referenciable por @id.
89
+ const imageNode = image
90
+ ? { '@type': 'ImageObject', '@id': primaryImageId, url: image }
91
+ : null;
87
92
 
88
- if (isArticleType) {
89
- schema.headline = headline || name;
90
- schema.mainEntityOfPage = { '@type': 'WebPage', '@id': url };
91
- if (datePublished) schema.datePublished = datePublished;
92
- if (dateModified) schema.dateModified = dateModified;
93
- if (articleSection) schema.articleSection = articleSection;
94
- if (authorId) {
95
- schema.author = { '@id': authorId };
96
- } else if (authorName) {
97
- const author: Record<string, unknown> = { '@type': authorType, name: authorName };
98
- if (authorUrl) author.url = authorUrl;
99
- if (authorImage && authorType === 'Person') author.image = authorImage;
100
- schema.author = author;
93
+ // Autor: nodo inline CON @id (autocontenido y fusionable entre páginas) si llega authorName;
94
+ // referencia pura { @id } si solo llega authorId (caso "Lefebvre" → #organization global).
95
+ const buildAuthor = (): Record<string, unknown> | null => {
96
+ if (authorName) {
97
+ const node: Record<string, unknown> = { '@type': authorType, name: authorName };
98
+ if (authorId) node['@id'] = authorId;
99
+ if (authorUrl) node.url = authorUrl;
100
+ if (authorImage && authorType === 'Person') node.image = authorImage;
101
+ return node;
101
102
  }
102
- if (publisherId) {
103
- schema.publisher = { '@id': publisherId };
104
- } else {
105
- schema.publisher = {
103
+ if (authorId) return { '@id': authorId };
104
+ return null;
105
+ };
106
+
107
+ const buildPublisher = (): Record<string, unknown> =>
108
+ publisherId
109
+ ? { '@id': publisherId }
110
+ : {
106
111
  '@type': 'Organization',
107
112
  name: publisherName,
108
113
  url: publisherUrl,
109
114
  logo: { '@type': 'ImageObject', url: publisherLogo },
110
115
  };
111
- }
116
+
117
+ // --- Nodo WebPage (siempre). Para tipos artículo el @type es WebPage; si no, el propio pageType.
118
+ const webPageType = isArticleType ? 'WebPage' : pageType;
119
+ const webPage: Record<string, unknown> = {
120
+ '@type': webPageType,
121
+ '@id': webPageNodeId,
122
+ url,
123
+ name,
124
+ inLanguage,
125
+ };
126
+ if (description) webPage.description = description;
127
+ if (websiteId) webPage.isPartOf = { '@id': websiteId };
128
+ if (breadcrumbId) webPage.breadcrumb = { '@id': breadcrumbId };
129
+ if (imageNode) webPage.primaryImageOfPage = { '@id': primaryImageId };
130
+ if (!isArticleType && keywords) webPage.keywords = keywords;
131
+
132
+ const graph: Array<Record<string, unknown>> = [webPage];
133
+ if (imageNode) graph.push(imageNode);
134
+
135
+ // --- Tipos artículo: nodo independiente (#article) enlazado al WebPage ---
136
+ if (isArticleType) {
137
+ webPage.mainEntity = { '@id': articleNodeId };
138
+ const article: Record<string, unknown> = {
139
+ '@type': pageType,
140
+ '@id': articleNodeId,
141
+ headline: headline || name,
142
+ isPartOf: { '@id': webPageNodeId },
143
+ mainEntityOfPage: { '@id': webPageNodeId },
144
+ inLanguage,
145
+ };
146
+ if (description) article.description = description;
147
+ if (datePublished) article.datePublished = datePublished;
148
+ if (dateModified) article.dateModified = dateModified;
149
+ if (articleSection) article.articleSection = articleSection;
150
+ if (keywords) article.keywords = keywords;
151
+ if (imageNode) article.image = { '@id': primaryImageId };
152
+ const author = buildAuthor();
153
+ if (author) article.author = author;
154
+ article.publisher = buildPublisher();
155
+ graph.push(article);
112
156
  }
113
157
 
158
+ // --- FAQPage: las preguntas son el mainEntity del WebPage ---
114
159
  if (pageType === 'FAQPage' && faqItems.length) {
115
- schema.mainEntity = faqItems.map((item) => ({
160
+ webPage.mainEntity = faqItems.map((item) => ({
116
161
  '@type': 'Question',
117
162
  name: item.question,
118
- acceptedAnswer: {
119
- '@type': 'Answer',
120
- text: item.answer,
121
- },
163
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
122
164
  }));
123
165
  }
124
166
 
167
+ // --- ProfilePage: el mainEntity es la entidad (Person/Organization) del autor ---
125
168
  if (pageType === 'ProfilePage' && (authorName || authorId)) {
126
- if (authorId) {
127
- schema.mainEntity = { '@id': authorId };
128
- } else if (authorType === 'Organization') {
129
- const organization: Record<string, unknown> = { '@type': 'Organization', name: authorName };
130
- if (authorUrl) organization.url = authorUrl;
131
- if (description) organization.description = description;
132
- if (sameAs && sameAs.length) organization.sameAs = sameAs;
133
- schema.mainEntity = organization;
134
- } else {
135
- const person: Record<string, unknown> = { '@type': 'Person', name: authorName };
136
- if (authorUrl) person.url = authorUrl;
137
- if (authorImage) person.image = authorImage;
138
- if (jobTitle) person.jobTitle = jobTitle;
139
- if (description) person.description = description;
140
- if (sameAs && sameAs.length) person.sameAs = sameAs;
141
- schema.mainEntity = person;
169
+ const entityId = authorId || `${url}#person`;
170
+ webPage.mainEntity = { '@id': entityId };
171
+ if (authorName) {
172
+ const entity: Record<string, unknown> = { '@type': authorType, '@id': entityId, name: authorName };
173
+ if (authorUrl) entity.url = authorUrl;
174
+ if (authorImage && authorType === 'Person') entity.image = authorImage;
175
+ if (jobTitle && authorType === 'Person') entity.jobTitle = jobTitle;
176
+ if (description) entity.description = description;
177
+ if (sameAs && sameAs.length) entity.sameAs = sameAs;
178
+ graph.push(entity);
142
179
  }
180
+ // Si solo llega authorId (sin authorName), se referencia un nodo definido en otra parte
181
+ // (p. ej. la Organization global #organization del Layout) → no se redeclara aquí.
143
182
  }
144
183
 
184
+ // --- CollectionPage: el mainEntity (→ ItemList) se difiere a una fase posterior ---
185
+
186
+ const schema: Record<string, unknown> = {
187
+ '@context': 'https://schema.org',
188
+ '@graph': graph,
189
+ };
190
+
145
191
  const stringify = (value: unknown): string => {
146
192
  if (value === null || value === undefined) return 'null';
147
193
  if (typeof value === 'string') return `"${escapeJson(value)}"`;
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { PAGE_ENTITY_ID } from '../../lib/functions.js';
3
+
2
4
  const {
3
5
  title = "",
4
6
  description = "",
@@ -8,6 +10,9 @@ const {
8
10
  rightItems = [],
9
11
  } = Astro.props;
10
12
 
13
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
14
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
15
+
11
16
  const hasLeft = leftTitle || leftItems.length > 0;
12
17
  const hasRight = rightTitle || rightItems.length > 0;
13
18
  const hasBoth = hasLeft && hasRight;
@@ -19,7 +24,8 @@ const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g,
19
24
  const buildItemListScript = (listName: string, listItems: Item[]) => `<script type="application/ld+json">
20
25
  {
21
26
  "@context": "https://schema.org",
22
- "@type": "ItemList",
27
+ "@type": "ItemList",${pageId ? `
28
+ "isPartOf": { "@id": "${pageId}" },` : ''}
23
29
  "name": "${escapeJson(listName)}",
24
30
  "numberOfItems": ${listItems.length},
25
31
  "itemListElement": [${listItems.map((item: Item, index: number) => `{
@@ -15,16 +15,6 @@ const {
15
15
  color = '#262626',
16
16
  } = Astro.props;
17
17
 
18
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
19
-
20
- const structuredData = (title || description) ? `<script type="application/ld+json">
21
- {
22
- "@context": "https://schema.org",
23
- "@type": "WebPageElement",
24
- "name": "${escapeJson(title)}"${description ? `,
25
- "description": "${escapeJson(description)}"` : ''}
26
- }
27
- </script>` : '';
28
18
  ---
29
19
 
30
20
  <section class="w-full flex items-center justify-center p-6 md:p-0">
@@ -34,6 +24,4 @@ const structuredData = (title || description) ? `<script type="application/ld+js
34
24
  {showDescription && <div class={`text-[${color}] font-inter text-base font-normal leading-[24px] mb-8`} set:html={description}></div>}
35
25
  {showBtn && <a href={linkBtn} class="text-[#ffffff] font-inter font-normal bg-[#2134F1] px-5 py-3 border border-[#2134F1] rounded-xl hover:bg-[#ffffff] hover:border-[#2134F1] shadow-[0_2px_4px_-2px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.04)] transition-all duration-300 hover:text-[#2134F1]">{txtBtn}</a>}
36
26
  </article>
37
- </section>
38
-
39
- <Fragment set:html={structuredData} />
27
+ </section>
@@ -10,17 +10,6 @@ const {
10
10
  subdescription = "",
11
11
  } = Astro.props;
12
12
 
13
- const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
14
-
15
- const structuredData = (title || description) ? `<script type="application/ld+json">
16
- {
17
- "@context": "https://schema.org",
18
- "@type": "WebPageElement",
19
- "name": "${escapeJson(title)}"${description ? `,
20
- "description": "${escapeJson(description)}"` : ''}
21
- }
22
- </script>` : '';
23
-
24
13
  ---
25
14
 
26
15
  <div class={`w-full md:w-3/5 max-w-auto flex ${flexJustify === 'justify-center' ? 'justify-center' : 'justify-start'} flex-col p-4 md:p-0 mx-auto`}>
@@ -55,6 +44,4 @@ const structuredData = (title || description) ? `<script type="application/ld+js
55
44
  {subdescription && subdescription.trim() !== "" && (
56
45
  <div class={`w-full md:w-${descriptionWidth} text-[#363942] mx-auto max-w-[850px] font-poppins text-xl font-normal leading-7 mb-8 text-center`} set:html={subdescription}></div>
57
46
  )}
58
- </div>
59
-
60
- <Fragment set:html={structuredData} />
47
+ </div>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import Video_2025_Polop from './Video_2025_Polop.astro';
3
- import { extractImageUrl } from '../../lib/functions.js';
3
+ import { extractImageUrl, PAGE_ENTITY_ID } from '../../lib/functions.js';
4
4
 
5
5
  interface VideoItem {
6
6
  title?: string;
@@ -17,6 +17,9 @@ const {
17
17
  iframeSrc
18
18
  }: { items?: VideoItem[]; iframeSrc?: string } = Astro.props;
19
19
 
20
+ // isPartOf hacia el nodo de página (#webpage por defecto). Genérico: opt-out con pageId="".
21
+ const { pageId = PAGE_ENTITY_ID } = Astro.props;
22
+
20
23
  const idAccordion = 'js-acordeon-' + Math.random().toString(36).substring(2, 15);
21
24
 
22
25
  const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/[\n\r\t]+/g, ' ');
@@ -24,7 +27,8 @@ const escapeJson = (s = "") => String(s).replace(/<[^>]*>/g, '').replace(/\\/g,
24
27
  const structuredData = items.length > 0 ? `<script type="application/ld+json">
25
28
  {
26
29
  "@context": "https://schema.org",
27
- "@type": "ItemList",
30
+ "@type": "ItemList",${pageId ? `
31
+ "isPartOf": { "@id": "${pageId}" },` : ''}
28
32
  "numberOfItems": ${items.length},
29
33
  "itemListElement": [${items.map((item: VideoItem, index: number) => `{
30
34
  "@type": "ListItem",
@@ -1,5 +1,14 @@
1
1
  import { components } from '../generated/componentRegistry.ts';
2
2
 
3
+ // ============================================================================
4
+ // 🎯 Grafo JSON-LD
5
+ // ============================================================================
6
+ // @id RELATIVO de fragmento del nodo principal de la página (WebPage/CollectionPage…),
7
+ // que emite SEO_Schema_Page. Es la fuente única de la convención: cualquier bloque del mismo
8
+ // documento (incluido el HTML inyectado por el render API del CMS) puede colgar de la página con
9
+ // isPartOf: { "@id": PAGE_ENTITY_ID } sin necesidad de conocer la URL/canonical.
10
+ export const PAGE_ENTITY_ID = '#webpage';
11
+
3
12
  // ============================================================================
4
13
  // 🎯 Utilidades de Imagen para Limbo
5
14
  // ============================================================================