@cogenta/theme-magazine 1.1.2

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 (117) hide show
  1. package/LICENSE +373 -0
  2. package/dist/src/index.d.ts +21 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +21 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/render/blocks/accordion.d.ts +4 -0
  7. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  8. package/dist/src/render/blocks/accordion.js +20 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +5 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +134 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +10 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +16 -0
  17. package/dist/src/render/blocks/cta.js.map +1 -0
  18. package/dist/src/render/blocks/embed.d.ts +4 -0
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  20. package/dist/src/render/blocks/embed.js +76 -0
  21. package/dist/src/render/blocks/embed.js.map +1 -0
  22. package/dist/src/render/blocks/faq.d.ts +4 -0
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  24. package/dist/src/render/blocks/faq.js +18 -0
  25. package/dist/src/render/blocks/faq.js.map +1 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts +4 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  28. package/dist/src/render/blocks/feature-grid.js +27 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +13 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +26 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +11 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +26 -0
  37. package/dist/src/render/blocks/hero.js.map +1 -0
  38. package/dist/src/render/blocks/logo-strip.d.ts +4 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  40. package/dist/src/render/blocks/logo-strip.js +20 -0
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  42. package/dist/src/render/blocks/logos.d.ts +4 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  44. package/dist/src/render/blocks/logos.js +27 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +15 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +32 -0
  49. package/dist/src/render/blocks/media-figure.js.map +1 -0
  50. package/dist/src/render/blocks/pricing-table.d.ts +4 -0
  51. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  52. package/dist/src/render/blocks/pricing-table.js +32 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +16 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +17 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +16 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +29 -0
  61. package/dist/src/render/blocks/quote.js.map +1 -0
  62. package/dist/src/render/blocks/stat-counter.d.ts +4 -0
  63. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  64. package/dist/src/render/blocks/stat-counter.js +17 -0
  65. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  66. package/dist/src/render/blocks/stats.d.ts +4 -0
  67. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  68. package/dist/src/render/blocks/stats.js +19 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +19 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +28 -0
  73. package/dist/src/render/blocks/testimonial.js.map +1 -0
  74. package/dist/src/render/chrome.d.ts +3 -0
  75. package/dist/src/render/chrome.d.ts.map +1 -0
  76. package/dist/src/render/chrome.js +136 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +21 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +104 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +13 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +35 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +12 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +42 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +20 -0
  92. package/src/render/blocks/accordion.ts +51 -0
  93. package/src/render/blocks/collection-list.ts +286 -0
  94. package/src/render/blocks/cta.ts +33 -0
  95. package/src/render/blocks/embed.ts +99 -0
  96. package/src/render/blocks/faq.ts +58 -0
  97. package/src/render/blocks/feature-grid.ts +61 -0
  98. package/src/render/blocks/gallery.ts +46 -0
  99. package/src/render/blocks/hero.ts +54 -0
  100. package/src/render/blocks/logo-strip.ts +35 -0
  101. package/src/render/blocks/logos.ts +57 -0
  102. package/src/render/blocks/media-figure.ts +43 -0
  103. package/src/render/blocks/pricing-table.ts +76 -0
  104. package/src/render/blocks/prose.ts +22 -0
  105. package/src/render/blocks/quote.ts +46 -0
  106. package/src/render/blocks/stat-counter.ts +39 -0
  107. package/src/render/blocks/stats.ts +46 -0
  108. package/src/render/blocks/testimonial.ts +44 -0
  109. package/src/render/chrome.ts +162 -0
  110. package/src/render/render-block.ts +137 -0
  111. package/src/render/term-archive.ts +87 -0
  112. package/src/styles/archive.css +40 -0
  113. package/src/styles/base.css +1037 -0
  114. package/src/styles/blocks.css +1335 -0
  115. package/src/styles/theme.css +18 -0
  116. package/src/styles/tokens.css +308 -0
  117. package/tokens.json +36 -0
