@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.
Files changed (108) hide show
  1. package/dist/src/index.d.ts +4 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +4 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/actions.d.ts +2 -16
  6. package/dist/src/render/actions.d.ts.map +1 -1
  7. package/dist/src/render/actions.js +2 -42
  8. package/dist/src/render/actions.js.map +1 -1
  9. package/dist/src/render/blocks/accordion.d.ts +5 -0
  10. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  11. package/dist/src/render/blocks/accordion.js +19 -0
  12. package/dist/src/render/blocks/accordion.js.map +1 -0
  13. package/dist/src/render/blocks/collection-list.d.ts +9 -7
  14. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  15. package/dist/src/render/blocks/collection-list.js +8 -14
  16. package/dist/src/render/blocks/collection-list.js.map +1 -1
  17. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  18. package/dist/src/render/blocks/feature-grid.js +7 -2
  19. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  20. package/dist/src/render/blocks/logo-strip.d.ts +5 -0
  21. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  22. package/dist/src/render/blocks/logo-strip.js +18 -0
  23. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  24. package/dist/src/render/blocks/pricing-table.d.ts +5 -0
  25. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  26. package/dist/src/render/blocks/pricing-table.js +29 -0
  27. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  28. package/dist/src/render/blocks/stat-counter.d.ts +5 -0
  29. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  30. package/dist/src/render/blocks/stat-counter.js +17 -0
  31. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  32. package/dist/src/render/blocks/testimonial.d.ts +14 -0
  33. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  34. package/dist/src/render/blocks/testimonial.js +24 -0
  35. package/dist/src/render/blocks/testimonial.js.map +1 -0
  36. package/dist/src/render/chrome.d.ts +3 -0
  37. package/dist/src/render/chrome.d.ts.map +1 -0
  38. package/dist/src/render/chrome.js +82 -0
  39. package/dist/src/render/chrome.js.map +1 -0
  40. package/dist/src/render/comments.d.ts +3 -0
  41. package/dist/src/render/comments.d.ts.map +1 -0
  42. package/dist/src/render/comments.js +3 -0
  43. package/dist/src/render/comments.js.map +1 -0
  44. package/dist/src/render/entry.d.ts +2 -6
  45. package/dist/src/render/entry.d.ts.map +1 -1
  46. package/dist/src/render/entry.js +2 -34
  47. package/dist/src/render/entry.js.map +1 -1
  48. package/dist/src/render/heading.d.ts +2 -13
  49. package/dist/src/render/heading.d.ts.map +1 -1
  50. package/dist/src/render/heading.js +2 -33
  51. package/dist/src/render/heading.js.map +1 -1
  52. package/dist/src/render/html.d.ts +2 -43
  53. package/dist/src/render/html.d.ts.map +1 -1
  54. package/dist/src/render/html.js +2 -95
  55. package/dist/src/render/html.js.map +1 -1
  56. package/dist/src/render/icons.d.ts +3 -0
  57. package/dist/src/render/icons.d.ts.map +1 -0
  58. package/dist/src/render/icons.js +3 -0
  59. package/dist/src/render/icons.js.map +1 -0
  60. package/dist/src/render/media.d.ts +2 -31
  61. package/dist/src/render/media.d.ts.map +1 -1
  62. package/dist/src/render/media.js +2 -41
  63. package/dist/src/render/media.js.map +1 -1
  64. package/dist/src/render/render-block.d.ts +11 -22
  65. package/dist/src/render/render-block.d.ts.map +1 -1
  66. package/dist/src/render/render-block.js +63 -62
  67. package/dist/src/render/render-block.js.map +1 -1
  68. package/dist/src/render/rich-text.d.ts +2 -4
  69. package/dist/src/render/rich-text.d.ts.map +1 -1
  70. package/dist/src/render/rich-text.js +2 -104
  71. package/dist/src/render/rich-text.js.map +1 -1
  72. package/dist/src/render/term-archive.d.ts +12 -0
  73. package/dist/src/render/term-archive.d.ts.map +1 -0
  74. package/dist/src/render/term-archive.js +39 -0
  75. package/dist/src/render/term-archive.js.map +1 -0
  76. package/dist/src/theme-contract.d.ts +10 -162
  77. package/dist/src/theme-contract.d.ts.map +1 -1
  78. package/dist/src/theme-contract.js +9 -17
  79. package/dist/src/theme-contract.js.map +1 -1
  80. package/dist/theme.config.d.ts +5 -16
  81. package/dist/theme.config.d.ts.map +1 -1
  82. package/dist/theme.config.js +16 -5
  83. package/dist/theme.config.js.map +1 -1
  84. package/package.json +4 -2
  85. package/src/index.ts +4 -0
  86. package/src/render/actions.ts +2 -57
  87. package/src/render/blocks/accordion.ts +44 -0
  88. package/src/render/blocks/collection-list.ts +9 -15
  89. package/src/render/blocks/feature-grid.ts +11 -2
  90. package/src/render/blocks/logo-strip.ts +38 -0
  91. package/src/render/blocks/pricing-table.ts +65 -0
  92. package/src/render/blocks/stat-counter.ts +34 -0
  93. package/src/render/blocks/testimonial.ts +41 -0
  94. package/src/render/chrome.ts +105 -0
  95. package/src/render/comments.ts +6 -0
  96. package/src/render/entry.ts +2 -40
  97. package/src/render/heading.ts +7 -44
  98. package/src/render/html.ts +15 -122
  99. package/src/render/icons.ts +2 -0
  100. package/src/render/media.ts +2 -62
  101. package/src/render/render-block.ts +85 -75
  102. package/src/render/rich-text.ts +2 -150
  103. package/src/render/term-archive.ts +85 -0
  104. package/src/styles/archive.css +40 -0
  105. package/src/styles/base.css +525 -3
  106. package/src/styles/blocks.css +344 -14
  107. package/src/styles/theme.css +1 -0
  108. package/src/theme-contract.ts +23 -155
