@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
@@ -0,0 +1,44 @@
1
+ import type { AccordionBlock, AccordionItem } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { blockHeadingTag, heading } from '../heading.js'
4
+ import { type HtmlElement, h } from '../html.js'
5
+ import { renderRichText } from '../rich-text.js'
6
+
7
+ /**
8
+ * `blocks@2.0` (RFC 0001). Shares `faq`'s `<details>`/`<summary>` mechanics —
9
+ * expanding, keyboard operation and the expanded state all come from the
10
+ * browser, at zero bytes of JavaScript — but is its own block, since
11
+ * "accordion" and "frequently asked question" are different editorial
12
+ * intents even when the underlying question/answer shape coincides.
13
+ */
14
+ function renderItem(item: AccordionItem, ctx: RenderContext): HtmlElement {
15
+ return h(
16
+ 'li',
17
+ { class: 'cg-accordion__item' },
18
+ h(
19
+ 'details',
20
+ { class: 'cg-accordion__details' },
21
+ h('summary', { class: 'cg-accordion__question' }, item.question),
22
+ h('div', { class: 'cg-accordion__answer' }, renderRichText(ctx, item.answer)),
23
+ ),
24
+ )
25
+ }
26
+
27
+ export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
28
+ return h(
29
+ 'section',
30
+ { class: 'cg-block cg-accordion', 'data-block': 'accordion' },
31
+ block.title === undefined
32
+ ? null
33
+ : heading(
34
+ blockHeadingTag('accordion') ?? 'h2',
35
+ { class: 'cg-accordion__title', 'data-field': 'title' },
36
+ block.title,
37
+ ),
38
+ h(
39
+ 'ul',
40
+ { class: 'cg-accordion__items' },
41
+ block.items.map((item) => renderItem(item, ctx)),
42
+ ),
43
+ )
44
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CollectionListBlock } from '@cogenta/blocks'
2
- import type { ContentEntry, QueryRequest, RenderContext } from '../../theme-contract.js'
2
+ import type { ContentEntry, RenderContext } from '../../theme-contract.js'
3
3
  import { entryDate, entryExcerpt, entryHref, entryTitle } from '../entry.js'
4
4
  import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
5
  import { type HtmlElement, h } from '../html.js'
@@ -8,21 +8,15 @@ import { type HtmlElement, h } from '../html.js'
8
8
  * The only block of the twelve that reads data at render time — contract B
9
9
  * marks it `runtime: 'server'` for that reason.
10
10
  *
11
- * The read is *not* done here. `query` builds the request, the `.astro`
12
- * component awaits `ctx.content.list(...)` in its frontmatter, and this
13
- * function stays a pure function of the entries it is handed. That keeps the
14
- * markup snapshot-testable and keeps the theme's single door to data the
15
- * read-only content client in one visible place.
11
+ * The read is *not* done here. `query` builds the request (`@cogenta/theme-kit`'s
12
+ * `buildCollectionListQuery`, shared across every theme since it derives
13
+ * purely from the block's own fields), the caller awaits `ctx.content.list(...)`
14
+ * before rendering starts, and this function stays a pure function of the
15
+ * entries it is handed. That keeps the markup snapshot-testable and keeps a
16
+ * theme's single door to data — the read-only content client — in one visible
17
+ * place.
16
18
  */
17
- export function query(block: CollectionListBlock): QueryRequest {
18
- return {
19
- collection: block.collection,
20
- ...(block.filter === undefined ? {} : { filter: block.filter }),
21
- ...(block.sort === undefined ? {} : { sort: block.sort }),
22
- // Capped by contract B at 100; an absent limit still must not mean "all".
23
- limit: block.limit ?? 10,
24
- }
25
- }
19
+ export { buildCollectionListQuery as query } from '@cogenta/theme-kit'
26
20
 
27
21
  function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
28
22
  const date = entryDate(entry)
@@ -3,6 +3,7 @@ import type { RenderContext } from '../../theme-contract.js'
3
3
  import { href } from '../actions.js'
4
4
  import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
5
  import { type HtmlElement, h } from '../html.js'
6
+ import { renderIcon } from '../icons.js'
6
7
 
7
8
  /**
8
9
  * The item's title is the link, so the link's accessible name is the feature's
@@ -10,7 +11,11 @@ import { type HtmlElement, h } from '../html.js'
10
11
  * WCAG 2.4.4, and the block carries no label field to write one anyway.
11
12
  *
12
13
  * `icon` names a symbol, never markup: it is exposed as a data attribute for
13
- * the skin and marked `aria-hidden`, since the title already names the feature.
14
+ * the skin (and, since L25, the page builder see `data-icon` below) and,
15
+ * when the name is one `renderIcon` (`@cogenta/theme-kit`) recognises, a real
16
+ * inline glyph — `aria-hidden`, since the title already names the feature.
17
+ * An unrecognised name keeps the pre-L25 behaviour: the bare, empty
18
+ * `data-icon` span, styled by `.cg-feature__icon` alone.
14
19
  */
15
20
  function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
16
21
  const title =
@@ -26,7 +31,11 @@ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): Htm
26
31
  { class: 'cg-feature' },
