@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":
|
|
2563
|
+
"default": true,
|
|
2553
2564
|
"content": false,
|
|
2554
|
-
"description": "
|
|
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": {},
|
package/components/faq/Faq.astro
CHANGED
|
@@ -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.
|
|
4
|
-
//
|
|
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
|
-
/**
|
|
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 =
|
|
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',
|
package/components/faq/meta.json
CHANGED
|
@@ -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":
|
|
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.
|
|
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",
|