@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,42 @@
|
|
|
1
|
+
import type { MediaFigureBlock } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { type HtmlElement, h } from '../html.js'
|
|
4
|
+
import { aspectRatio, image } from '../media.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `<figure>`/`<figcaption>` rather than a div and a paragraph: the association
|
|
8
|
+
* between the picture and its caption is then in the markup, and a screen
|
|
9
|
+
* reader announces the caption as belonging to the image instead of as loose
|
|
10
|
+
* text after it.
|
|
11
|
+
*
|
|
12
|
+
* `align` is written as a data attribute, never as a class: contract B's values
|
|
13
|
+
* are `start`/`end`, an intent that mirrors in right-to-left locales, and the
|
|
14
|
+
* skin decides what it means.
|
|
15
|
+
*/
|
|
16
|
+
export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
|
|
17
|
+
const ratio = aspectRatio(block.ratio)
|
|
18
|
+
const hasCaption = block.caption !== undefined || block.credit !== undefined
|
|
19
|
+
return h(
|
|
20
|
+
'figure',
|
|
21
|
+
{
|
|
22
|
+
class: 'cg-block cg-figure',
|
|
23
|
+
'data-block': 'mediaFigure',
|
|
24
|
+
'data-align': block.align ?? 'center',
|
|
25
|
+
style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
|
|
26
|
+
},
|
|
27
|
+
image(ctx, block.media, {
|
|
28
|
+
className: 'cg-figure__media',
|
|
29
|
+
sizes: '(min-width: 45rem) 40rem, 100vw',
|
|
30
|
+
}),
|
|
31
|
+
hasCaption
|
|
32
|
+
? h(
|
|
33
|
+
'figcaption',
|
|
34
|
+
{ class: 'cg-figure__caption' },
|
|
35
|
+
block.caption,
|
|
36
|
+
block.credit === undefined
|
|
37
|
+
? null
|
|
38
|
+
: h('span', { class: 'cg-figure__credit' }, block.credit),
|
|
39
|
+
)
|
|
40
|
+
: null,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProseBlock } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../../theme-contract.js'
|
|
3
|
+
import { type HtmlElement, h } from '../html.js'
|
|
4
|
+
import { renderRichText } from '../rich-text.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `prose` declares `headingLevel: 'none'`: it contributes no heading of its
|
|
8
|
+
* own. Whatever headings appear come from the rich text document, whose
|
|
9
|
+
* vocabulary starts at `h2`.
|
|
10
|
+
*/
|
|
11
|
+
export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
|
|
12
|
+
return h(
|
|
13
|
+
'div',
|
|
14
|
+
{ class: 'cg-block cg-prose', 'data-block': 'prose' },
|
|
15
|
+
renderRichText(ctx, block.body),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { QuoteBlock } 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
|
+
* `<figure><blockquote>…</blockquote><figcaption>` is the attribution pattern
|
|
8
|
+
* the HTML spec prescribes: putting the author inside the `<blockquote>` would
|
|
9
|
+
* claim the author's name is part of what was said.
|
|
10
|
+
*
|
|
11
|
+
* The avatar is decorative here — the name is right beside it in text — so its
|
|
12
|
+
* media entity's alt text is expected to be empty. `image` still writes the
|
|
13
|
+
* attribute either way.
|
|
14
|
+
*/
|
|
15
|
+
export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
|
|
16
|
+
const hasAttribution =
|
|
17
|
+
block.author !== undefined || block.role !== undefined || block.avatar !== undefined
|
|
18
|
+
return h(
|
|
19
|
+
'figure',
|
|
20
|
+
{ class: 'cg-block cg-quote', 'data-block': 'quote' },
|
|
21
|
+
h('blockquote', { class: 'cg-quote__text' }, h('p', {}, block.text)),
|
|
22
|
+
hasAttribution
|
|
23
|
+
? h(
|
|
24
|
+
'figcaption',
|
|
25
|
+
{ class: 'cg-quote__attribution' },
|
|
26
|
+
block.avatar === undefined
|
|
27
|
+
? null
|
|
28
|
+
: image(ctx, block.avatar, {
|
|
29
|
+
className: 'cg-quote__avatar',
|
|
30
|
+
variant: { width: 96, height: 96, fit: 'cover' },
|
|
31
|
+
}),
|
|
32
|
+
block.author === undefined
|
|
33
|
+
? null
|
|
34
|
+
: h('span', { class: 'cg-quote__author' }, block.author),
|
|
35
|
+
block.role === undefined ? null : h('span', { class: 'cg-quote__role' }, block.role),
|
|
36
|
+
)
|
|
37
|
+
: null,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { StatItem, StatsBlock } 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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A description list: each figure is the description of its label, which is
|
|
8
|
+
* exactly what `<dt>`/`<dd>` mean, and it survives being read linearly.
|
|
9
|
+
*
|
|
10
|
+
* The label comes first in the markup and the skin puts the figure above it —
|
|
11
|
+
* reading order and visual order stay independent, which is what WCAG 1.3.2
|
|
12
|
+
* asks for. A `<div>` wrapper around each pair is valid inside `<dl>` and is
|
|
13
|
+
* what makes that reordering possible without breaking the association.
|
|
14
|
+
*
|
|
15
|
+
* The unit is a separate span so the skin can size it apart, and the pair is
|
|
16
|
+
* announced as one string either way.
|
|
17
|
+
*/
|
|
18
|
+
function renderItem(item: StatItem): HtmlElement {
|
|
19
|
+
return h(
|
|
20
|
+
'div',
|
|
21
|
+
{ class: 'cg-stat' },
|
|
22
|
+
h('dt', { class: 'cg-stat__label' }, item.label),
|
|
23
|
+
h(
|
|
24
|
+
'dd',
|
|
25
|
+
{ class: 'cg-stat__value' },
|
|
26
|
+
item.value,
|
|
27
|
+
item.unit === undefined ? null : h('span', { class: 'cg-stat__unit' }, item.unit),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
|
|
33
|
+
return h(
|
|
34
|
+
'section',
|
|
35
|
+
{ class: 'cg-block cg-stats', 'data-block': 'stats' },
|
|
36
|
+
block.title === undefined
|
|
37
|
+
? null
|
|
38
|
+
: heading(blockHeadingTag('stats') ?? 'h2', { class: 'cg-stats__title' }, block.title),
|
|
39
|
+
h('dl', { class: 'cg-stats__items' }, block.items.map(renderItem)),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ContentEntry, RenderContext } from '../theme-contract.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract A fixes the system fields but not the schema-defined ones: which
|
|
5
|
+
* fields an entry has depends on its collection, and a theme that assumes
|
|
6
|
+
* `title` exists breaks on the first collection that calls it `name`.
|
|
7
|
+
*
|
|
8
|
+
* So every read of an unknown field goes through here, returns a fallback the
|
|
9
|
+
* visitor can live with, and never renders `undefined`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TITLE_FIELDS = ['title', 'name', 'label'] as const
|
|
13
|
+
|
|
14
|
+
export function entryTitle(entry: ContentEntry, ctx: RenderContext): string {
|
|
15
|
+
for (const field of TITLE_FIELDS) {
|
|
16
|
+
const value = entry[field]
|
|
17
|
+
if (typeof value === 'string' && value.trim() !== '') return value
|
|
18
|
+
}
|
|
19
|
+
return ctx.t('entry.untitled')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const EXCERPT_FIELDS = ['excerpt', 'summary', 'description'] as const
|
|
23
|
+
|
|
24
|
+
export function entryExcerpt(entry: ContentEntry): string | undefined {
|
|
25
|
+
for (const field of EXCERPT_FIELDS) {
|
|
26
|
+
const value = entry[field]
|
|
27
|
+
if (typeof value === 'string' && value.trim() !== '') return value
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** ISO 8601, for `<time datetime>`. Anything else is not a date this can use. */
|
|
33
|
+
export function entryDate(entry: ContentEntry): string | undefined {
|
|
34
|
+
const value = entry.publishedAt ?? entry.createdAt
|
|
35
|
+
return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function entryHref(entry: ContentEntry, ctx: RenderContext): string {
|
|
39
|
+
return ctx.link({ collection: entry.collection, id: entry.id })
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HeadingLevel } from '@cogenta/blocks'
|
|
2
|
+
import { VOCABULARY } from '@cogenta/blocks'
|
|
3
|
+
import { type Child, type HtmlElement, h } from './html.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heading level of a block is **not** a theme decision: contract B fixes it
|
|
7
|
+
* in each block's `a11y.headingLevel`, and the theme reads it from there rather
|
|
8
|
+
* than restating it. Restating it is how an outline drifts — one block gets
|
|
9
|
+
* promoted in the theme, the page grows a second `h1`, and nothing fails.
|
|
10
|
+
*/
|
|
11
|
+
const DECLARED_LEVELS: ReadonlyMap<string, HeadingLevel> = new Map(
|
|
12
|
+
VOCABULARY.map((block) => [block.name, block.a11y.headingLevel]),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
|
16
|
+
|
|
17
|
+
/** The level a block's own title is rendered at, or `null` if it carries none. */
|
|
18
|
+
export function blockHeadingTag(blockName: string): HeadingTag | null {
|
|
19
|
+
const level = DECLARED_LEVELS.get(blockName)
|
|
20
|
+
if (level === undefined || level === 'none') return null
|
|
21
|
+
return level
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The level for a title *inside* a block — a feature's name, a listed entry.
|
|
26
|
+
*
|
|
27
|
+
* One below the block's own heading when the block renders one, and at the
|
|
28
|
+
* block's declared level when it does not: a `featureGrid` without a title must
|
|
29
|
+
* not start its items at `h3`, which would skip a level.
|
|
30
|
+
*/
|
|
31
|
+
export function nestedHeadingTag(blockName: string, blockRendersHeading: boolean): HeadingTag {
|
|
32
|
+
const own = blockHeadingTag(blockName) ?? 'h2'
|
|
33
|
+
if (!blockRendersHeading) return own
|
|
34
|
+
const next = Number(own.slice(1)) + 1
|
|
35
|
+
return `h${Math.min(next, 6)}` as HeadingTag
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function heading(
|
|
39
|
+
tag: HeadingTag,
|
|
40
|
+
attrs: Readonly<Record<string, string | number | boolean | undefined>>,
|
|
41
|
+
...children: readonly Child[]
|
|
42
|
+
): HtmlElement {
|
|
43
|
+
return h(tag, attrs, ...children)
|
|
44
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal, dependency-free HTML tree and serialiser.
|
|
3
|
+
*
|
|
4
|
+
* Why the theme's markup is built here rather than written inline in the
|
|
5
|
+
* `.astro` files:
|
|
6
|
+
*
|
|
7
|
+
* 1. It is testable without an Astro toolchain — the twelve blocks get real
|
|
8
|
+
* snapshot tests, and the heading outline and the `alt` rule get asserted on
|
|
9
|
+
* the actual output rather than on a reading of the source.
|
|
10
|
+
* 2. There is **no way to emit raw HTML**. A block stores semantic data only
|
|
11
|
+
* (rule R3); with no `raw()` escape hatch, a string that arrives in a block
|
|
12
|
+
* field cannot become markup, whatever it contains.
|
|
13
|
+
*
|
|
14
|
+
* The `.astro` components in `src/blocks/` are the contract-D entry points and
|
|
15
|
+
* stay thin on purpose: one call each, so the two never drift.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type AttributeValue = string | number | boolean | undefined
|
|
19
|
+
|
|
20
|
+
export type Attributes = Readonly<Record<string, AttributeValue>>
|
|
21
|
+
|
|
22
|
+
export interface HtmlElement {
|
|
23
|
+
readonly kind: 'element'
|
|
24
|
+
readonly tag: string
|
|
25
|
+
readonly attrs: Attributes
|
|
26
|
+
readonly children: readonly HtmlNode[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HtmlText {
|
|
30
|
+
readonly kind: 'text'
|
|
31
|
+
readonly value: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type HtmlNode = HtmlElement | HtmlText
|
|
35
|
+
|
|
36
|
+
/** Anything a caller may pass as a child. `null`/`false` drop out, so a
|
|
37
|
+
* conditional child reads as `condition && node` without a wrapper. */
|
|
38
|
+
export type Child = HtmlNode | string | null | false | undefined | readonly Child[]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Elements that carry no children and no closing tag. Serialising `<img>` with
|
|
42
|
+
* one would produce markup no parser agrees on.
|
|
43
|
+
*/
|
|
44
|
+
const VOID_ELEMENTS: ReadonlySet<string> = new Set([
|
|
45
|
+
'area',
|
|
46
|
+
'base',
|
|
47
|
+
'br',
|
|
48
|
+
'col',
|
|
49
|
+
'embed',
|
|
50
|
+
'hr',
|
|
51
|
+
'img',
|
|
52
|
+
'input',
|
|
53
|
+
'link',
|
|
54
|
+
'meta',
|
|
55
|
+
'source',
|
|
56
|
+
'track',
|
|
57
|
+
'wbr',
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
export function text(value: string): HtmlText {
|
|
61
|
+
return { kind: 'text', value }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function flatten(children: readonly Child[], into: HtmlNode[]): void {
|
|
65
|
+
for (const child of children) {
|
|
66
|
+
if (child === null || child === false || child === undefined) continue
|
|
67
|
+
if (typeof child === 'string') {
|
|
68
|
+
into.push(text(child))
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(child)) {
|
|
72
|
+
flatten(child as readonly Child[], into)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
into.push(child as HtmlNode)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function h(tag: string, attrs: Attributes = {}, ...children: readonly Child[]): HtmlElement {
|
|
80
|
+
const collected: HtmlNode[] = []
|
|
81
|
+
flatten(children, collected)
|
|
82
|
+
return { kind: 'element', tag, attrs, children: collected }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `&` first, or every following replacement would be escaped twice.
|
|
87
|
+
* `<` and `>` cover text; the quotes are escaped in attributes only, where they
|
|
88
|
+
* are the ones that can break out.
|
|
89
|
+
*/
|
|
90
|
+
export function escapeText(value: string): string {
|
|
91
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function escapeAttribute(value: string): string {
|
|
95
|
+
return escapeText(value).replace(/"/g, '"').replace(/'/g, ''')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeAttributes(attrs: Attributes): string {
|
|
99
|
+
let out = ''
|
|
100
|
+
// Insertion order, not sorted: it is deterministic already, and an author
|
|
101
|
+
// reading a snapshot expects the order they wrote.
|
|
102
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
103
|
+
if (value === undefined || value === false) continue
|
|
104
|
+
if (value === true) {
|
|
105
|
+
out += ` ${name}`
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
out += ` ${name}="${escapeAttribute(String(value))}"`
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function serialize(node: HtmlNode): string {
|
|
114
|
+
if (node.kind === 'text') return escapeText(node.value)
|
|
115
|
+
const open = `<${node.tag}${serializeAttributes(node.attrs)}>`
|
|
116
|
+
if (VOID_ELEMENTS.has(node.tag)) return open
|
|
117
|
+
return `${open}${node.children.map(serialize).join('')}</${node.tag}>`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function serializeAll(nodes: readonly HtmlNode[]): string {
|
|
121
|
+
return nodes.map(serialize).join('')
|
|
122
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ImageOptions, MediaReference, RenderContext } from '../theme-contract.js'
|
|
2
|
+
import { type HtmlElement, h } from './html.js'
|
|
3
|
+
|
|
4
|
+
export interface ImageRenderOptions {
|
|
5
|
+
/** The `sizes` hint. Layout knowledge, so it belongs to the theme, not the block. */
|
|
6
|
+
readonly sizes?: string
|
|
7
|
+
/** `eager` only for what is above the fold — the hero, and nothing else. */
|
|
8
|
+
readonly loading?: 'lazy' | 'eager'
|
|
9
|
+
readonly className?: string
|
|
10
|
+
/**
|
|
11
|
+
* An accessible name the *block* provides — a logo's organisation name, for
|
|
12
|
+
* instance. It never invents alt text: it is used only where contract B says
|
|
13
|
+
* the block already carries the name.
|
|
14
|
+
*/
|
|
15
|
+
readonly altFrom?: string
|
|
16
|
+
readonly variant?: ImageOptions
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The only place the theme emits an `<img>`.
|
|
21
|
+
*
|
|
22
|
+
* `alt` is always written, never omitted: an image with no `alt` attribute is
|
|
23
|
+
* announced by its file name, while `alt=""` is announced as decorative. The
|
|
24
|
+
* difference is the whole of WCAG 1.1.1 here, and it cannot be left to whether
|
|
25
|
+
* a caller remembered.
|
|
26
|
+
*
|
|
27
|
+
* The alt text itself comes from the media entity through `ctx.image` — the
|
|
28
|
+
* theme has no business inventing one, and correcting it in the media library
|
|
29
|
+
* must fix every page at once.
|
|
30
|
+
*/
|
|
31
|
+
export function image(
|
|
32
|
+
ctx: RenderContext,
|
|
33
|
+
media: MediaReference,
|
|
34
|
+
options: ImageRenderOptions = {},
|
|
35
|
+
): HtmlElement {
|
|
36
|
+
const source = ctx.image(media, options.variant)
|
|
37
|
+
return h('img', {
|
|
38
|
+
class: options.className,
|
|
39
|
+
src: source.src,
|
|
40
|
+
srcset: source.srcset === '' ? undefined : source.srcset,
|
|
41
|
+
sizes: options.sizes,
|
|
42
|
+
width: source.width,
|
|
43
|
+
height: source.height,
|
|
44
|
+
alt: source.alt !== '' ? source.alt : (options.altFrom ?? ''),
|
|
45
|
+
loading: options.loading ?? 'lazy',
|
|
46
|
+
decoding: 'async',
|
|
47
|
+
// The focal point is content data, not a style value: it says which part of
|
|
48
|
+
// the picture must survive a crop. Nothing else can carry it per-image.
|
|
49
|
+
style:
|
|
50
|
+
source.focal === null
|
|
51
|
+
? undefined
|
|
52
|
+
: `object-position:${source.focal.x * 100}% ${source.focal.y * 100}%`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Contract B's framing values, as a CSS `aspect-ratio`. `original` means none. */
|
|
57
|
+
export function aspectRatio(ratio: string | undefined): string | undefined {
|
|
58
|
+
if (ratio === undefined || ratio === 'original') return undefined
|
|
59
|
+
const [width, height] = ratio.split(':')
|
|
60
|
+
if (width === undefined || height === undefined) return undefined
|
|
61
|
+
return `${width} / ${height}`
|
|
62
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { VocabularyBlock } from '@cogenta/blocks'
|
|
2
|
+
import type { ContentEntry, RenderContext } from '../theme-contract.js'
|
|
3
|
+
import { renderCollectionList } from './blocks/collection-list.js'
|
|
4
|
+
import { renderCta } from './blocks/cta.js'
|
|
5
|
+
import { renderEmbed } from './blocks/embed.js'
|
|
6
|
+
import { renderFaq } from './blocks/faq.js'
|
|
7
|
+
import { renderFeatureGrid } from './blocks/feature-grid.js'
|
|
8
|
+
import { renderGallery } from './blocks/gallery.js'
|
|
9
|
+
import { renderHero } from './blocks/hero.js'
|
|
10
|
+
import { renderLogos } from './blocks/logos.js'
|
|
11
|
+
import { renderMediaFigure } from './blocks/media-figure.js'
|
|
12
|
+
import { renderProse } from './blocks/prose.js'
|
|
13
|
+
import { renderQuote } from './blocks/quote.js'
|
|
14
|
+
import { renderStats } from './blocks/stats.js'
|
|
15
|
+
import { type HtmlElement, h } from './html.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Entries already fetched for the `collectionList` blocks on the page, by the
|
|
19
|
+
* block's `_key`. Fetching happens in the Astro frontmatter, once per block,
|
|
20
|
+
* before any markup is built — a renderer that could await would make the
|
|
21
|
+
* number of round trips depend on the markup.
|
|
22
|
+
*/
|
|
23
|
+
export type FetchedEntries = Readonly<Record<string, readonly ContentEntry[]>>
|
|
24
|
+
|
|
25
|
+
export function renderBlock(
|
|
26
|
+
block: VocabularyBlock,
|
|
27
|
+
ctx: RenderContext,
|
|
28
|
+
entries: FetchedEntries = {},
|
|
29
|
+
): HtmlElement | null {
|
|
30
|
+
switch (block._type) {
|
|
31
|
+
case 'hero':
|
|
32
|
+
return renderHero(block, ctx)
|
|
33
|
+
case 'prose':
|
|
34
|
+
return renderProse(block, ctx)
|
|
35
|
+
case 'mediaFigure':
|
|
36
|
+
return renderMediaFigure(block, ctx)
|
|
37
|
+
case 'featureGrid':
|
|
38
|
+
return renderFeatureGrid(block, ctx)
|
|
39
|
+
case 'cta':
|
|
40
|
+
return renderCta(block, ctx)
|
|
41
|
+
case 'gallery':
|
|
42
|
+
return renderGallery(block, ctx)
|
|
43
|
+
case 'quote':
|
|
44
|
+
return renderQuote(block, ctx)
|
|
45
|
+
case 'faq':
|
|
46
|
+
return renderFaq(block, ctx)
|
|
47
|
+
case 'stats':
|
|
48
|
+
return renderStats(block, ctx)
|
|
49
|
+
case 'logos':
|
|
50
|
+
return renderLogos(block, ctx)
|
|
51
|
+
case 'collectionList':
|
|
52
|
+
return renderCollectionList(block, ctx, entries[block._key] ?? [])
|
|
53
|
+
case 'embed':
|
|
54
|
+
return renderEmbed(block, ctx)
|
|
55
|
+
default: {
|
|
56
|
+
// Exhaustive over contract B's twelve: `block` is `never` here, so a
|
|
57
|
+
// thirteenth block stops this package compiling until it is implemented.
|
|
58
|
+
// Returning null rather than throwing is deliberate — a theme has no
|
|
59
|
+
// access to `@cogenta/core`'s error types (contract D refuses the import),
|
|
60
|
+
// and choosing a fallback block is the render layer's job, not the
|
|
61
|
+
// theme's.
|
|
62
|
+
const unreachable: never = block
|
|
63
|
+
void unreachable
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PageContent {
|
|
70
|
+
/** The entry's title. Rendered as the `h1` unless a hero already carries one. */
|
|
71
|
+
readonly title: string
|
|
72
|
+
readonly blocks: readonly VocabularyBlock[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A page has exactly one `h1`.
|
|
77
|
+
*
|
|
78
|
+
* A `hero` declares `headingLevel: 'h1'` and renders the title itself, so the
|
|
79
|
+
* layout must not render a second one; without a hero, nothing else on the page
|
|
80
|
+
* would — `prose` starts at `h2` — and the page would have no `h1` at all.
|
|
81
|
+
*/
|
|
82
|
+
export function pageHasOwnHeading(blocks: readonly VocabularyBlock[]): boolean {
|
|
83
|
+
return blocks.some((block) => block._type === 'hero')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderPage(
|
|
87
|
+
page: PageContent,
|
|
88
|
+
ctx: RenderContext,
|
|
89
|
+
entries: FetchedEntries = {},
|
|
90
|
+
): HtmlElement {
|
|
91
|
+
return h(
|
|
92
|
+
'main',
|
|
93
|
+
{ class: 'cg-main', id: 'cg-main' },
|
|
94
|
+
pageHasOwnHeading(page.blocks) ? null : h('h1', { class: 'cg-page__title' }, page.title),
|
|
95
|
+
page.blocks.map((block) => renderBlock(block, ctx, entries)),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { MarkDefinition, RichTextDocument, RichTextNode, Span } from '@cogenta/blocks'
|
|
2
|
+
import type { RenderContext } from '../theme-contract.js'
|
|
3
|
+
import { type HtmlNode, h, text } from './html.js'
|
|
4
|
+
import { image } from './media.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rich text is a structured document, never HTML (contract A, ADR-0013). This
|
|
8
|
+
* is the whole of the theme's mapping from that document to markup.
|
|
9
|
+
*
|
|
10
|
+
* `h1` cannot occur — the vocabulary starts at `h2` — so the page keeps exactly
|
|
11
|
+
* one `h1` without this module having to defend against anything.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type TextBlock = Extract<RichTextNode, { _type: 'block' }>
|
|
15
|
+
|
|
16
|
+
const KNOWN_MARKS: ReadonlyMap<string, string> = new Map([
|
|
17
|
+
['strong', 'strong'],
|
|
18
|
+
['em', 'em'],
|
|
19
|
+
['code', 'code'],
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
function applyMark(
|
|
23
|
+
ctx: RenderContext,
|
|
24
|
+
mark: string,
|
|
25
|
+
markDefs: readonly MarkDefinition[],
|
|
26
|
+
child: HtmlNode,
|
|
27
|
+
): HtmlNode {
|
|
28
|
+
const tag = KNOWN_MARKS.get(mark)
|
|
29
|
+
if (tag !== undefined) return h(tag, {}, child)
|
|
30
|
+
|
|
31
|
+
const definition = markDefs.find((candidate) => candidate._key === mark)
|
|
32
|
+
if (definition === undefined) {
|
|
33
|
+
// An annotation this theme does not know stays text. Guessing markup for it
|
|
34
|
+
// is how a mark becomes an injection vector.
|
|
35
|
+
return child
|
|
36
|
+
}
|
|
37
|
+
if (definition._type === 'link') {
|
|
38
|
+
return h('a', { href: ctx.link(definition.href), rel: definition.rel }, child)
|
|
39
|
+
}
|
|
40
|
+
return h('a', { href: ctx.link({ collection: definition.collection, id: definition.id }) }, child)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderSpan(ctx: RenderContext, span: Span, markDefs: readonly MarkDefinition[]): HtmlNode {
|
|
44
|
+
let node: HtmlNode = text(span.text)
|
|
45
|
+
// Applied in reverse so the first mark ends up outermost, which keeps
|
|
46
|
+
// `['link', 'strong']` rendering as a link containing bold text.
|
|
47
|
+
for (const mark of [...span.marks].reverse()) {
|
|
48
|
+
node = applyMark(ctx, mark, markDefs, node)
|
|
49
|
+
}
|
|
50
|
+
return node
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderChildren(ctx: RenderContext, block: TextBlock): readonly HtmlNode[] {
|
|
54
|
+
return block.children.map((span) => renderSpan(ctx, span, block.markDefs))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isListItem(node: RichTextNode | undefined): node is TextBlock {
|
|
58
|
+
return node !== undefined && node._type === 'block' && node.listItem !== undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function levelOf(node: TextBlock): number {
|
|
62
|
+
return node.level ?? 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds one list, and every list nested inside it, from a run of consecutive
|
|
67
|
+
* list items. A nested list lives *inside* the preceding `<li>` — putting it
|
|
68
|
+
* beside one produces a list whose items do not match what is announced.
|
|
69
|
+
*/
|
|
70
|
+
function buildList(
|
|
71
|
+
ctx: RenderContext,
|
|
72
|
+
nodes: RichTextDocument,
|
|
73
|
+
start: number,
|
|
74
|
+
level: number,
|
|
75
|
+
): { readonly node: HtmlNode; readonly next: number } {
|
|
76
|
+
const first = nodes[start]
|
|
77
|
+
if (!isListItem(first)) return { node: text(''), next: start + 1 }
|
|
78
|
+
const kind = first.listItem
|
|
79
|
+
const items: HtmlNode[] = []
|
|
80
|
+
let index = start
|
|
81
|
+
|
|
82
|
+
while (index < nodes.length) {
|
|
83
|
+
const node = nodes[index]
|
|
84
|
+
if (!isListItem(node)) break
|
|
85
|
+
const nodeLevel = levelOf(node)
|
|
86
|
+
if (nodeLevel < level || node.listItem !== kind) break
|
|
87
|
+
|
|
88
|
+
if (nodeLevel > level) {
|
|
89
|
+
const nested = buildList(ctx, nodes, index, nodeLevel)
|
|
90
|
+
const last = items.pop()
|
|
91
|
+
items.push(
|
|
92
|
+
last === undefined || last.kind !== 'element'
|
|
93
|
+
? h('li', {}, nested.node)
|
|
94
|
+
: h('li', last.attrs, ...last.children, nested.node),
|
|
95
|
+
)
|
|
96
|
+
index = nested.next
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
items.push(h('li', {}, ...renderChildren(ctx, node)))
|
|
101
|
+
index += 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { node: h(kind === 'number' ? 'ol' : 'ul', {}, items), next: index }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderTextBlock(ctx: RenderContext, block: TextBlock): HtmlNode {
|
|
108
|
+
const children = renderChildren(ctx, block)
|
|
109
|
+
if (block.style === 'blockquote') return h('blockquote', {}, h('p', {}, ...children))
|
|
110
|
+
if (block.style === 'normal') return h('p', {}, ...children)
|
|
111
|
+
return h(block.style, {}, ...children)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderRichText(
|
|
115
|
+
ctx: RenderContext,
|
|
116
|
+
document: RichTextDocument,
|
|
117
|
+
): readonly HtmlNode[] {
|
|
118
|
+
const out: HtmlNode[] = []
|
|
119
|
+
let index = 0
|
|
120
|
+
|
|
121
|
+
while (index < document.length) {
|
|
122
|
+
const node = document[index]
|
|
123
|
+
if (node === undefined) break
|
|
124
|
+
|
|
125
|
+
if (isListItem(node)) {
|
|
126
|
+
const list = buildList(ctx, document, index, levelOf(node))
|
|
127
|
+
out.push(list.node)
|
|
128
|
+
index = list.next
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (node._type === 'media') {
|
|
133
|
+
out.push(
|
|
134
|
+
h(
|
|
135
|
+
'figure',
|
|
136
|
+
{ class: 'cg-prose__figure' },
|
|
137
|
+
image(ctx, node.id, { sizes: '(min-width: 45rem) 40rem, 100vw' }),
|
|
138
|
+
node.caption === undefined ? null : h('figcaption', {}, node.caption),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
index += 1
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
out.push(renderTextBlock(ctx, node))
|
|
146
|
+
index += 1
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return out
|
|
150
|
+
}
|