27
32
  item.icon === undefined
28
33
  ? null
29
- : h('span', { class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' }),
34
+ : h(
35
+ 'span',
36
+ { class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
37
+ renderIcon(item.icon),
38
+ ),
30
39
  title,
31
40
  item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
32
41
  )
@@ -0,0 +1,38 @@
1
+ import type { LogoStripBlock, LogoStripItem } 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
+ * `blocks@2.0` (RFC 0001). The lighter-weight "as seen in" / social-proof
8
+ * row next to `logos`: no per-logo name or link, so no accessible-name field
9
+ * to require — each image's own alt text (set once, in the media library)
10
+ * is what names it, exactly as `logos` already falls back to when a link is
11
+ * absent.
12
+ */
13
+ function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
14
+ return h(
15
+ 'li',
16
+ { class: 'cg-logo-strip__item' },
17
+ image(ctx, item.media, { className: 'cg-logo-strip__image', variant: { fit: 'contain' } }),
18
+ )
19
+ }
20
+
21
+ export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
22
+ return h(
23
+ 'figure',
24
+ { class: 'cg-block cg-logo-strip', 'data-block': 'logoStrip' },
25
+ h(
26
+ 'ul',
27
+ { class: 'cg-logo-strip__items' },
28
+ block.logos.map((item) => renderItem(item, ctx)),
29
+ ),
30
+ block.caption === undefined
31
+ ? null
32
+ : h(
33
+ 'figcaption',
34
+ { class: 'cg-logo-strip__caption', 'data-field': 'caption' },
35
+ block.caption,
36
+ ),
37
+ )
38
+ }
@@ -0,0 +1,65 @@
1
+ import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
2
+ import type { RenderContext } from '../../theme-contract.js'
3
+ import { actionLink } from '../actions.js'
4
+ import { blockHeadingTag, type HeadingTag, heading, nestedHeadingTag } from '../heading.js'
5
+ import { type HtmlElement, h } from '../html.js'
6
+
7
+ /**
8
+ * `blocks@2.0` (RFC 0001). `highlighted` is editorial emphasis, not a colour:
9
+ * it becomes `data-highlighted` for the skin to style, and `aria-current`
10
+ * (loosely "the one on offer") so it is announced, not only shown.
11
+ */
12
+ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
13
+ return h(
14
+ 'li',
15
+ {
16
+ class: 'cg-pricing__tier',
17
+ 'data-highlighted': tier.highlighted === true ? 'true' : undefined,
18
+ 'aria-current': tier.highlighted === true ? 'true' : undefined,
19
+ },
20
+ heading(tag, { class: 'cg-pricing__name' }, tier.name),
21
+ h(
22
+ 'p',
23
+ { class: 'cg-pricing__price' },
24
+ h('span', { class: 'cg-pricing__amount' }, tier.price),
25
+ tier.interval === undefined
26
+ ? null
27
+ : h('span', { class: 'cg-pricing__interval' }, tier.interval),
28
+ ),
29
+ tier.features.length === 0
30
+ ? null
31
+ : h(
32
+ 'ul',
33
+ { class: 'cg-pricing__features' },
34
+ tier.features.map((feature) => h('li', {}, feature)),
35
+ ),
36
+ tier.action === undefined
37
+ ? null
38
+ : h(
39
+ 'div',
40
+ { class: 'cg-pricing__action' },
41
+ actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
42
+ ),
43
+ )
44
+ }
45
+
46
+ export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
47
+ const hasTitle = block.title !== undefined
48
+ const tierTag = nestedHeadingTag('pricingTable', hasTitle)
49
+ return h(
50
+ 'section',
51
+ { class: 'cg-block cg-pricing', 'data-block': 'pricingTable' },
52
+ hasTitle
53
+ ? heading(
54
+ blockHeadingTag('pricingTable') ?? 'h2',
55
+ { class: 'cg-pricing__title', 'data-field': 'title' },
56
+ block.title ?? '',
57
+ )
58
+ : null,
59
+ h(
60
+ 'ul',
61
+ { class: 'cg-pricing__tiers' },
62
+ block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
63
+ ),
64
+ )
65
+ }
@@ -0,0 +1,34 @@
1
+ import type { StatCounterBlock, StatCounterItem } 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
+ * `blocks@2.0` (RFC 0001). A `<dl>`, exactly like `stats` — each figure is
8
+ * the description of its label — but narrower on purpose: no `unit`, since a
9
+ * counter row is meant for one big figure and its label, not the fuller
10
+ * statistic `stats` already covers.
11
+ */
12
+ function renderItem(item: StatCounterItem): HtmlElement {
13
+ return h(
14
+ 'div',
15
+ { class: 'cg-stat-counter' },
16
+ h('dt', { class: 'cg-stat-counter__label' }, item.label),
17
+ h('dd', { class: 'cg-stat-counter__value' }, item.value),
18
+ )
19
+ }
20
+
21
+ export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
22
+ return h(
23
+ 'section',
24
+ { class: 'cg-block cg-stat-counters', 'data-block': 'statCounter' },
25
+ block.title === undefined
26
+ ? null
27
+ : heading(
28
+ blockHeadingTag('statCounter') ?? 'h2',
29
+ { class: 'cg-stat-counters__title', 'data-field': 'title' },
30
+ block.title,
31
+ ),
32
+ h('dl', { class: 'cg-stat-counters__items' }, block.stats.map(renderItem)),
33
+ )
34
+ }
@@ -0,0 +1,41 @@
1
+ import type { TestimonialBlock } 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
+ import { renderRichText } from '../rich-text.js'
6
+
7
+ /**
8
+ * `blocks@2.0` (RFC 0001). Same `<figure><blockquote>…<figcaption>` pattern
9
+ * as `quote`, but the quote itself is rich text (a testimonial can carry a
10
+ * link or emphasis the shorter, plain-text `quote` block cannot) and the
11
+ * attribution is one grouped field rather than three loose ones.
12
+ *
13
+ * The avatar is decorative, exactly like `quote`'s own: the name is right
14
+ * beside it in text, so its media entity's alt text is expected to be empty.
15
+ */
16
+ export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
17
+ const { attribution } = block
18
+ return h(
19
+ 'figure',
20
+ { class: 'cg-block cg-testimonial', 'data-block': 'testimonial' },
21
+ h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
22
+ h(
23
+ 'figcaption',
24
+ { class: 'cg-testimonial__attribution' },
25
+ attribution.avatar === undefined
26
+ ? null
27
+ : image(ctx, attribution.avatar, {
28
+ className: 'cg-testimonial__avatar',
29
+ variant: { width: 96, height: 96, fit: 'cover' },
30
+ }),
31
+ h(
32
+ 'span',
33
+ { class: 'cg-testimonial__names' },
34
+ h('span', { class: 'cg-testimonial__name' }, attribution.name),
35
+ attribution.role === undefined
36
+ ? null
37
+ : h('span', { class: 'cg-testimonial__role' }, attribution.role),
38
+ ),
39
+ ),
40
+ )
41
+ }
@@ -0,0 +1,105 @@
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 site header and footer — moved here, verbatim, from what used to be
15
+ * `@cogenta/cli`'s own hardcoded `<header class="cg-site-header">`/`<footer
16
+ * class="cg-site-footer">` template (`packages/cli/src/commands/theme-render.ts`,
17
+ * before the chrome extension point existed). Byte-identical output to that
18
+ * template: this is the reference theme, and every site that installed it
19
+ * before a second theme existed must render exactly as it always has.
20
+ *
21
+ * A future theme with a structurally different header (a mega-menu, a cart
22
+ * icon, no header at all) implements its own `renderChrome` instead — this is
23
+ * simply the one this package ships.
24
+ *
25
+ * `theme@1.4` (L25 D2) adds four optional fields, each rendered only when
26
+ * present: `headerAction` as a button-styled link at the end of the header
27
+ * nav, `tagline` under the site name in the footer, `social` as an icon-link
28
+ * row (`renderSocialLinks`), `footerNote` as a short "about" paragraph. A
29
+ * site that sets none of them, and a `1.3` render that never reaches this
30
+ * file at all, both keep the pre-1.4 header/footer byte for byte — see
31
+ * `test/chrome.test.ts`'s own "without the new fields" case.
32
+ */
33
+
34
+ function renderNavLinks(links: readonly ChromeNavLink[]): string {
35
+ if (links.length === 0) return ''
36
+ const items = links
37
+ .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
38
+ .map((link) => {
39
+ const label = escapeText(link.label)
40
+ const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
41
+ if (link.href === null) return `<li><span${titleAttr}>${label}</span></li>`
42
+ const href = escapeAttribute(link.href)
43
+ const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
44
+ return `<li><a href="${href}"${target}${titleAttr}>${label}</a></li>`
45
+ })
46
+ .join('')
47
+ return items === '' ? '' : `<ul class="cg-menu">${items}</ul>`
48
+ }
49
+
50
+ /** The header's own call-to-action link (`theme@1.4`), styled like the block vocabulary's own primary action. */
51
+ function renderHeaderAction(action: ChromeInput['headerAction']): string {
52
+ if (action === undefined) return ''
53
+ return (
54
+ `<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
55
+ `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
56
+ )
57
+ }
58
+
59
+ export function renderChrome(input: ChromeInput): ChromeResult {
60
+ const siteName = escapeAttribute(input.site.name)
61
+ const headerNav = renderNavLinks(input.headerNav)
62
+ const footerNav = renderNavLinks(input.footerNav)
63
+ // The uploaded logo replaces the wordmark, and only the wordmark: the
64
+ // footer keeps the site's name in text, so a site whose logo fails to load
65
+ // is still named somewhere on every page.
66
+ const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteName
67
+ const headerAction = renderHeaderAction(input.headerAction)
68
+ const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
69
+
70
+ const header =
71
+ `<header class="cg-site-header"><div class="cg-site-header__inner">` +
72
+ `<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
73
+ `${headerNav === '' ? '' : `<nav class="cg-site-header__nav" aria-label="Primary">${headerNav}</nav>`}` +
74
+ `${themeToggle}` +
75
+ `${headerAction}` +
76
+ `</div></header>`
77
+
78
+ const tagline =
79
+ input.tagline === undefined
80
+ ? ''
81
+ : `<p class="cg-site-footer__tagline">${escapeText(input.tagline)}</p>`
82
+ const social =
83
+ input.social === undefined
84
+ ? ''
85
+ : serialize(
86
+ renderSocialLinks(input.social, {
87
+ className: 'cg-site-footer__social',
88
+ itemClassName: 'cg-site-footer__social-item',
89
+ }) ?? { kind: 'text', value: '' },
90
+ )
91
+ const footerNote =
92
+ input.footerNote === undefined
93
+ ? ''
94
+ : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
95
+
96
+ const footer =
97
+ `<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
98
+ `<span>${siteName}</span>${tagline}` +
99
+ `${footerNav === '' ? '' : `<nav class="cg-site-footer__nav" aria-label="Footer">${footerNav}</nav>`}` +
100
+ `${social}` +
101
+ `${footerNote === '' ? '' : `<div class="cg-site-footer__about">${footerNote}</div>`}` +
102
+ `${input.brandingHtml}</div></footer>`
103
+
104
+ return { header, footer }
105
+ }
@@ -0,0 +1,6 @@
1
+ /** Re-exported from `@cogenta/theme-kit` — see that package's `comments.ts`. */
2
+ export {
3
+ type CommentsSectionOptions,
4
+ type PublicComment,
5
+ renderCommentsSection,
6
+ } from '@cogenta/theme-kit'
@@ -1,40 +1,2 @@
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
- }
1
+ /** Re-exported from `@cogenta/theme-kit` see that package's `entry.ts`. */
2
+ export { entryDate, entryExcerpt, entryHref, entryImage, entryTitle } from '@cogenta/theme-kit'
@@ -1,44 +1,7 @@
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
- }
1
+ /** Re-exported from `@cogenta/theme-kit` see that package's `heading.ts`. */
2
+ export {
3
+ blockHeadingTag,
4
+ type HeadingTag,
5
+ heading,
6
+ nestedHeadingTag,
7
+ } from '@cogenta/theme-kit'
@@ -1,122 +1,15 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
92
- }
93
-
94
- export function escapeAttribute(value: string): string {
95
- return escapeText(value).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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
- }
1
+ /** Re-exported from `@cogenta/theme-kit` — see that package's `html.ts`. */
2
+ export {
3
+ type Attributes,
4
+ type AttributeValue,
5
+ type Child,
6
+ escapeAttribute,
7
+ escapeText,
8
+ type HtmlElement,
9
+ type HtmlNode,
10
+ type HtmlText,
11
+ h,
12
+ serialize,
13
+ serializeAll,
14
+ text,
15
+ } from '@cogenta/theme-kit'
@@ -0,0 +1,2 @@
1
+ /** Re-exported from `@cogenta/theme-kit` — see that package's `icons.ts`. */
2
+ export { ICON_NAMES, type IconName, renderIcon } from '@cogenta/theme-kit'