@contentrain/astro-kit 0.46.3 → 0.47.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.
package/catalog.json CHANGED
@@ -2403,6 +2403,16 @@
2403
2403
  "type": "string",
2404
2404
  "description": "Poster action for a map or widget (ui-strings embed.show)."
2405
2405
  },
2406
+ "schema": {
2407
+ "type": "boolean",
2408
+ "default": true,
2409
+ "content": false,
2410
+ "description": "A VideoObject (JSON-LD) for a video with a title: name, caption as description, page and embed url, the local poster or the YouTube still as thumbnail (a URL in the data, not a request). On by default; a video with no title, a map or a player prints none. false: none."
2411
+ },
2412
+ "uploaded": {
2413
+ "type": "string",
2414
+ "description": "The video's upload date (ISO 8601), printed as uploadDate only when the source states it."
2415
+ },
2406
2416
  "id": {
2407
2417
  "type": "string",
2408
2418
  "description": "Element id."
@@ -2445,6 +2455,7 @@
2445
2455
  "instance-id.ts",
2446
2456
  "measured.ts",
2447
2457
  "muted.ts",
2458
+ "once-per-page.ts",
2448
2459
  "safe-url.ts",
2449
2460
  "types.ts"
2450
2461
  ],
@@ -2549,9 +2560,9 @@
2549
2560
  },
2550
2561
  "schema": {
2551
2562
  "type": "boolean",
2552
- "default": false,
2563
+ "default": true,
2553
2564
  "content": false,
2554
- "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ."
2565
+ "description": "FAQPage JSON-LD built from the answered items. On by default; only the first FAQ on a page that has an answered question prints it. false: none (questions that are not the page's own FAQ)."
2555
2566
  },
2556
2567
  "measured": {
2557
2568
  "type": "object",
@@ -0,0 +1,17 @@
1
+ // Something a page states once, whichever component prints it: the first FAQ on a page carries the page's FAQPage, a
2
+ // second one would state a second FAQPage for the same page. Taken in the order the page renders its components, the
3
+ // same on every build (`page` = `Astro.locals`, as in instance-id.ts).
4
+
5
+ const claimed = new WeakMap<object, Set<string>>()
6
+
7
+ /** True for the first caller on this page that claims `what`, false for every later one. */
8
+ export function firstOnPage(page: object, what: string): boolean {
9
+ let taken = claimed.get(page)
10
+ if (!taken) {
11
+ taken = new Set()
12
+ claimed.set(page, taken)
13
+ }
14
+ if (taken.has(what)) return false
15
+ taken.add(what)
16
+ return true
17
+ }
@@ -49,10 +49,14 @@ interface Props {
49
49
  showLabel?: string | undefined
50
50
  aspect?: 'auto' | 'video' | 'portrait' | 'square' | 'map' | undefined
51
51
  width?: 'prose' | 'page' | undefined
52
+ /** VideoObject structured data (JSON-LD) for a video with a name (`title` or `caption`); on by default, `false`: none. */
53
+ schema?: boolean | undefined
54
+ /** When the video was published (ISO date), for the VideoObject's uploadDate; left out when unknown. */
55
+ uploaded?: string | undefined
52
56
  id?: string | undefined
53
57
  }
54
58
 
55
- const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, id } = Astro.props
59
+ const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, schema = true, uploaded, id } = Astro.props
56
60
  const target = (url ? embedTarget(url) : null) ?? (address && !url ? mapTarget(address) : null)
57
61
  const shape = aspect !== 'auto' ? aspect : target?.portrait ? 'portrait' : target?.kind === 'map' ? 'map' : 'video'
58
62
  // A player strip (Spotify, SoundCloud) has a fixed height, not a ratio.
@@ -61,6 +65,22 @@ const s = embed({ aspect: strip ? 'auto' : shape, width })
61
65
  const action = target && (target.kind === 'video' || target.kind === 'audio') ? playLabel : showLabel
62
66
  const name = title ?? caption ?? ''
63
67
  const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