@@ -1,62 +1,2 @@
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
- }
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 { VocabularyBlock } from '@cogenta/blocks'
2
- import type { ContentEntry, RenderContext } from '../theme-contract.js'
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
- * 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.
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 = Readonly<Record<string, readonly ContentEntry[]>>
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
- switch (block._type) {
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(block, ctx)
68
+ return renderHero(known, ctx)
33
69
  case 'prose':
34
- return renderProse(block, ctx)
70
+ return renderProse(known, ctx)
35
71
  case 'mediaFigure':
36
- return renderMediaFigure(block, ctx)
72
+ return renderMediaFigure(known, ctx)
37
73
  case 'featureGrid':
38
- return renderFeatureGrid(block, ctx)
74
+ return renderFeatureGrid(known, ctx)
39
75
  case 'cta':
40
- return renderCta(block, ctx)
76
+ return renderCta(known, ctx)
41
77
  case 'gallery':
42
- return renderGallery(block, ctx)
78
+ return renderGallery(known, ctx)
43
79
  case 'quote':
44
- return renderQuote(block, ctx)
80
+ return renderQuote(known, ctx)
45
81
  case 'faq':
46
- return renderFaq(block, ctx)
82
+ return renderFaq(known, ctx)
47
83
  case 'stats':
48
- return renderStats(block, ctx)
84
+ return renderStats(known, ctx)
49
85
  case 'logos':
50
- return renderLogos(block, ctx)
86
+ return renderLogos(known, ctx)
51
87
  case 'collectionList':
52
- return renderCollectionList(block, ctx, entries[block._key] ?? [])
88
+ return renderCollectionList(known, ctx, entries[known._key] ?? [])
53
89
  case 'embed':
54
- return renderEmbed(block, ctx)
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 twelve: `block` is `never` here, so a
57
- // thirteenth block stops this package compiling until it is implemented.
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 = block
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
- pageHasOwnHeading(page.blocks) ? null : h('h1', { class: 'cg-page__title' }, page.title),
128
- page.blocks.map((block) => withBlockKey(renderBlock(block, ctx, entries), block._key)),
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
  }
@@ -1,150 +1,2 @@
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
- }
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
+ }