@cogenta/theme-canonical 0.2.0 → 1.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 +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/render/actions.d.ts +2 -16
- package/dist/src/render/actions.d.ts.map +1 -1
- package/dist/src/render/actions.js +2 -42
- package/dist/src/render/actions.js.map +1 -1
- package/dist/src/render/blocks/accordion.d.ts +5 -0
- package/dist/src/render/blocks/accordion.d.ts.map +1 -0
- package/dist/src/render/blocks/accordion.js +19 -0
- package/dist/src/render/blocks/accordion.js.map +1 -0
- package/dist/src/render/blocks/collection-list.d.ts +9 -7
- package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
- package/dist/src/render/blocks/collection-list.js +8 -14
- package/dist/src/render/blocks/collection-list.js.map +1 -1
- package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
- package/dist/src/render/blocks/feature-grid.js +7 -2
- package/dist/src/render/blocks/feature-grid.js.map +1 -1
- package/dist/src/render/blocks/logo-strip.d.ts +5 -0
- package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
- package/dist/src/render/blocks/logo-strip.js +18 -0
- package/dist/src/render/blocks/logo-strip.js.map +1 -0
- package/dist/src/render/blocks/pricing-table.d.ts +5 -0
- package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
- package/dist/src/render/blocks/pricing-table.js +29 -0
- package/dist/src/render/blocks/pricing-table.js.map +1 -0
- package/dist/src/render/blocks/stat-counter.d.ts +5 -0
- package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
- package/dist/src/render/blocks/stat-counter.js +17 -0
- package/dist/src/render/blocks/stat-counter.js.map +1 -0
- package/dist/src/render/blocks/testimonial.d.ts +14 -0
- package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
- package/dist/src/render/blocks/testimonial.js +24 -0
- package/dist/src/render/blocks/testimonial.js.map +1 -0
- package/dist/src/render/chrome.d.ts +3 -0
- package/dist/src/render/chrome.d.ts.map +1 -0
- package/dist/src/render/chrome.js +82 -0
- package/dist/src/render/chrome.js.map +1 -0
- package/dist/src/render/comments.d.ts +3 -0
- package/dist/src/render/comments.d.ts.map +1 -0
- package/dist/src/render/comments.js +3 -0
- package/dist/src/render/comments.js.map +1 -0
- package/dist/src/render/entry.d.ts +2 -6
- package/dist/src/render/entry.d.ts.map +1 -1
- package/dist/src/render/entry.js +2 -34
- package/dist/src/render/entry.js.map +1 -1
- package/dist/src/render/heading.d.ts +2 -13
- package/dist/src/render/heading.d.ts.map +1 -1
- package/dist/src/render/heading.js +2 -33
- package/dist/src/render/heading.js.map +1 -1
- package/dist/src/render/html.d.ts +2 -43
- package/dist/src/render/html.d.ts.map +1 -1
- package/dist/src/render/html.js +2 -95
- package/dist/src/render/html.js.map +1 -1
- package/dist/src/render/icons.d.ts +3 -0
- package/dist/src/render/icons.d.ts.map +1 -0
- package/dist/src/render/icons.js +3 -0
- package/dist/src/render/icons.js.map +1 -0
- package/dist/src/render/media.d.ts +2 -31
- package/dist/src/render/media.d.ts.map +1 -1
- package/dist/src/render/media.js +2 -41
- package/dist/src/render/media.js.map +1 -1
- package/dist/src/render/render-block.d.ts +11 -22
- package/dist/src/render/render-block.d.ts.map +1 -1
- package/dist/src/render/render-block.js +63 -62
- package/dist/src/render/render-block.js.map +1 -1
- package/dist/src/render/rich-text.d.ts +2 -4
- package/dist/src/render/rich-text.d.ts.map +1 -1
- package/dist/src/render/rich-text.js +2 -104
- package/dist/src/render/rich-text.js.map +1 -1
- package/dist/src/render/term-archive.d.ts +12 -0
- package/dist/src/render/term-archive.d.ts.map +1 -0
- package/dist/src/render/term-archive.js +39 -0
- package/dist/src/render/term-archive.js.map +1 -0
- package/dist/src/theme-contract.d.ts +10 -162
- package/dist/src/theme-contract.d.ts.map +1 -1
- package/dist/src/theme-contract.js +9 -17
- package/dist/src/theme-contract.js.map +1 -1
- package/dist/theme.config.d.ts +5 -16
- package/dist/theme.config.d.ts.map +1 -1
- package/dist/theme.config.js +16 -5
- package/dist/theme.config.js.map +1 -1
- package/package.json +4 -2
- package/src/index.ts +4 -0
- package/src/render/actions.ts +2 -57
- package/src/render/blocks/accordion.ts +44 -0
- package/src/render/blocks/collection-list.ts +9 -15
- package/src/render/blocks/feature-grid.ts +11 -2
- package/src/render/blocks/logo-strip.ts +38 -0
- package/src/render/blocks/pricing-table.ts +65 -0
- package/src/render/blocks/stat-counter.ts +34 -0
- package/src/render/blocks/testimonial.ts +41 -0
- package/src/render/chrome.ts +105 -0
- package/src/render/comments.ts +6 -0
- package/src/render/entry.ts +2 -40
- package/src/render/heading.ts +7 -44
- package/src/render/html.ts +15 -122
- package/src/render/icons.ts +2 -0
- package/src/render/media.ts +2 -62
- package/src/render/render-block.ts +85 -75
- package/src/render/rich-text.ts +2 -150
- package/src/render/term-archive.ts +85 -0
- package/src/styles/archive.css +40 -0
- package/src/styles/base.css +525 -3
- package/src/styles/blocks.css +344 -14
- package/src/styles/theme.css +1 -0
- package/src/theme-contract.ts +23 -155
package/src/render/media.ts
CHANGED
|
@@ -1,62 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
}
|
|
1
|
+
/** Re-exported from `@cogenta/theme-kit` — see that package's `media.ts`. */
|
|
2
|
+
export { aspectRatio, type ImageRenderOptions, image } from '@cogenta/theme-kit'
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import
|
|
1
|
+
import { type BlockRegistry, VOCABULARY_NAMES, type VocabularyBlock } from '@cogenta/blocks'
|
|
2
|
+
import {
|
|
3
|
+
type FetchedEntries,
|
|
4
|
+
type PageContent,
|
|
5
|
+
pageHasOwnHeading,
|
|
6
|
+
renderEntryHeader,
|
|
7
|
+
resolveBlockForRender,
|
|
8
|
+
withBlockKey,
|
|
9
|
+
withBlockVariant,
|
|
10
|
+
} from '@cogenta/theme-kit'
|
|
11
|
+
import type { RenderContext } from '../theme-contract.js'
|
|
12
|
+
import { renderAccordion } from './blocks/accordion.js'
|
|
3
13
|
import { renderCollectionList } from './blocks/collection-list.js'
|
|
4
14
|
import { renderCta } from './blocks/cta.js'
|
|
5
15
|
import { renderEmbed } from './blocks/embed.js'
|
|
@@ -7,124 +17,124 @@ import { renderFaq } from './blocks/faq.js'
|
|
|
7
17
|
import { renderFeatureGrid } from './blocks/feature-grid.js'
|
|
8
18
|
import { renderGallery } from './blocks/gallery.js'
|
|
9
19
|
import { renderHero } from './blocks/hero.js'
|
|
20
|
+
import { renderLogoStrip } from './blocks/logo-strip.js'
|
|
10
21
|
import { renderLogos } from './blocks/logos.js'
|
|
11
22
|
import { renderMediaFigure } from './blocks/media-figure.js'
|
|
23
|
+
import { renderPricingTable } from './blocks/pricing-table.js'
|
|
12
24
|
import { renderProse } from './blocks/prose.js'
|
|
13
25
|
import { renderQuote } from './blocks/quote.js'
|
|
26
|
+
import { renderStatCounter } from './blocks/stat-counter.js'
|
|
14
27
|
import { renderStats } from './blocks/stats.js'
|
|
28
|
+
import { renderTestimonial } from './blocks/testimonial.js'
|
|
15
29
|
import { type HtmlElement, h } from './html.js'
|
|
16
30
|
|
|
17
31
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
32
|
+
* `PageContent`/`FetchedEntries`/`pageHasOwnHeading`/`withBlockKey` are
|
|
33
|
+
* `@cogenta/theme-kit`'s own — re-exported here (rather than only imported)
|
|
34
|
+
* so this package's public surface still names them, exactly as it did
|
|
35
|
+
* before they moved to the package every theme now shares them from.
|
|
22
36
|
*/
|
|
23
|
-
export type FetchedEntries
|
|
37
|
+
export type { FetchedEntries, PageContent }
|
|
38
|
+
export { pageHasOwnHeading, renderEntryHeader, withBlockKey }
|
|
24
39
|
|
|
25
40
|
export function renderBlock(
|
|
26
41
|
block: VocabularyBlock,
|
|
27
42
|
ctx: RenderContext,
|
|
28
43
|
entries: FetchedEntries = {},
|
|
44
|
+
registry?: BlockRegistry,
|
|
29
45
|
): HtmlElement | null {
|
|
30
|
-
|
|
46
|
+
// `block`'s type says `VocabularyBlock`, but the value crossing this
|
|
47
|
+
// boundary from stored content is not always literally one of the twelve —
|
|
48
|
+
// `theme-render.ts` cannot know at compile time what a block zone holds.
|
|
49
|
+
// Resolving here, once, is what turns an unimplemented theme-private block
|
|
50
|
+
// into its declared fallback instead of a silently blank slot.
|
|
51
|
+
const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
|
|
52
|
+
if (resolved === null) return null
|
|
53
|
+
const known = resolved as unknown as VocabularyBlock
|
|
54
|
+
// `withBlockVariant` (blocks@2.0, RFC 0002) is applied once here, after
|
|
55
|
+
// dispatch, rather than inside each of the seventeen block renderers —
|
|
56
|
+
// `variant` is envelope data every block carries identically, exactly the
|
|
57
|
+
// same reasoning `withBlockKey` in `renderPage` already follows for `_key`.
|
|
58
|
+
return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderKnownBlock(
|
|
62
|
+
known: VocabularyBlock,
|
|
63
|
+
ctx: RenderContext,
|
|
64
|
+
entries: FetchedEntries,
|
|
65
|
+
): HtmlElement | null {
|
|
66
|
+
switch (known._type) {
|
|
31
67
|
case 'hero':
|
|
32
|
-
return renderHero(
|
|
68
|
+
return renderHero(known, ctx)
|
|
33
69
|
case 'prose':
|
|
34
|
-
return renderProse(
|
|
70
|
+
return renderProse(known, ctx)
|
|
35
71
|
case 'mediaFigure':
|
|
36
|
-
return renderMediaFigure(
|
|
72
|
+
return renderMediaFigure(known, ctx)
|
|
37
73
|
case 'featureGrid':
|
|
38
|
-
return renderFeatureGrid(
|
|
74
|
+
return renderFeatureGrid(known, ctx)
|
|
39
75
|
case 'cta':
|
|
40
|
-
return renderCta(
|
|
76
|
+
return renderCta(known, ctx)
|
|
41
77
|
case 'gallery':
|
|
42
|
-
return renderGallery(
|
|
78
|
+
return renderGallery(known, ctx)
|
|
43
79
|
case 'quote':
|
|
44
|
-
return renderQuote(
|
|
80
|
+
return renderQuote(known, ctx)
|
|
45
81
|
case 'faq':
|
|
46
|
-
return renderFaq(
|
|
82
|
+
return renderFaq(known, ctx)
|
|
47
83
|
case 'stats':
|
|
48
|
-
return renderStats(
|
|
84
|
+
return renderStats(known, ctx)
|
|
49
85
|
case 'logos':
|
|
50
|
-
return renderLogos(
|
|
86
|
+
return renderLogos(known, ctx)
|
|
51
87
|
case 'collectionList':
|
|
52
|
-
return renderCollectionList(
|
|
88
|
+
return renderCollectionList(known, ctx, entries[known._key] ?? [])
|
|
53
89
|
case 'embed':
|
|
54
|
-
return renderEmbed(
|
|
90
|
+
return renderEmbed(known, ctx)
|
|
91
|
+
case 'testimonial':
|
|
92
|
+
return renderTestimonial(known, ctx)
|
|
93
|
+
case 'pricingTable':
|
|
94
|
+
return renderPricingTable(known, ctx)
|
|
95
|
+
case 'accordion':
|
|
96
|
+
return renderAccordion(known, ctx)
|
|
97
|
+
case 'statCounter':
|
|
98
|
+
return renderStatCounter(known, ctx)
|
|
99
|
+
case 'logoStrip':
|
|
100
|
+
return renderLogoStrip(known, ctx)
|
|
55
101
|
default: {
|
|
56
|
-
// Exhaustive over contract B's
|
|
57
|
-
//
|
|
102
|
+
// Exhaustive over contract B's vocabulary: `known` is `never` here, so a
|
|
103
|
+
// block this package does not implement stops it compiling until it is
|
|
104
|
+
// — every case above is either implemented directly, or the value was
|
|
105
|
+
// already resolved to something that is by `resolveBlockForRender`.
|
|
58
106
|
// Returning null rather than throwing is deliberate — a theme has no
|
|
59
107
|
// access to `@cogenta/core`'s error types (contract D refuses the import),
|
|
60
108
|
// and choosing a fallback block is the render layer's job, not the
|
|
61
109
|
// theme's.
|
|
62
|
-
const unreachable: never =
|
|
110
|
+
const unreachable: never = known
|
|
63
111
|
void unreachable
|
|
64
112
|
return null
|
|
65
113
|
}
|
|
66
114
|
}
|
|
67
115
|
}
|
|
68
116
|
|
|
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
|
-
/**
|
|
87
|
-
* Stamps the block's own `_key` onto the element it rendered to.
|
|
88
|
-
*
|
|
89
|
-
* The key is contract B's identity for a placed block — minted once, surviving
|
|
90
|
-
* reorder, translation and version restore — so it is the one honest way for a
|
|
91
|
-
* *reader* of the finished HTML to say "this piece of the page came from that
|
|
92
|
-
* block". The visual page builder (L16) is such a reader: it shows the real
|
|
93
|
-
* server-rendered page in an iframe and needs to map a clicked element back to
|
|
94
|
-
* the block that produced it.
|
|
95
|
-
*
|
|
96
|
-
* Emitted on every render, never only in a builder mode. A preview that is
|
|
97
|
-
* assembled differently from the published page is exactly the divergence the
|
|
98
|
-
* builder exists to rule out — one render path, one output, and the fidelity
|
|
99
|
-
* test can then assert byte equality rather than "close enough".
|
|
100
|
-
*
|
|
101
|
-
* It stays a data attribute and it stays out of the block renderers: no block
|
|
102
|
-
* has to remember it, and nothing about layout or styling depends on it.
|
|
103
|
-
*
|
|
104
|
-
* Its companion is `data-field`, written by the block renderers themselves on
|
|
105
|
-
* the single element that carries one plain-text field's whole value —
|
|
106
|
-
* `hero`'s title, `quote`'s author, and so on. A field only earns the
|
|
107
|
-
* attribute when it already had an element of its own: none of them changed
|
|
108
|
-
* shape to get it, so the outline, the styling and the snapshots are the same
|
|
109
|
-
* page they were, with one more attribute on some elements. Rich text and
|
|
110
|
-
* repeated list items carry none, which is the honest answer — a builder
|
|
111
|
-
* cannot edit them in place from a text node, and pretending otherwise would
|
|
112
|
-
* be the divergence all of this exists to avoid.
|
|
113
|
-
*/
|
|
114
|
-
function withBlockKey(element: HtmlElement | null, key: string): HtmlElement | null {
|
|
115
|
-
if (element === null) return null
|
|
116
|
-
return { ...element, attrs: { ...element.attrs, 'data-block-key': key } }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
117
|
export function renderPage(
|
|
120
118
|
page: PageContent,
|
|
121
119
|
ctx: RenderContext,
|
|
122
120
|
entries: FetchedEntries = {},
|
|
121
|
+
registry?: BlockRegistry,
|
|
123
122
|
): HtmlElement {
|
|
123
|
+
// `theme@1.4` (L25 D2): `renderEntryHeader` already returns `null` for a
|
|
124
|
+
// page with no `entry` meta *and* for one whose blocks already draw their
|
|
125
|
+
// own heading (a `hero`), so the bare `<h1 class="cg-page__title">`
|
|
126
|
+
// fallback below is the right markup in both of those cases and only
|
|
127
|
+
// those — never a double heading, and never a page with none at all.
|
|
128
|
+
const entryHeader = renderEntryHeader(page, ctx)
|
|
124
129
|
return h(
|
|
125
130
|
'main',
|
|
126
131
|
{ class: 'cg-main', id: 'cg-main' },
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
entryHeader,
|
|
133
|
+
entryHeader === null && !pageHasOwnHeading(page.blocks)
|
|
134
|
+
? h('h1', { class: 'cg-page__title' }, page.title)
|
|
135
|
+
: null,
|
|
136
|
+
page.blocks.map((block) =>
|
|
137
|
+
withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
|
|
138
|
+
),
|
|
129
139
|
)
|
|
130
140
|
}
|
package/src/render/rich-text.ts
CHANGED
|
@@ -1,150 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
}
|
|
1
|
+
/** Re-exported from `@cogenta/theme-kit` — see that package's `rich-text.ts`. */
|
|
2
|
+
export { renderRichText } from '@cogenta/theme-kit'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type HtmlElement, h, type TermArchiveInput } from '@cogenta/theme-kit'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The taxonomy-term archive (contract D `theme@1.3`).
|
|
5
|
+
*
|
|
6
|
+
* Built from this theme's *own* entry-card classes — the same `cg-entry`
|
|
7
|
+
* markup `collectionList` already renders — rather than a new set of
|
|
8
|
+
* archive-only classes: an archive of articles should look like a list of
|
|
9
|
+
* articles on this theme, and reusing the classes is what guarantees that
|
|
10
|
+
* without a second stylesheet to keep in step.
|
|
11
|
+
*/
|
|
12
|
+
export function renderTermArchive(input: TermArchiveInput): HtmlElement {
|
|
13
|
+
const items = input.entries.map((entry) =>
|
|
14
|
+
h(
|
|
15
|
+
'li',
|
|
16
|
+
{ class: 'cg-entry' },
|
|
17
|
+
h(
|
|
18
|
+
'div',
|
|
19
|
+
{ class: 'cg-entry__body' },
|
|
20
|
+
h(
|
|
21
|
+
'h2',
|
|
22
|
+
{ class: 'cg-entry__title' },
|
|
23
|
+
entry.href === null
|
|
24
|
+
? entry.title
|
|
25
|
+
: h('a', { class: 'cg-entry__link', href: entry.href }, entry.title),
|
|
26
|
+
),
|
|
27
|
+
entry.publishedAt === null
|
|
28
|
+
? null
|
|
29
|
+
: h(
|
|
30
|
+
'time',
|
|
31
|
+
{ class: 'cg-entry__date', datetime: entry.publishedAt },
|
|
32
|
+
entry.publishedAt.slice(0, 10),
|
|
33
|
+
),
|
|
34
|
+
entry.summary === null ? null : h('p', { class: 'cg-entry__excerpt' }, entry.summary),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return h(
|
|
40
|
+
'main',
|
|
41
|
+
{ class: 'cg-main cg-archive', id: 'cg-main' },
|
|
42
|
+
input.ancestors.length === 0
|
|
43
|
+
? null
|
|
44
|
+
: h(
|
|
45
|
+
'nav',
|
|
46
|
+
{ class: 'cg-archive__breadcrumb', 'aria-label': input.labels.breadcrumb },
|
|
47
|
+
h(
|
|
48
|
+
'ol',
|
|
49
|
+
{},
|
|
50
|
+
...input.ancestors.map((link) => h('li', {}, h('a', { href: link.href }, link.label))),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
h('h1', { class: 'cg-page__title' }, input.term.label),
|
|
54
|
+
input.children.length === 0
|
|
55
|
+
? null
|
|
56
|
+
: h(
|
|
57
|
+
'ul',
|
|
58
|
+
{ class: 'cg-archive__children', 'aria-label': input.labels.subterms },
|
|
59
|
+
...input.children.map((child) => h('li', {}, h('a', { href: child.href }, child.label))),
|
|
60
|
+
),
|
|
61
|
+
items.length === 0
|
|
62
|
+
? h('p', { class: 'cg-collection__empty' }, input.labels.empty)
|
|
63
|
+
: h('ul', { class: 'cg-collection__items' }, ...items),
|
|
64
|
+
renderPager(input),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Previous/next only — never a numbered pager. This renderer is handed the
|
|
70
|
+
* two hrefs the host resolved and no page-number list, so inventing one here
|
|
71
|
+
* would mean guessing URLs the host never said were valid.
|
|
72
|
+
*/
|
|
73
|
+
function renderPager(input: TermArchiveInput): HtmlElement | null {
|
|
74
|
+
if (input.page.previousHref === null && input.page.nextHref === null) return null
|
|
75
|
+
return h(
|
|
76
|
+
'nav',
|
|
77
|
+
{ class: 'cg-archive__pager', 'aria-label': input.labels.pagination },
|
|
78
|
+
input.page.previousHref === null
|
|
79
|
+
? null
|
|
80
|
+
: h('a', { rel: 'prev', href: input.page.previousHref }, input.labels.previous),
|
|
81
|
+
input.page.nextHref === null
|
|
82
|
+
? null
|
|
83
|
+
: h('a', { rel: 'next', href: input.page.nextHref }, input.labels.next),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The taxonomy-term archive (contract D `theme@1.3`).
|
|
3
|
+
*
|
|
4
|
+
* Only the three things an archive adds beyond what `collectionList` already
|
|
5
|
+
* styles — a breadcrumb, a sub-term row, a prev/next pager. The entry list
|
|
6
|
+
* itself reuses the `cg-entry`/`cg-collection__items` rules, on purpose: an
|
|
7
|
+
* archive of articles must look like this theme's list of articles, and a
|
|
8
|
+
* second set of card rules is exactly how those two drift apart.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.cg-archive {
|
|
12
|
+
max-inline-size: var(--cg-page);
|
|
13
|
+
margin-inline: auto;
|
|
14
|
+
padding: var(--cg-s10) var(--cg-gutter);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.cg-archive__breadcrumb ol,
|
|
18
|
+
.cg-archive__children {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
gap: var(--cg-s3);
|
|
22
|
+
margin: 0 0 var(--cg-s4);
|
|
23
|
+
padding: 0;
|
|
24
|
+
list-style: none;
|
|
25
|
+
font-size: var(--cg-text-sm);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.cg-archive__children {
|
|
29
|
+
margin-block: var(--cg-s4) var(--cg-s8);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.cg-archive__pager {
|
|
33
|
+
display: flex;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
gap: var(--cg-s4);
|
|
36
|
+
margin-block-start: var(--cg-s10);
|
|
37
|
+
padding-block-start: var(--cg-s5);
|
|
38
|
+
border-block-start: 1px solid var(--cg-line-soft);
|
|
39
|
+
font-weight: var(--cg-weight-semibold);
|
|
40
|
+
}
|