68
+ // VideoObject states only what the embed knows: its name, its page and player, a poster (the site's own, else the
69
+ // provider's still for a YouTube video: a URL in data, never fetched by the visitor) and an upload date when given.
70
+ const youtubeId = target?.provider === 'youtube' ? /(?:[?&]v=|\/shorts\/)([\w-]{11})/.exec(target.href)?.[1] : undefined
71
+ const thumbnail = poster?.src ? new URL(poster.src, Astro.site ?? Astro.url).href : youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : undefined
72
+ const structured = schema && target?.kind === 'video' && name
73
+ ? JSON.stringify({
74
+ '@context': 'https://schema.org',
75
+ '@type': 'VideoObject',
76
+ name,
77
+ ...(caption && caption !== name ? { description: caption } : {}),
78
+ url: target.href,
79
+ embedUrl: target.src.split('?')[0],
80
+ ...(thumbnail ? { thumbnailUrl: thumbnail } : {}),
81
+ ...(uploaded ? { uploadDate: uploaded } : {}),
82
+ }).replace(/</g, '\\u003c')
83
+ : null
64
84
  ---
65
85
  {target
66
86
  ? (
@@ -77,6 +97,7 @@ const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
77
97
  </a>
78
98
  </div>
79
99
  {caption && <figcaption class={s.caption()}>{caption}</figcaption>}
100
+ {structured && <script type="application/ld+json" set:html={structured}></script>}
80
101
  </figure>
81
102
  )
82
103
  : linkUrl && (
@@ -17,6 +17,8 @@
17
17
  "poster": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Local poster image (an ImageInput; its alt is not used, the link names the embed). Without one the poster is a plain dark panel. Never a provider thumbnail URL: that would be a third-party request before the click." },
18
18
  "playLabel": { "type": "string", "description": "Poster action for a video or audio player (ui-strings embed.play)." },
19
19
  "showLabel": { "type": "string", "description": "Poster action for a map or widget (ui-strings embed.show)." },
20
+ "schema": { "type": "boolean", "default": true, "content": false, "description": "A VideoObject (JSON-LD) for a video with a title: name, caption as description, page and embed url, the local poster or the YouTube still as thumbnail (a URL in the data, not a request). On by default; a video with no title, a map or a player prints none. false: none." },
21
+ "uploaded": { "type": "string", "description": "The video's upload date (ISO 8601), printed as uploadDate only when the source states it." },
20
22
  "id": { "type": "string", "description": "Element id." }
21
23
  },
22
24
  "slots": {},
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  // Questions and answers as native disclosure widgets (<details>) — no script.
3
- // `exclusive` groups them by name, so opening one closes the others. With
4
- // `schema`, the page also carries FAQPage structured data for search engines
5
- // and AI answers.
3
+ // `exclusive` groups them by name, so opening one closes the others. The first
4
+ // FAQ on a page with an answered question also carries the page's FAQPage
5
+ // structured data for search engines and AI answers (`schema={false}`: none).
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import { measuredColumns } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
10
  import { instanceId } from '../_shared/instance-id'
11
+ import { firstOnPage } from '../_shared/once-per-page'
11
12
  import { muted } from '../_shared/muted'
12
13
 
13
14
  export interface FaqItem {
@@ -78,7 +79,7 @@ interface Props {
78
79
  exclusive?: boolean | undefined
79
80
  /** Open the first answer. */
80
81
  openFirst?: boolean | undefined
81
- /** Add FAQPage structured data (JSON-LD). Once per page. */
82
+ /** FAQPage structured data (JSON-LD) from the answered questions; on by default, printed by the first FAQ on the page only. */
82
83
  schema?: boolean | undefined
83
84
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
84
85
  /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
@@ -88,7 +89,7 @@ interface Props {
88
89
  entry?: string | undefined
89
90
  }
90
91
 
91
- const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id: givenId } = Astro.props
92
+ const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = true, tone, measured, id: givenId } = Astro.props
92
93
  // Two on one page never share an id: their labels and controls would point into each other.
93
94
  const id = instanceId(Astro.locals, givenId, 'faq')
94
95
  const s = faq({ layout, columns, icon, numbered, style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
@@ -105,9 +106,10 @@ const plain = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/&(?:nbsp|
105
106
  const MEDIA = /<(?:img|picture|video|audio|iframe|svg|embed|object|canvas)\b/i
106
107
  const answered = items.map(item => plain(item.answer) !== '' || MEDIA.test(item.answer))
107
108
  const firstAnswered = answered.indexOf(true)
108
- // FAQPage needs each question's acceptedAnswer text, so a question without one is left out of it.
109
+ // FAQPage needs each question's acceptedAnswer text, so a question without one is left out of it. One per page: a FAQ
110
+ // with nothing to state does not take the page's turn.
109
111
  const withText = items.filter(item => plain(item.answer) !== '')
110
- const structured = schema && withText.length > 0
112
+ const structured = schema && withText.length > 0 && firstOnPage(Astro.locals, 'faq-page')
111
113
  ? JSON.stringify({
112
114
  '@context': 'https://schema.org',
113
115
  '@type': 'FAQPage',
@@ -4,7 +4,7 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "once-per-page.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["single", "split"], "default": "single", "description": "single: the heading above one column. split: the heading and introduction stay in a left column beside the list; stacks on a phone." },
10
10
  "columns": { "options": ["1", "2"], "default": "1", "description": "Columns of questions on wide screens." },
@@ -19,7 +19,7 @@
19
19
  "intro": { "type": "text", "label": "Introduction", "description": "A sentence under the heading." },
20
20
  "exclusive": { "type": "boolean", "default": false, "content": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
21
21
  "openFirst": { "type": "boolean", "default": false, "content": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
22
- "schema": { "type": "boolean", "default": false, "content": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
22
+ "schema": { "type": "boolean", "default": true, "content": false, "description": "FAQPage JSON-LD built from the answered items. On by default; only the first FAQ on a page that has an answered question prints it. false: none (questions that are not the page's own FAQ)." },
23
23
  "measured": { "type": "object", "content": false, "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.", "fields": { "containerPx": { "type": "integer", "min": 0, "max": 2560, "description": "The frame's content width." }, "padY": { "type": "integer", "min": 0, "max": 400, "description": "Top and bottom padding." }, "padX": { "type": "integer", "min": 0, "max": 200, "description": "The frame's side padding (gutter)." }, "bg": { "type": "object", "description": "The section's background.", "fields": { "color": { "type": "string", "description": "Fill colour: hex, rgb(a) or hsl(a)." }, "image": { "type": "string", "description": "Background image address (http(s) or root-relative), drawn behind the content." }, "overlay": { "type": "string", "description": "Colour or linear/radial gradient laid over the image or the fill." }, "size": { "type": "string", "description": "cover, contain or auto; anything else draws as cover." }, "position": { "type": "string", "description": "One or two of center/top/bottom/left/right or percentages." } } }, "columns": { "type": "object", "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.", "fields": { "390": { "type": "integer", "min": 1, "max": 6, "description": "Phone." }, "768": { "type": "integer", "min": 1, "max": 6, "description": "Tablet." }, "1280": { "type": "integer", "min": 1, "max": 6, "description": "Desktop." } } }, "gap": { "type": "integer", "min": 0, "max": 200, "description": "Gap between items." }, "tone": { "type": "select", "options": ["light", "dark"], "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides." }, "align": { "type": "select", "options": ["start", "center", "end"], "description": "Where the heading and lead sit; read by a component whose own align is not set." }, "radius": { "type": "integer", "min": 0, "max": 200, "description": "Card and item corner radius." }, "minHeight": { "type": "integer", "min": 0, "max": 2000, "description": "The section's minimum height." } } },
24
24
  "entry": { "type": "string", "content": false, "description": "The page entry this section is built from, without its locale (services-0): printed as data-kit-entry, the hook a design layer styles this one section by. Absent, none is printed." },
25
25
  "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.46.3",
3
+ "version": "0.47.0",
4
4
  "license": "MIT",
5
5
  "description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
6
6
  "type": "module",