@@ -0,0 +1,35 @@
1
+ import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `blocks@2.0` (RFC 0001). The lighter-weight "as used by" strip next to
6
+ * `logos`'s own press row (`cg-press`, which titles itself and links each
7
+ * mark to its organisation): `logoStrip` has no per-logo name or url, so it
8
+ * carries no title and no links — a plain band of marks with an optional
9
+ * caption set underneath, the way a colophon credits its supporters along
10
+ * the bottom of a masthead page. Each image's own alt text (set once, in
11
+ * the media library) is what names it, exactly as `logos` already falls
12
+ * back to when a link is absent.
13
+ */
14
+ function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
15
+ return h(
16
+ 'li',
17
+ { class: 'cg-imprint__item' },
18
+ image(ctx, item.media, { className: 'cg-imprint__image', variant: { fit: 'contain' } }),
19
+ )
20
+ }
21
+
22
+ export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
23
+ return h(
24
+ 'figure',
25
+ { class: 'cg-block cg-imprint', 'data-block': 'logoStrip' },
26
+ h(
27
+ 'ul',
28
+ { class: 'cg-imprint__items' },
29
+ block.logos.map((item) => renderItem(item, ctx)),
30
+ ),
31
+ block.caption === undefined
32
+ ? null
33
+ : h('figcaption', { class: 'cg-imprint__caption', 'data-field': 'caption' }, block.caption),
34
+ )
35
+ }
@@ -0,0 +1,57 @@
1
+ import type { LogoItem, LogosBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ image,
8
+ type RenderContext,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * A press strip — "As seen in" — a single row divided by hairline rules
13
+ * rather than a card grid, the way a masthead credits its syndication
14
+ * partners along one line.
15
+ *
16
+ * The organisation's name is contract B's own accessible name for the link:
17
+ * the logo image carries it as `alt` when the media entity has none, so no
18
+ * visually hidden duplicate text is needed — a link whose only content is an
19
+ * image with `alt` text is already named.
20
+ */
21
+ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
22
+ const logo = image(ctx, item.media, {
23
+ className: 'cg-press__image',
24
+ altFrom: item.name,
25
+ variant: { fit: 'contain' },
26
+ })
27
+ return h(
28
+ 'li',
29
+ { class: 'cg-press__item' },
30
+ item.url === undefined
31
+ ? logo
32
+ : h(
33
+ 'a',
34
+ { class: 'cg-press__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
35
+ logo,
36
+ ),
37
+ )
38
+ }
39
+
40
+ export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
41
+ return h(
42
+ 'section',
43
+ { class: 'cg-block cg-press', 'data-block': 'logos' },
44
+ block.title === undefined
45
+ ? null
46
+ : heading(
47
+ blockHeadingTag('logos') ?? 'h2',
48
+ { class: 'cg-press__title', 'data-field': 'title' },
49
+ block.title,
50
+ ),
51
+ h(
52
+ 'ul',
53
+ { class: 'cg-press__items' },
54
+ block.items.map((item) => renderItem(item, ctx)),
55
+ ),
56
+ )
57
+ }
@@ -0,0 +1,43 @@
1
+ import type { MediaFigureBlock } from '@cogenta/blocks'
2
+ import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * A plate, in the print sense: the picture runs large, and the caption sits
6
+ * under a hairline rule set in small caps with the credit line trailing it —
7
+ * the way a photo essay is captioned, rather than a card with a paragraph
8
+ * under it.
9
+ *
10
+ * `<figure>`/`<figcaption>` keep the caption announced as belonging to the
11
+ * image; `align` stays a data attribute (contract B's intent, not a class),
12
+ * so the skin — here, this theme — decides what `start`/`end`/`wide`/`full`
13
+ * mean.
14
+ */
15
+ export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
16
+ const ratio = aspectRatio(block.ratio)
17
+ const hasCaption = block.caption !== undefined || block.credit !== undefined
18
+ return h(
19
+ 'figure',
20
+ {
21
+ class: 'cg-block cg-plate',
22
+ 'data-block': 'mediaFigure',
23
+ 'data-align': block.align ?? 'center',
24
+ style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
25
+ },
26
+ image(ctx, block.media, {
27
+ className: 'cg-plate__media',
28
+ sizes: '(min-width: 60rem) 48rem, 100vw',
29
+ }),
30
+ hasCaption
31
+ ? h(
32
+ 'figcaption',
33
+ { class: 'cg-plate__caption' },
34
+ block.caption === undefined
35
+ ? null
36
+ : h('span', { class: 'cg-plate__text' }, block.caption),
37
+ block.credit === undefined
38
+ ? null
39
+ : h('span', { class: 'cg-plate__credit', 'data-field': 'credit' }, block.credit),
40
+ )
41
+ : null,
42
+ )
43
+ }
@@ -0,0 +1,76 @@
1
+ import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
2
+ import {
3
+ actionLink,
4
+ blockHeadingTag,
5
+ type HeadingTag,
6
+ type HtmlElement,
7
+ h,
8
+ heading,
9
+ nestedHeadingTag,
10
+ type RenderContext,
11
+ } from '@cogenta/theme-kit'
12
+
13
+ /**
14
+ * `blocks@2.0` (RFC 0001). A classified-ads rate card: tiers set side by
15
+ * side and separated by the theme's one visible column rule (the same
16
+ * device `hero` and `collectionList` use), rather than a set of raised
17
+ * cards — a print page has no elevation to raise a card off of.
18
+ *
19
+ * `highlighted` is an editorial signal, not a colour: it becomes
20
+ * `data-highlighted` for the stylesheet to pick up (an ink-reversed panel,
21
+ * here) and `aria-current` so it is announced, not only shown.
22
+ */
23
+ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
24
+ return h(
25
+ 'li',
26
+ {
27
+ class: 'cg-ratecard__tier',
28
+ 'data-highlighted': tier.highlighted === true ? 'true' : undefined,
29
+ 'aria-current': tier.highlighted === true ? 'true' : undefined,
30
+ },
31
+ heading(tag, { class: 'cg-ratecard__name' }, tier.name),
32
+ h(
33
+ 'p',
34
+ { class: 'cg-ratecard__price' },
35
+ h('span', { class: 'cg-ratecard__amount' }, tier.price),
36
+ tier.interval === undefined
37
+ ? null
38
+ : h('span', { class: 'cg-ratecard__interval' }, tier.interval),
39
+ ),
40
+ tier.features.length === 0
41
+ ? null
42
+ : h(
43
+ 'ul',
44
+ { class: 'cg-ratecard__features' },
45
+ tier.features.map((feature) => h('li', { class: 'cg-ratecard__feature' }, feature)),
46
+ ),
47
+ tier.action === undefined
48
+ ? null
49
+ : h(
50
+ 'div',
51
+ { class: 'cg-ratecard__action' },
52
+ actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
53
+ ),
54
+ )
55
+ }
56
+
57
+ export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
58
+ const hasTitle = block.title !== undefined
59
+ const tierTag = nestedHeadingTag('pricingTable', hasTitle)
60
+ return h(
61
+ 'section',
62
+ { class: 'cg-block cg-ratecard', 'data-block': 'pricingTable' },
63
+ hasTitle
64
+ ? heading(
65
+ blockHeadingTag('pricingTable') ?? 'h2',
66
+ { class: 'cg-ratecard__title', 'data-field': 'title' },
67
+ block.title ?? '',
68
+ )
69
+ : null,
70
+ h(
71
+ 'ul',
72
+ { class: 'cg-ratecard__tiers' },
73
+ block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
74
+ ),
75
+ )
76
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProseBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `prose` declares `headingLevel: 'none'` — it contributes no heading of its
6
+ * own, and `renderRichText` (shared, `@cogenta/theme-kit`) is what actually
7
+ * turns the document into markup, including its embedded figures
8
+ * (`.cg-prose__figure`, a class name that module already fixes — this file's
9
+ * own container class has to match it).
10
+ *
11
+ * The drop cap and the pull-quote treatment on a nested `<blockquote>` are
12
+ * both pure CSS, keyed off `.cg-prose > :first-child` and `.cg-prose
13
+ * blockquote` in `blocks.css` — nothing here has to know which paragraph
14
+ * came first.
15
+ */
16
+ export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
17
+ return h(
18
+ 'div',
19
+ { class: 'cg-block cg-prose', 'data-block': 'prose' },
20
+ renderRichText(ctx, block.body),
21
+ )
22
+ }
@@ -0,0 +1,46 @@
1
+ import type { QuoteBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * The pull quote — the one typographic gesture a magazine spread always
6
+ * makes. Set large in the display serif, indented from a heavy left rule
7
+ * rather than centred under a giant glyph, with the attribution trailing it
8
+ * on its own line, joined by a typographic em-rule instead of a comma the
9
+ * markup does not carry.
10
+ *
11
+ * `<figure><blockquote>…</blockquote><figcaption>` is the attribution
12
+ * pattern the HTML spec itself prescribes — the author's name sitting
13
+ * outside the `<blockquote>` is what stops it from being claimed as part of
14
+ * what was said.
15
+ */
16
+ export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
17
+ const hasAttribution =
18
+ block.author !== undefined || block.role !== undefined || block.avatar !== undefined
19
+ return h(
20
+ 'figure',
21
+ { class: 'cg-block cg-pullquote', 'data-block': 'quote' },
22
+ h('blockquote', { class: 'cg-pullquote__text' }, h('p', { 'data-field': 'text' }, block.text)),
23
+ hasAttribution
24
+ ? h(
25
+ 'figcaption',
26
+ { class: 'cg-pullquote__attribution' },
27
+ block.avatar === undefined
28
+ ? null
29
+ : image(ctx, block.avatar, {
30
+ className: 'cg-pullquote__avatar',
31
+ variant: { width: 96, height: 96, fit: 'cover' },
32
+ }),
33
+ h(
34
+ 'span',
35
+ { class: 'cg-pullquote__names' },
36
+ block.author === undefined
37
+ ? null
38
+ : h('span', { class: 'cg-pullquote__author', 'data-field': 'author' }, block.author),
39
+ block.role === undefined
40
+ ? null
41
+ : h('span', { class: 'cg-pullquote__role', 'data-field': 'role' }, block.role),
42
+ ),
43
+ )
44
+ : null,
45
+ )
46
+ }
@@ -0,0 +1,39 @@
1
+ import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ type RenderContext,
8
+ } from '@cogenta/theme-kit'
9
+
10
+ /**
11
+ * `blocks@2.0` (RFC 0001). A circulation tally, set apart from `stats`'s own
12
+ * `cg-figures` (which carries vertical column rules and an optional `unit`):
13
+ * `statCounter` has no `unit`, so it is laid out as a single running line —
14
+ * each figure and its label separated only by a shared baseline rule, the
15
+ * way a masthead prints its circulation and edition counts along one strip.
16
+ */
17
+ function renderItem(item: StatCounterItem): HtmlElement {
18
+ return h(
19
+ 'div',
20
+ { class: 'cg-tally__item' },
21
+ h('dd', { class: 'cg-tally__value' }, item.value),
22
+ h('dt', { class: 'cg-tally__label' }, item.label),
23
+ )
24
+ }
25
+
26
+ export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
27
+ return h(
28
+ 'section',
29
+ { class: 'cg-block cg-tally', 'data-block': 'statCounter' },
30
+ block.title === undefined
31
+ ? null
32
+ : heading(
33
+ blockHeadingTag('statCounter') ?? 'h2',
34
+ { class: 'cg-tally__title', 'data-field': 'title' },
35
+ block.title,
36
+ ),
37
+ h('dl', { class: 'cg-tally__items' }, block.stats.map(renderItem)),
38
+ )
39
+ }
@@ -0,0 +1,46 @@
1
+ import type { StatItem, StatsBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ type RenderContext,
8
+ } from '@cogenta/theme-kit'
9
+
10
+ /**
11
+ * "By the numbers" — an infographic sidebar with a real newspaper
12
+ * `column-rule` between figures, each set in the display serif with tabular
13
+ * numerals so a column of changing digits never shifts width.
14
+ *
15
+ * A description list, as in the reference theme: each figure *describes* its
16
+ * label, which is exactly what `<dt>`/`<dd>` mean, and it survives being read
17
+ * linearly by anything that ignores the stylesheet.
18
+ */
19
+ function renderItem(item: StatItem): HtmlElement {
20
+ return h(
21
+ 'div',
22
+ { class: 'cg-figures__item' },
23
+ h('dt', { class: 'cg-figures__label' }, item.label),
24
+ h(
25
+ 'dd',
26
+ { class: 'cg-figures__value' },
27
+ item.value,
28
+ item.unit === undefined ? null : h('span', { class: 'cg-figures__unit' }, item.unit),
29
+ ),
30
+ )
31
+ }
32
+
33
+ export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
34
+ return h(
35
+ 'section',
36
+ { class: 'cg-block cg-figures', 'data-block': 'stats' },
37
+ block.title === undefined
38
+ ? null
39
+ : heading(
40
+ blockHeadingTag('stats') ?? 'h2',
41
+ { class: 'cg-figures__title', 'data-field': 'title' },
42
+ block.title,
43
+ ),
44
+ h('dl', { class: 'cg-figures__items' }, block.items.map(renderItem)),
45
+ )
46
+ }
@@ -0,0 +1,44 @@
1
+ import type { TestimonialBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `blocks@2.0` (RFC 0001). A reader's letter, not a pull quote: `quote`
6
+ * already owns the theme's one "loud" typographic gesture (the giant quote
7
+ * mark and heavy rule in `cg-pullquote`), so a testimonial reads instead as
8
+ * a boxed letter-to-the-editor — a framed panel, the quote set in italic
9
+ * serif at body size rather than blown up, and a byline row underneath a
10
+ * hairline rule, the way a magazine runs its reader mail column.
11
+ *
12
+ * The quote is rich text (unlike `quote`'s plain-text `text`), so a
13
+ * testimonial can carry a link or emphasis; `renderRichText` handles it the
14
+ * same way `accordion`'s answers and `prose`'s body do.
15
+ *
16
+ * The avatar is decorative, exactly like `quote`'s own: the name sits right
17
+ * beside it in text, so its media entity's alt text is expected to be empty.
18
+ */
19
+ export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
20
+ const { attribution } = block
21
+ return h(
22
+ 'figure',
23
+ { class: 'cg-block cg-letter', 'data-block': 'testimonial' },
24
+ h('blockquote', { class: 'cg-letter__quote' }, renderRichText(ctx, block.quote)),
25
+ h(
26
+ 'figcaption',
27
+ { class: 'cg-letter__byline' },
28
+ attribution.avatar === undefined
29
+ ? null
30
+ : image(ctx, attribution.avatar, {
31
+ className: 'cg-letter__avatar',
32
+ variant: { width: 96, height: 96, fit: 'cover' },
33
+ }),
34
+ h(
35
+ 'span',
36
+ { class: 'cg-letter__names' },
37
+ h('span', { class: 'cg-letter__name' }, attribution.name),
38
+ attribution.role === undefined
39
+ ? null
40
+ : h('span', { class: 'cg-letter__role' }, attribution.role),
41
+ ),
42
+ ),
43
+ )
44
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ type ChromeInput,
3
+ type ChromeNavLink,
4
+ type ChromeResult,
5
+ escapeAttribute,
6
+ escapeText,
7
+ renderBrandMark,
8
+ renderSocialLinks,
9
+ renderThemeToggle,
10
+ serialize,
11
+ } from '@cogenta/theme-kit'
12
+
13
+ /**
14
+ * The masthead. A magazine's front matter is a nameplate — the title set in
15
+ * the display serif, centred, with a hairline rule above and below — and a
16
+ * quiet rubric bar underneath it, the way a print contents strip reads. That
17
+ * is a structurally different header from a left-aligned wordmark plus a
18
+ * right-aligned menu (`theme-canonical`'s own layout): the nameplate gets
19
+ * its own row so it can be set large without competing with the nav for
20
+ * width, and the nav becomes a second, quieter row.
21
+ *
22
+ * L25 pro pass (`theme@1.4`): the top strip now names today's date rather
23
+ * than a static tagline (`Intl.DateTimeFormat(ctx.locale, { dateStyle:
24
+ * 'full' })` on `new Date()` — legitimate here because every page renders
25
+ * per request, `cogenta serve` never caches a rendered page across a day
26
+ * boundary for an anonymous visitor beyond `security.pageMaxAge`, and this
27
+ * theme's own masthead has never claimed to be static-generated — `theme.
28
+ * config.ts` still declares `runtime: 'static'`, which describes what this
29
+ * theme *needs* to render a page, not a promise that today's date is frozen
30
+ * at build time); the rubric row now also carries `headerAction` as a filled
31
+ * "Subscribe"-style button; and a CSS-only `<details>` disclosure collapses
32
+ * the whole rubric row into a hamburger below the nameplate's own
33
+ * breakpoint, exactly the technique `theme-blog`'s masthead already proved
34
+ * against a real browser (a closed `<details>`'s non-summary content cannot
35
+ * be forced open by CSS alone, so the collapsed and expanded nav are two
36
+ * separate `<nav>`s, never one pushed into a state it was never opened
37
+ * into — see that theme's own `chrome.ts` for the verified detail).
38
+ *
39
+ * L26 D4 — the manual light/dark/system toggle (`renderThemeToggle`,
40
+ * `@cogenta/theme-kit`) sits in the top strip, next to today's date, not in
41
+ * the rubric row below: the top strip renders unconditionally on every
42
+ * page, while the rubric (`hasRubric`) only renders when there is a nav or
43
+ * a header action — a toggle placed there would vanish on a site with
44
+ * neither, which is exactly the "dark mode looks unsupported" bug this
45
+ * feature exists to fix.
46
+ */
47
+
48
+ function renderNavItems(links: readonly ChromeNavLink[], className: string): string {
49
+ const items = links
50
+ .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
51
+ .map((link) => {
52
+ const label = escapeText(link.label)
53
+ const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
54
+ if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
55
+ const href = escapeAttribute(link.href)
56
+ const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
57
+ return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
58
+ })
59
+ .join('')
60
+ return items === '' ? '' : `<ul class="${className}">${items}</ul>`
61
+ }
62
+
63
+ /** The rubric row's own call-to-action link (`theme@1.4`) — a filled button, the masthead's one place of solid colour. */
64
+ function renderHeaderAction(action: ChromeInput['headerAction']): string {
65
+ if (action === undefined) return ''
66
+ return (
67
+ `<a class="cg-action cg-masthead__action" data-emphasis="primary" ` +
68
+ `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
69
+ )
70
+ }
71
+
72
+ /** A hamburger mark drawn from three stacked bars — no icon font, no glyph a font might not ship. */
73
+ function menuGlyph(): string {
74
+ return '<span class="cg-masthead__bars" aria-hidden="true"></span>'
75
+ }
76
+
77
+ function todaysDate(locale: string): string {
78
+ try {
79
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'full' }).format(new Date())
80
+ } catch {
81
+ return new Date().toISOString().slice(0, 10)
82
+ }
83
+ }
84
+
85
+ export function renderChrome(input: ChromeInput): ChromeResult {
86
+ const siteNameText = escapeText(input.site.name)
87
+ const navItems = renderNavItems(input.headerNav, 'cg-masthead__menu')
88
+ const headerAction = renderHeaderAction(input.headerAction)
89
+ const footerNav = renderNavItems(input.footerNav, 'cg-colophon__menu')
90
+ const homeHref = escapeAttribute(input.homeHref)
91
+ // A nameplate is the one place a masthead earns a logo: it replaces the
92
+ // wordmark on its own row. The colophon keeps the name in text, so the
93
+ // site is still named in a page whose images never load.
94
+ const nameplate = renderBrandMark(input.brand, { className: 'cg-masthead__logo' }) ?? siteNameText
95
+ const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
96
+
97
+ const hasRubric = navItems !== '' || headerAction !== ''
98
+ // Two renderings of the same rubric row, never both visible at once (CSS,
99
+ // `min-width: 56rem`) — see the file-level comment for why this cannot be
100
+ // a single `<details open>` forced open at width.
101
+ const mobileRubric = !hasRubric
102
+ ? ''
103
+ : `<details class="cg-masthead__disclosure">` +
104
+ `<summary class="cg-masthead__toggle" aria-label="Menu">${menuGlyph()}</summary>` +
105
+ `<nav class="cg-masthead__panel" aria-label="Primary">${navItems}${headerAction}</nav>` +
106
+ `</details>`
107
+ const desktopRubric = !hasRubric
108
+ ? ''
109
+ : `<nav class="cg-masthead__nav" aria-label="Primary"><div class="cg-masthead__nav-inner">${navItems}${headerAction}</div></nav>`
110
+
111
+ const header =
112
+ `<header class="cg-masthead">` +
113
+ `<div class="cg-masthead__top">` +
114
+ `<span class="cg-masthead__date">${escapeText(todaysDate(input.locale))}</span>` +
115
+ `${themeToggle}` +
116
+ `</div>` +
117
+ `<div class="cg-masthead__nameplate">` +
118
+ `<a class="cg-masthead__wordmark" href="${homeHref}">${nameplate}</a>` +
119
+ `${mobileRubric}` +
120
+ `</div>` +
121
+ `${desktopRubric}` +
122
+ `</header>`
123
+
124
+ const tagline =
125
+ input.tagline === undefined
126
+ ? ''
127
+ : `<p class="cg-colophon__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
128
+ const social =
129
+ input.social === undefined
130
+ ? ''
131
+ : serialize(
132
+ renderSocialLinks(input.social, {
133
+ className: 'cg-colophon__social',
134
+ itemClassName: 'cg-colophon__social-item',
135
+ }) ?? { kind: 'text', value: '' },
136
+ )
137
+ const footerNote =
138
+ input.footerNote === undefined
139
+ ? ''
140
+ : `<p class="cg-colophon__note">${escapeText(input.footerNote)}</p>`
141
+
142
+ const footer =
143
+ `<footer class="cg-colophon"><div class="cg-colophon__grid">` +
144
+ `<div class="cg-colophon__about">` +
145
+ `<a class="cg-colophon__wordmark" href="${homeHref}">${siteNameText}</a>` +
146
+ `${tagline}</div>` +
147
+ `<div class="cg-colophon__sections">` +
148
+ `<p class="cg-colophon__heading">Sections</p>` +
149
+ `${footerNav === '' ? '' : `<nav aria-label="Footer">${footerNav}</nav>`}` +
150
+ `</div>` +
151
+ `<div class="cg-colophon__follow">` +
152
+ `<p class="cg-colophon__heading">Follow</p>` +
153
+ `${social}` +
154
+ `</div>` +
155
+ `<div class="cg-colophon__meta">` +
156
+ `${footerNote}` +
157
+ `<div class="cg-colophon__branding">${input.brandingHtml}</div>` +
158
+ `</div>` +
159
+ `</div></footer>`
160
+
161
+ return { header, footer }
162
+ }