@cogenta/theme-canonical 0.1.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.
Files changed (126) hide show
  1. package/dist/src/index.d.ts +21 -0
  2. package/dist/src/index.d.ts.map +1 -0
  3. package/dist/src/index.js +21 -0
  4. package/dist/src/index.js.map +1 -0
  5. package/dist/src/render/actions.d.ts +17 -0
  6. package/dist/src/render/actions.d.ts.map +1 -0
  7. package/dist/src/render/actions.js +43 -0
  8. package/dist/src/render/actions.js.map +1 -0
  9. package/dist/src/render/blocks/collection-list.d.ts +16 -0
  10. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  11. package/dist/src/render/blocks/collection-list.js +54 -0
  12. package/dist/src/render/blocks/collection-list.js.map +1 -0
  13. package/dist/src/render/blocks/cta.d.ts +5 -0
  14. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  15. package/dist/src/render/blocks/cta.js +10 -0
  16. package/dist/src/render/blocks/cta.js.map +1 -0
  17. package/dist/src/render/blocks/embed.d.ts +5 -0
  18. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  19. package/dist/src/render/blocks/embed.js +88 -0
  20. package/dist/src/render/blocks/embed.js.map +1 -0
  21. package/dist/src/render/blocks/faq.d.ts +5 -0
  22. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  23. package/dist/src/render/blocks/faq.js +21 -0
  24. package/dist/src/render/blocks/faq.js.map +1 -0
  25. package/dist/src/render/blocks/feature-grid.d.ts +5 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  27. package/dist/src/render/blocks/feature-grid.js +27 -0
  28. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  29. package/dist/src/render/blocks/gallery.d.ts +15 -0
  30. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  31. package/dist/src/render/blocks/gallery.js +28 -0
  32. package/dist/src/render/blocks/gallery.js.map +1 -0
  33. package/dist/src/render/blocks/hero.d.ts +12 -0
  34. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  35. package/dist/src/render/blocks/hero.js +25 -0
  36. package/dist/src/render/blocks/hero.js.map +1 -0
  37. package/dist/src/render/blocks/logos.d.ts +5 -0
  38. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  39. package/dist/src/render/blocks/logos.js +25 -0
  40. package/dist/src/render/blocks/logos.js.map +1 -0
  41. package/dist/src/render/blocks/media-figure.d.ts +15 -0
  42. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  43. package/dist/src/render/blocks/media-figure.js +30 -0
  44. package/dist/src/render/blocks/media-figure.js.map +1 -0
  45. package/dist/src/render/blocks/prose.d.ts +10 -0
  46. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  47. package/dist/src/render/blocks/prose.js +11 -0
  48. package/dist/src/render/blocks/prose.js.map +1 -0
  49. package/dist/src/render/blocks/quote.d.ts +14 -0
  50. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  51. package/dist/src/render/blocks/quote.js +25 -0
  52. package/dist/src/render/blocks/quote.js.map +1 -0
  53. package/dist/src/render/blocks/stats.d.ts +5 -0
  54. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  55. package/dist/src/render/blocks/stats.js +23 -0
  56. package/dist/src/render/blocks/stats.js.map +1 -0
  57. package/dist/src/render/entry.d.ts +7 -0
  58. package/dist/src/render/entry.d.ts.map +1 -0
  59. package/dist/src/render/entry.js +35 -0
  60. package/dist/src/render/entry.js.map +1 -0
  61. package/dist/src/render/heading.d.ts +14 -0
  62. package/dist/src/render/heading.d.ts.map +1 -0
  63. package/dist/src/render/heading.js +34 -0
  64. package/dist/src/render/heading.js.map +1 -0
  65. package/dist/src/render/html.d.ts +44 -0
  66. package/dist/src/render/html.d.ts.map +1 -0
  67. package/dist/src/render/html.js +96 -0
  68. package/dist/src/render/html.js.map +1 -0
  69. package/dist/src/render/media.d.ts +32 -0
  70. package/dist/src/render/media.d.ts.map +1 -0
  71. package/dist/src/render/media.js +42 -0
  72. package/dist/src/render/media.js.map +1 -0
  73. package/dist/src/render/render-block.d.ts +26 -0
  74. package/dist/src/render/render-block.d.ts.map +1 -0
  75. package/dist/src/render/render-block.js +66 -0
  76. package/dist/src/render/render-block.js.map +1 -0
  77. package/dist/src/render/rich-text.d.ts +5 -0
  78. package/dist/src/render/rich-text.d.ts.map +1 -0
  79. package/dist/src/render/rich-text.js +105 -0
  80. package/dist/src/render/rich-text.js.map +1 -0
  81. package/dist/src/theme-contract.d.ts +165 -0
  82. package/dist/src/theme-contract.d.ts.map +1 -0
  83. package/dist/src/theme-contract.js +21 -0
  84. package/dist/src/theme-contract.js.map +1 -0
  85. package/dist/theme.config.d.ts +31 -0
  86. package/dist/theme.config.d.ts.map +1 -0
  87. package/dist/theme.config.js +42 -0
  88. package/dist/theme.config.js.map +1 -0
  89. package/package.json +57 -0
  90. package/src/blocks/CollectionList.astro +20 -0
  91. package/src/blocks/Cta.astro +11 -0
  92. package/src/blocks/Embed.astro +18 -0
  93. package/src/blocks/Faq.astro +16 -0
  94. package/src/blocks/FeatureGrid.astro +11 -0
  95. package/src/blocks/Gallery.astro +17 -0
  96. package/src/blocks/Hero.astro +25 -0
  97. package/src/blocks/Logos.astro +11 -0
  98. package/src/blocks/MediaFigure.astro +11 -0
  99. package/src/blocks/Prose.astro +11 -0
  100. package/src/blocks/Quote.astro +11 -0
  101. package/src/blocks/Stats.astro +11 -0
  102. package/src/components/BlockZone.astro +62 -0
  103. package/src/index.ts +20 -0
  104. package/src/layouts/Base.astro +63 -0
  105. package/src/render/actions.ts +57 -0
  106. package/src/render/blocks/collection-list.ts +98 -0
  107. package/src/render/blocks/cta.ts +17 -0
  108. package/src/render/blocks/embed.ts +111 -0
  109. package/src/render/blocks/faq.ts +42 -0
  110. package/src/render/blocks/feature-grid.ts +54 -0
  111. package/src/render/blocks/gallery.ts +49 -0
  112. package/src/render/blocks/hero.ts +42 -0
  113. package/src/render/blocks/logos.ts +45 -0
  114. package/src/render/blocks/media-figure.ts +42 -0
  115. package/src/render/blocks/prose.ts +17 -0
  116. package/src/render/blocks/quote.ts +39 -0
  117. package/src/render/blocks/stats.ts +41 -0
  118. package/src/render/entry.ts +40 -0
  119. package/src/render/heading.ts +44 -0
  120. package/src/render/html.ts +122 -0
  121. package/src/render/media.ts +62 -0
  122. package/src/render/render-block.ts +97 -0
  123. package/src/render/rich-text.ts +150 -0
  124. package/src/styles/theme.css +606 -0
  125. package/src/theme-contract.ts +159 -0
  126. package/tokens.json +36 -0
