@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.
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/render/actions.d.ts +17 -0
- package/dist/src/render/actions.d.ts.map +1 -0
- package/dist/src/render/actions.js +43 -0
- package/dist/src/render/actions.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +16 -0
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
- package/dist/src/render/blocks/collection-list.js +54 -0
- package/dist/src/render/blocks/collection-list.js.map +1 -0
- package/dist/src/render/blocks/cta.d.ts +5 -0
- package/dist/src/render/blocks/cta.d.ts.map +1 -0
- package/dist/src/render/blocks/cta.js +10 -0
- package/dist/src/render/blocks/cta.js.map +1 -0
- package/dist/src/render/blocks/embed.d.ts +5 -0
- package/dist/src/render/blocks/embed.d.ts.map +1 -0
- package/dist/src/render/blocks/embed.js +88 -0
- package/dist/src/render/blocks/embed.js.map +1 -0
- package/dist/src/render/blocks/faq.d.ts +5 -0
- package/dist/src/render/blocks/faq.d.ts.map +1 -0
- package/dist/src/render/blocks/faq.js +21 -0
- package/dist/src/render/blocks/faq.js.map +1 -0
- package/dist/src/render/blocks/feature-grid.d.ts +5 -0
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
- package/dist/src/render/blocks/feature-grid.js +27 -0
- package/dist/src/render/blocks/feature-grid.js.map +1 -0
- package/dist/src/render/blocks/gallery.d.ts +15 -0
- package/dist/src/render/blocks/gallery.d.ts.map +1 -0
- package/dist/src/render/blocks/gallery.js +28 -0
- package/dist/src/render/blocks/gallery.js.map +1 -0
- package/dist/src/render/blocks/hero.d.ts +12 -0
- package/dist/src/render/blocks/hero.d.ts.map +1 -0
- package/dist/src/render/blocks/hero.js +25 -0
- package/dist/src/render/blocks/hero.js.map +1 -0
- package/dist/src/render/blocks/logos.d.ts +5 -0
- package/dist/src/render/blocks/logos.d.ts.map +1 -0
- package/dist/src/render/blocks/logos.js +25 -0
- package/dist/src/render/blocks/logos.js.map +1 -0
- package/dist/src/render/blocks/media-figure.d.ts +15 -0
- package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
- package/dist/src/render/blocks/media-figure.js +30 -0
- package/dist/src/render/blocks/media-figure.js.map +1 -0
- package/dist/src/render/blocks/prose.d.ts +10 -0
- package/dist/src/render/blocks/prose.d.ts.map +1 -0
- package/dist/src/render/blocks/prose.js +11 -0
- package/dist/src/render/blocks/prose.js.map +1 -0
- package/dist/src/render/blocks/quote.d.ts +14 -0
- package/dist/src/render/blocks/quote.d.ts.map +1 -0
- package/dist/src/render/blocks/quote.js +25 -0
- package/dist/src/render/blocks/quote.js.map +1 -0
- package/dist/src/render/blocks/stats.d.ts +5 -0
- package/dist/src/render/blocks/stats.d.ts.map +1 -0
- package/dist/src/render/blocks/stats.js +23 -0
- package/dist/src/render/blocks/stats.js.map +1 -0
- package/dist/src/render/entry.d.ts +7 -0
- package/dist/src/render/entry.d.ts.map +1 -0
- package/dist/src/render/entry.js +35 -0
- package/dist/src/render/entry.js.map +1 -0
- package/dist/src/render/heading.d.ts +14 -0
- package/dist/src/render/heading.d.ts.map +1 -0
- package/dist/src/render/heading.js +34 -0
- package/dist/src/render/heading.js.map +1 -0
- package/dist/src/render/html.d.ts +44 -0
- package/dist/src/render/html.d.ts.map +1 -0
- package/dist/src/render/html.js +96 -0
- package/dist/src/render/html.js.map +1 -0
- package/dist/src/render/media.d.ts +32 -0
- package/dist/src/render/media.d.ts.map +1 -0
- package/dist/src/render/media.js +42 -0
- package/dist/src/render/media.js.map +1 -0
- package/dist/src/render/render-block.d.ts +26 -0
- package/dist/src/render/render-block.d.ts.map +1 -0
- package/dist/src/render/render-block.js +66 -0
- package/dist/src/render/render-block.js.map +1 -0
- package/dist/src/render/rich-text.d.ts +5 -0
- package/dist/src/render/rich-text.d.ts.map +1 -0
- package/dist/src/render/rich-text.js +105 -0
- package/dist/src/render/rich-text.js.map +1 -0
- package/dist/src/theme-contract.d.ts +165 -0
- package/dist/src/theme-contract.d.ts.map +1 -0
- package/dist/src/theme-contract.js +21 -0
- package/dist/src/theme-contract.js.map +1 -0
- package/dist/theme.config.d.ts +31 -0
- package/dist/theme.config.d.ts.map +1 -0
- package/dist/theme.config.js +42 -0
- package/dist/theme.config.js.map +1 -0
- package/package.json +57 -0
- package/src/blocks/CollectionList.astro +20 -0
- package/src/blocks/Cta.astro +11 -0
- package/src/blocks/Embed.astro +18 -0
- package/src/blocks/Faq.astro +16 -0
- package/src/blocks/FeatureGrid.astro +11 -0
- package/src/blocks/Gallery.astro +17 -0
- package/src/blocks/Hero.astro +25 -0
- package/src/blocks/Logos.astro +11 -0
- package/src/blocks/MediaFigure.astro +11 -0
- package/src/blocks/Prose.astro +11 -0
- package/src/blocks/Quote.astro +11 -0
- package/src/blocks/Stats.astro +11 -0
- package/src/components/BlockZone.astro +62 -0
- package/src/index.ts +20 -0
- package/src/layouts/Base.astro +63 -0
- package/src/render/actions.ts +57 -0
- package/src/render/blocks/collection-list.ts +98 -0
- package/src/render/blocks/cta.ts +17 -0
- package/src/render/blocks/embed.ts +111 -0
- package/src/render/blocks/faq.ts +42 -0
- package/src/render/blocks/feature-grid.ts +54 -0
- package/src/render/blocks/gallery.ts +49 -0
- package/src/render/blocks/hero.ts +42 -0
- package/src/render/blocks/logos.ts +45 -0
- package/src/render/blocks/media-figure.ts +42 -0
- package/src/render/blocks/prose.ts +17 -0
- package/src/render/blocks/quote.ts +39 -0
- package/src/render/blocks/stats.ts +41 -0
- package/src/render/entry.ts +40 -0
- package/src/render/heading.ts +44 -0
- package/src/render/html.ts +122 -0
- package/src/render/media.ts +62 -0
- package/src/render/render-block.ts +97 -0
- package/src/render/rich-text.ts +150 -0
- package/src/styles/theme.css +606 -0
- package/src/theme-contract.ts +159 -0
- 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
|
+
}
|