@@ -0,0 +1,62 @@
1
+ ---
2
+ import type { VocabularyBlock } from '@cogenta/blocks'
3
+ import CollectionList from '../blocks/CollectionList.astro'
4
+ import Cta from '../blocks/Cta.astro'
5
+ import Embed from '../blocks/Embed.astro'
6
+ import Faq from '../blocks/Faq.astro'
7
+ import FeatureGrid from '../blocks/FeatureGrid.astro'
8
+ import Gallery from '../blocks/Gallery.astro'
9
+ import Hero from '../blocks/Hero.astro'
10
+ import Logos from '../blocks/Logos.astro'
11
+ import MediaFigure from '../blocks/MediaFigure.astro'
12
+ import Prose from '../blocks/Prose.astro'
13
+ import Quote from '../blocks/Quote.astro'
14
+ import Stats from '../blocks/Stats.astro'
15
+ import { pageHasOwnHeading } from '../render/render-block.js'
16
+ import type { RenderContext } from '../theme-contract.js'
17
+
18
+ /**
19
+ * Dispatches a block zone to the twelve components.
20
+ *
21
+ * The dispatch happens here rather than inside `renderBlock` because
22
+ * `collectionList` has to await its data, and an async renderer would make the
23
+ * number of round trips depend on the markup. Everything else about the mapping
24
+ * — which block renders what — stays in `src/render/`.
25
+ */
26
+ interface Props {
27
+ ctx: RenderContext
28
+ blocks: readonly VocabularyBlock[]
29
+ /** Used as the `h1` when no `hero` on the page carries one. */
30
+ title: string
31
+ }
32
+
33
+ const { ctx, blocks, title } = Astro.props as Props
34
+
35
+ const COMPONENTS = {
36
+ hero: Hero,
37
+ prose: Prose,
38
+ mediaFigure: MediaFigure,
39
+ featureGrid: FeatureGrid,
40
+ cta: Cta,
41
+ gallery: Gallery,
42
+ quote: Quote,
43
+ faq: Faq,
44
+ stats: Stats,
45
+ logos: Logos,
46
+ collectionList: CollectionList,
47
+ embed: Embed,
48
+ } as const
49
+ ---
50
+
51
+ <main class="cg-main" id="cg-main">
52
+ {/* Exactly one h1 per page: the hero renders it, or this does. */}
53
+ {!pageHasOwnHeading(blocks) && <h1 class="cg-page__title">{title}</h1>}
54
+ {
55
+ blocks.map((block) => {
56
+ const Component = COMPONENTS[block._type]
57
+ // An unknown block is dropped, not guessed at: picking a fallback is the
58
+ // render layer's job (contract B's `fallback`), not the theme's.
59
+ return Component === undefined ? null : <Component block={block} ctx={ctx} />
60
+ })
61
+ }
62
+ </main>
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export * from './render/actions.js'
2
+ export * from './render/blocks/collection-list.js'
3
+ export * from './render/blocks/cta.js'
4
+ export * from './render/blocks/embed.js'
5
+ export * from './render/blocks/faq.js'
6
+ export * from './render/blocks/feature-grid.js'
7
+ export * from './render/blocks/gallery.js'
8
+ export * from './render/blocks/hero.js'
9
+ export * from './render/blocks/logos.js'
10
+ export * from './render/blocks/media-figure.js'
11
+ export * from './render/blocks/prose.js'
12
+ export * from './render/blocks/quote.js'
13
+ export * from './render/blocks/stats.js'
14
+ export * from './render/entry.js'
15
+ export * from './render/heading.js'
16
+ export * from './render/html.js'
17
+ export * from './render/media.js'
18
+ export * from './render/render-block.js'
19
+ export * from './render/rich-text.js'
20
+ export * from './theme-contract.js'
@@ -0,0 +1,63 @@
1
+ ---
2
+ import type { RenderContext } from '../theme-contract.js'
3
+ import '../styles/theme.css'
4
+
5
+ interface Props {
6
+ ctx: RenderContext
7
+ /** The document title. The page's `h1` is rendered by the content, not here. */
8
+ title: string
9
+ description?: string
10
+ }
11
+
12
+ const { ctx, title, description } = Astro.props as Props
13
+
14
+ // The skin is a *served file*, not an import: changing skin rewrites it and the
15
+ // next request picks it up, with no rebuild (L3, "Skins et changement à chaud").
16
+ // Inlining the tokens here would turn every skin change into a build.
17
+ const SKIN_HREF = '/_cogenta/skin.css'
18
+ ---
19
+
20
+ <!doctype html>
21
+ <!--
22
+ `lang` is the rendered locale, which assistive technology needs to pick the
23
+ right voice (WCAG 3.1.1). `dir="auto"` because contract D's `ctx` carries no
24
+ writing direction: inferring it from the first strong character is honest,
25
+ while hard-coding `ltr` would be wrong for half the locales a site may add.
26
+ -->
27
+ <html lang={ctx.locale} dir="auto">
28
+ <head>
29
+ <meta charset="utf-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <title>{title}</title>
32
+ {description && <meta name="description" content={description} />}
33
+ <link rel="canonical" href={ctx.url.href} />
34
+ <link rel="stylesheet" href={SKIN_HREF} />
35
+ </head>
36
+ <body>
37
+ <!--
38
+ First focusable element on the page, visible as soon as it takes focus.
39
+ Its target is the `id` `renderPage` puts on `<main>`.
40
+ -->
41
+ <a class="cg-skip-link" href="#cg-main">{ctx.t('a11y.skipToContent')}</a>
42
+
43
+ <header class="cg-site-header">
44
+ <div class="cg-site-header__inner">
45
+ <a class="cg-site-header__home" href={ctx.link({ path: '/' })}>{ctx.site.name}</a>
46
+ <slot name="navigation" />
47
+ </div>
48
+ </header>
49
+
50
+ <slot />
51
+
52
+ <footer class="cg-site-footer">
53
+ <div class="cg-site-footer__inner">
54
+ <slot name="footer" />
55
+ </div>
56
+ </footer>
57
+
58
+ <!--
59
+ No script tag. Nothing on this page hydrates: the FAQ uses <details>, the
60
+ carousel uses scroll snapping, and the embeds load nothing before consent.
61
+ -->
62
+ </body>
63
+ </html>
@@ -0,0 +1,57 @@
1
+ import type { Action, LinkTarget } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../theme-contract.js'
3
+ import { type HtmlElement, h } from './html.js'
4
+
5
+ /**
6
+ * A target is resolved through `ctx.link` in every case, including an external
7
+ * `href`: the context is the only thing that knows the locale prefix and the
8
+ * site's base URL, and duplicating that reasoning here is how a theme starts
9
+ * emitting broken links in a sub-path deployment.
10
+ */
11
+ export function href(ctx: RenderContext, target: LinkTarget): string {
12
+ return 'href' in target ? ctx.link(target.href) : ctx.link(target)
13
+ }
14
+
15
+ /** A link that leaves the site gets the protection; a same-site one must not. */
16
+ function isExternal(ctx: RenderContext, url: string): boolean {
17
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) return false
18
+ const site = URL.parse(ctx.site.url)
19
+ const target = URL.parse(url)
20
+ if (site === null || target === null) return false
21
+ return site.host !== target.host
22
+ }
23
+
24
+ export function actionLink(ctx: RenderContext, action: Action): HtmlElement {
25
+ const url = href(ctx, action.target)
26
+ const external = isExternal(ctx, url)
27
+ return h(
28
+ 'a',
29
+ {
30
+ class: 'cg-action',
31
+ // `emphasis` is a semantic intent, so it stays an attribute the skin reads
32
+ // rather than a class the block dictated. Default `secondary`: promoting
33
+ // an unstated action to primary is a decision the editor did not make.
34
+ 'data-emphasis': action.emphasis ?? 'secondary',
35
+ href: url,
36
+ rel: external ? 'noopener noreferrer' : undefined,
37
+ },
38
+ action.label,
39
+ )
40
+ }
41
+
42
+ /**
43
+ * A list, not a row of loose anchors: a screen reader then announces "list, 2
44
+ * items" and the actions are navigable as a group.
45
+ */
46
+ export function actionList(
47
+ ctx: RenderContext,
48
+ actions: readonly Action[] | undefined,
49
+ label: string,
50
+ ): HtmlElement | null {
51
+ if (actions === undefined || actions.length === 0) return null
52
+ return h(
53
+ 'ul',
54
+ { class: 'cg-actions', 'aria-label': label },
55
+ actions.map((action) => h('li', {}, actionLink(ctx, action))),
56
+ )
57
+ }
@@ -0,0 +1,98 @@
1
+ import type { CollectionListBlock } from '@cogenta/blocks'
2
+ import type { ContentEntry, QueryRequest, RenderContext } from '../../theme-contract.js'
3
+ import { entryDate, entryExcerpt, entryHref, entryTitle } from '../entry.js'
4
+ import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
+ import { type HtmlElement, h } from '../html.js'
6
+
7
+ /**
8
+ * The only block of the twelve that reads data at render time — contract B
9
+ * marks it `runtime: 'server'` for that reason.
10
+ *
11
+ * The read is *not* done here. `query` builds the request, the `.astro`
12
+ * component awaits `ctx.content.list(...)` in its frontmatter, and this
13
+ * function stays a pure function of the entries it is handed. That keeps the
14
+ * markup snapshot-testable and keeps the theme's single door to data — the
15
+ * read-only content client — in one visible place.
16
+ */
17
+ export function query(block: CollectionListBlock): QueryRequest {
18
+ return {
19
+ collection: block.collection,
20
+ ...(block.filter === undefined ? {} : { filter: block.filter }),
21
+ ...(block.sort === undefined ? {} : { sort: block.sort }),
22
+ // Capped by contract B at 100; an absent limit still must not mean "all".
23
+ limit: block.limit ?? 10,
24
+ }
25
+ }
26
+
27
+ function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
28
+ const date = entryDate(entry)
29
+ const excerpt = entryExcerpt(entry)
30
+ return h(
31
+ 'li',
32
+ { class: 'cg-entry' },
33
+ h(
34
+ 'article',
35
+ { class: 'cg-entry__body' },
36
+ heading(
37
+ tag,
38
+ { class: 'cg-entry__title' },
39
+ h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
40
+ ),
41
+ date === undefined
42
+ ? null
43
+ : h(
44
+ 'time',
45
+ { class: 'cg-entry__date', datetime: date },
46
+ // Formatted in the rendered locale rather than left as an ISO
47
+ // string: the machine-readable form is already in `datetime`.
48
+ new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
49
+ ),
50
+ excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
51
+ ),
52
+ )
53
+ }
54
+
55
+ export function renderCollectionList(
56
+ block: CollectionListBlock,
57
+ ctx: RenderContext,
58
+ entries: readonly ContentEntry[],
59
+ ): HtmlElement {
60
+ const hasTitle = block.title !== undefined
61
+ const entryTag = nestedHeadingTag('collectionList', hasTitle)
62
+ const items =
63
+ entries.length === 0
64
+ ? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
65
+ : h(
66
+ 'ul',
67
+ { class: 'cg-collection__items' },
68
+ entries.map((entry) => renderEntry(entry, ctx, entryTag)),
69
+ )
70
+
71
+ return h(
72
+ 'section',
73
+ {
74
+ class: 'cg-block cg-collection',
75
+ 'data-block': 'collectionList',
76
+ 'data-layout': block.layout,
77
+ },
78
+ hasTitle
79
+ ? heading(
80
+ blockHeadingTag('collectionList') ?? 'h2',
81
+ { class: 'cg-collection__title' },
82
+ block.title ?? '',
83
+ )
84
+ : null,
85
+ block.layout === 'carousel'
86
+ ? h(
87
+ 'div',
88
+ {
89
+ class: 'cg-collection__viewport',
90
+ role: 'region',
91
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
92
+ tabindex: '0',
93
+ },
94
+ items,
95
+ )
96
+ : items,
97
+ )
98
+ }
@@ -0,0 +1,17 @@
1
+ import type { CtaBlock } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { actionList } from '../actions.js'
4
+ import { blockHeadingTag, heading } from '../heading.js'
5
+ import { type HtmlElement, h } from '../html.js'
6
+
7
+ export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
8
+ return h(
9
+ 'section',
10
+ { class: 'cg-block cg-cta', 'data-block': 'cta' },
11
+ heading(blockHeadingTag('cta') ?? 'h2', { class: 'cg-cta__title' }, block.title),
12
+ block.text === undefined ? null : h('p', { class: 'cg-cta__text' }, block.text),
13
+ // `actions` is required and non-empty for a `cta`, so the list is always
14
+ // rendered; the null branch stays reachable only through invalid data.
15
+ actionList(ctx, block.actions, block.title),
16
+ )
17
+ }
@@ -0,0 +1,111 @@
1
+ import type { EmbedBlock } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { type HtmlElement, h } from '../html.js'
4
+ import { aspectRatio } from '../media.js'
5
+
6
+ /**
7
+ * Nothing here contacts a third party before the visitor has consented.
8
+ *
9
+ * When `consentRequired` is true the block renders a self-contained card and an
10
+ * outbound link — no `<iframe>`, no `<script>`, no preconnect, no poster image
11
+ * fetched from the provider. An embed that "only" loads a thumbnail has already
12
+ * handed the visitor's IP address and a cookie to the provider, which is the
13
+ * exact transfer consent was supposed to gate.
14
+ *
15
+ * The theme ships no consent *button*, because it ships no JavaScript: granting
16
+ * consent is a site-wide decision that belongs to the consent layer, not to a
17
+ * block. Until that layer exists, the card is the honest rendering — it says
18
+ * what would be loaded and lets the visitor go there deliberately.
19
+ */
20
+
21
+ /** `null` means "this provider has no embeddable frame we trust". */
22
+ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
23
+ const url = URL.parse(rawUrl)
24
+ if (url === null) return null
25
+ const segments = url.pathname.split('/').filter((segment) => segment !== '')
26
+
27
+ switch (provider) {
28
+ case 'youtube': {
29
+ // `youtube-nocookie.com` is the privacy-preserving host, and the only one
30
+ // this theme will point an iframe at.
31
+ const id = url.hostname.endsWith('youtu.be')
32
+ ? segments[0]
33
+ : (url.searchParams.get('v') ?? segments[1])
34
+ return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
35
+ }
36
+ case 'vimeo': {
37
+ const id = segments.find((segment) => /^\d+$/.test(segment))
38
+ return id === undefined ? null : `https://player.vimeo.com/video/${id}`
39
+ }
40
+ case 'dailymotion': {
41
+ const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
42
+ return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
43
+ }
44
+ case 'spotify': {
45
+ const [kind, id] = segments
46
+ return kind === undefined || id === undefined
47
+ ? null
48
+ : `https://open.spotify.com/embed/${kind}/${id}`
49
+ }
50
+ case 'soundcloud':
51
+ return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
52
+ default:
53
+ // bluesky, mastodon, other: a post embed is a script tag, which this theme
54
+ // does not load. The link card is the rendering.
55
+ return null
56
+ }
57
+ }
58
+
59
+ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
60
+ return h(
61
+ 'div',
62
+ { class: 'cg-embed__placeholder' },
63
+ h('p', { class: 'cg-embed__notice' }, reason),
64
+ h(
65
+ 'a',
66
+ {
67
+ class: 'cg-embed__link',
68
+ href: ctx.link(block.url),
69
+ rel: 'noopener noreferrer nofollow',
70
+ },
71
+ ctx.t('embed.open', { provider: block.provider }),
72
+ ),
73
+ )
74
+ }
75
+
76
+ export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
77
+ const source = block.consentRequired ? null : frameSource(block.provider, block.url)
78
+ // Video providers are 16:9 unless the editor framed them otherwise; without a
79
+ // ratio the frame would collapse and shift the layout as it loads.
80
+ const ratio = aspectRatio(block.ratio) ?? '16 / 9'
81
+
82
+ return h(
83
+ 'div',
84
+ {
85
+ class: 'cg-block cg-embed',
86
+ 'data-block': 'embed',
87
+ 'data-provider': block.provider,
88
+ 'data-consent': block.consentRequired ? 'required' : 'not-required',
89
+ style: `--cg-ratio:${ratio}`,
90
+ },
91
+ source === null
92
+ ? consentCard(
93
+ block,
94
+ ctx,
95
+ block.consentRequired
96
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
97
+ : ctx.t('embed.unsupported', { provider: block.provider }),
98
+ )
99
+ : h('iframe', {
100
+ class: 'cg-embed__frame',
101
+ src: source,
102
+ // An iframe with no accessible name is announced as "frame" and
103
+ // nothing else. WCAG 4.1.2, and the single most common embed defect.
104
+ title: ctx.t('embed.title', { provider: block.provider }),
105
+ loading: 'lazy',
106
+ referrerpolicy: 'strict-origin-when-cross-origin',
107
+ allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
108
+ allowfullscreen: true,
109
+ }),
110
+ )
111
+ }
@@ -0,0 +1,42 @@
1
+ import type { FaqBlock, FaqItem } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { blockHeadingTag, heading } from '../heading.js'
4
+ import { type HtmlElement, h } from '../html.js'
5
+ import { renderRichText } from '../rich-text.js'
6
+
7
+ /**
8
+ * `<details>`/`<summary>` rather than a scripted accordion: expanding, keyboard
9
+ * operation, the expanded state announced to assistive technology and in-page
10
+ * search all come from the browser, at zero bytes of JavaScript.
11
+ *
12
+ * The question is plain text inside `<summary>`, not a heading: a heading
13
+ * nested in a summary is rendered inconsistently across screen readers, and the
14
+ * questions are already reachable as a list under the block's own heading.
15
+ */
16
+ function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
17
+ return h(
18
+ 'li',
19
+ { class: 'cg-faq__item' },
20
+ h(
21
+ 'details',
22
+ { class: 'cg-faq__details' },
23
+ h('summary', { class: 'cg-faq__question' }, item.question),
24
+ h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
25
+ ),
26
+ )
27
+ }
28
+
29
+ export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
30
+ return h(
31
+ 'section',
32
+ { class: 'cg-block cg-faq', 'data-block': 'faq' },
33
+ block.title === undefined
34
+ ? null
35
+ : heading(blockHeadingTag('faq') ?? 'h2', { class: 'cg-faq__title' }, block.title),
36
+ h(
37
+ 'ul',
38
+ { class: 'cg-faq__items' },
39
+ block.items.map((item) => renderItem(item, ctx)),
40
+ ),
41
+ )
42
+ }
@@ -0,0 +1,54 @@
1
+ import type { FeatureGridBlock, FeatureItem } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { href } from '../actions.js'
4
+ import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
+ import { type HtmlElement, h } from '../html.js'
6
+
7
+ /**
8
+ * The item's title is the link, so the link's accessible name is the feature's
9
+ * name. A bare "learn more" repeated six times is the classic failure of
10
+ * WCAG 2.4.4, and the block carries no label field to write one anyway.
11
+ *
12
+ * `icon` names a symbol, never markup: it is exposed as a data attribute for
13
+ * the skin and marked `aria-hidden`, since the title already names the feature.
14
+ */
15
+ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
16
+ const title =
17
+ item.link === undefined
18
+ ? heading(tag, { class: 'cg-feature__title' }, item.title)
19
+ : heading(
20
+ tag,
21
+ { class: 'cg-feature__title' },
22
+ h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
23
+ )
24
+ return h(
25
+ 'li',
26
+ { class: 'cg-feature' },
27
+ item.icon === undefined
28
+ ? null
29
+ : h('span', { class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' }),
30
+ title,
31
+ item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
32
+ )
33
+ }
34
+
35
+ export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
36
+ const hasTitle = block.title !== undefined
37
+ const itemTag = nestedHeadingTag('featureGrid', hasTitle)
38
+ return h(
39
+ 'section',
40
+ { class: 'cg-block cg-features', 'data-block': 'featureGrid' },
41
+ hasTitle
42
+ ? heading(
43
+ blockHeadingTag('featureGrid') ?? 'h2',
44
+ { class: 'cg-features__title' },
45
+ block.title ?? '',
46
+ )
47
+ : null,
48
+ h(
49
+ 'ul',
50
+ { class: 'cg-features__items' },
51
+ block.items.map((item) => renderItem(item, ctx, itemTag)),
52
+ ),
53
+ )
54
+ }
@@ -0,0 +1,49 @@
1
+ import type { GalleryBlock } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { type HtmlElement, h } from '../html.js'
4
+ import { image } from '../media.js'
5
+
6
+ /**
7
+ * The carousel ships **no JavaScript**. It is a scroll-snapping list, which
8
+ * gives touch swiping, trackpad scrolling and — because the list is focusable
9
+ * and labelled — arrow-key scrolling, with none of the failure modes of a
10
+ * scripted carousel: no auto-advance to fight (WCAG 2.2.2), no focus stolen
11
+ * from an off-screen slide, and it still works before hydration.
12
+ *
13
+ * A focusable scrollable region needs an accessible name, or a screen reader
14
+ * announces an unlabelled tab stop; that is WCAG 2.4.3 and 4.1.2 both.
15
+ */
16
+ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
17
+ const items = h(
18
+ 'ul',
19
+ { class: 'cg-gallery__items' },
20
+ block.items.map((item) =>
21
+ h(
22
+ 'li',
23
+ { class: 'cg-gallery__item' },
24
+ image(ctx, item.media, { sizes: '(min-width: 45rem) 20rem, 60vw' }),
25
+ ),
26
+ ),
27
+ )
28
+
29
+ return h(
30
+ 'section',
31
+ {
32
+ class: 'cg-block cg-gallery',
33
+ 'data-block': 'gallery',
34
+ 'data-layout': block.layout,
35
+ },
36
+ block.layout === 'carousel'
37
+ ? h(
38
+ 'div',
39
+ {
40
+ class: 'cg-gallery__viewport',
41
+ role: 'region',
42
+ 'aria-label': ctx.t('gallery.carousel'),
43
+ tabindex: '0',
44
+ },
45
+ items,
46
+ )
47
+ : items,
48
+ )
49
+ }
@@ -0,0 +1,42 @@
1
+ import type { HeroBlock } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { actionList } from '../actions.js'
4
+ import { blockHeadingTag, heading } from '../heading.js'
5
+ import { type HtmlElement, h } from '../html.js'
6
+ import { image } from '../media.js'
7
+
8
+ /**
9
+ * The hero carries the page's `h1` — contract B declares `headingLevel: 'h1'`
10
+ * for it, and `renderPage` relies on that to avoid emitting a second one.
11
+ *
12
+ * The eyebrow is a paragraph, not a small heading: it labels the page, and
13
+ * turning it into a heading would put an `h*` above the `h1` in the outline.
14
+ */
15
+ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
16
+ const tag = blockHeadingTag('hero') ?? 'h1'
17
+ return h(
18
+ 'section',
19
+ { class: 'cg-block cg-hero', 'data-block': 'hero' },
20
+ h(
21
+ 'div',
22
+ { class: 'cg-hero__body' },
23
+ block.eyebrow === undefined ? null : h('p', { class: 'cg-hero__eyebrow' }, block.eyebrow),
24
+ heading(tag, { class: 'cg-hero__title' }, block.title),
25
+ block.subtitle === undefined ? null : h('p', { class: 'cg-hero__subtitle' }, block.subtitle),
26
+ actionList(ctx, block.actions, ctx.t('hero.actions')),
27
+ ),
28
+ block.media === undefined
29
+ ? null
30
+ : h(
31
+ 'div',
32
+ { class: 'cg-hero__media' },
33
+ // The only image on the page that is above the fold by construction,
34
+ // so the only one that must not be lazy: lazy-loading the LCP element
35
+ // is a measured Lighthouse regression, not a theoretical one.
36
+ image(ctx, block.media, {
37
+ loading: 'eager',
38
+ sizes: '(min-width: 60rem) 50vw, 100vw',
39
+ }),
40
+ ),
41
+ )
42
+ }
@@ -0,0 +1,45 @@
1
+ import type { LogoItem, LogosBlock } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { blockHeadingTag, heading } from '../heading.js'
4
+ import { type HtmlElement, h } from '../html.js'
5
+ import { image } from '../media.js'
6
+
7
+ /**
8
+ * Contract B says the organisation's name **is** the accessible name of the
9
+ * link. So the logo image carries it as alt text when the media entity has
10
+ * none, and no visually hidden duplicate is added: a link whose only content is
11
+ * an image with alt text is already named.
12
+ */
13
+ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
14
+ const logo = image(ctx, item.media, {
15
+ className: 'cg-logo__image',
16
+ altFrom: item.name,
17
+ variant: { fit: 'contain' },
18
+ })
19
+ return h(
20
+ 'li',
21
+ { class: 'cg-logo' },
22
+ item.url === undefined
23
+ ? logo
24
+ : h(
25
+ 'a',
26
+ { class: 'cg-logo__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
27
+ logo,
28
+ ),
29
+ )
30
+ }
31
+
32
+ export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
33
+ return h(
34
+ 'section',
35
+ { class: 'cg-block cg-logos', 'data-block': 'logos' },
36
+ block.title === undefined
37
+ ? null
38
+ : heading(blockHeadingTag('logos') ?? 'h2', { class: 'cg-logos__title' }, block.title),
39
+ h(
40
+ 'ul',
41
+ { class: 'cg-logos__items' },
42
+ block.items.map((item) => renderItem(item, ctx)),
43
+ ),
44
+ )
45
+ }