@edoxen/browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +45 -0
  2. package/dist/cli.js +4475 -0
  3. package/package.json +82 -0
  4. package/src/astro/components/ActionTypeBadge.astro +28 -0
  5. package/src/astro/components/AgendaTable.astro +49 -0
  6. package/src/astro/components/BodyBadge.astro +28 -0
  7. package/src/astro/components/CommitteeCard.astro +58 -0
  8. package/src/astro/components/CountryFlag.astro +34 -0
  9. package/src/astro/components/DecadeTimeline.astro +52 -0
  10. package/src/astro/components/DecisionList.astro +66 -0
  11. package/src/astro/components/EmptyState.astro +19 -0
  12. package/src/astro/components/LocaleTabs.astro +44 -0
  13. package/src/astro/components/MeetingList.astro +63 -0
  14. package/src/astro/components/MinutesSection.astro +59 -0
  15. package/src/astro/components/OfficerList.astro +49 -0
  16. package/src/astro/components/PageHero.astro +42 -0
  17. package/src/astro/components/PrevNextNav.astro +62 -0
  18. package/src/astro/components/ReferenceDocuments.astro +38 -0
  19. package/src/astro/components/UrnBar.astro +49 -0
  20. package/src/astro/layouts/BaseLayout.astro +79 -0
  21. package/src/astro/layouts/DecisionLayout.astro +21 -0
  22. package/src/astro/layouts/MeetingLayout.astro +21 -0
  23. package/src/astro/pages/404.astro +10 -0
  24. package/src/astro/pages/[lang]/about.astro +65 -0
  25. package/src/astro/pages/[lang]/decisions/[urn].astro +126 -0
  26. package/src/astro/pages/[lang]/decisions/index.astro +27 -0
  27. package/src/astro/pages/[lang]/index.astro +50 -0
  28. package/src/astro/pages/[lang]/meetings/[urn].astro +114 -0
  29. package/src/astro/pages/[lang]/meetings/index.astro +27 -0
  30. package/src/astro/pages/about.astro +65 -0
  31. package/src/astro/pages/decisions/[urn].astro +122 -0
  32. package/src/astro/pages/decisions/index.astro +67 -0
  33. package/src/astro/pages/index.astro +50 -0
  34. package/src/astro/pages/meetings/[urn].astro +110 -0
  35. package/src/astro/pages/meetings/index.astro +20 -0
  36. package/src/cli/index.ts +195 -0
  37. package/src/config/index.ts +33 -0
  38. package/src/config/paths.spec.ts +59 -0
  39. package/src/config/paths.ts +26 -0
  40. package/src/config/schema.spec.ts +263 -0
  41. package/src/config/schema.ts +132 -0
  42. package/src/config/tokens.spec.ts +57 -0
  43. package/src/config/tokens.ts +48 -0
  44. package/src/data/index.ts +36 -0
  45. package/src/data/lint.spec.ts +79 -0
  46. package/src/data/lint.ts +77 -0
  47. package/src/data/load.spec.ts +58 -0
  48. package/src/data/load.ts +50 -0
  49. package/src/data/prepare.spec.ts +137 -0
  50. package/src/data/prepare.ts +199 -0
  51. package/src/data/project.ts +11 -0
  52. package/src/data/validate.ts +38 -0
  53. package/src/errors.ts +25 -0
  54. package/src/i18n/index.spec.ts +121 -0
  55. package/src/i18n/index.ts +90 -0
  56. package/src/index.ts +74 -0
  57. package/src/integration.ts +233 -0
  58. package/src/islands/decade-scroller.ts +18 -0
  59. package/src/islands/print-button.ts +12 -0
  60. package/src/islands/search-filter-core.spec.ts +84 -0
  61. package/src/islands/search-filter-core.ts +89 -0
  62. package/src/islands/search-filter.ts +166 -0
  63. package/src/islands/section-tabs.ts +35 -0
  64. package/src/islands/theme-toggle.ts +33 -0
  65. package/src/islands/urn-copy.ts +29 -0
  66. package/src/seo/index.spec.ts +76 -0
  67. package/src/seo/index.ts +85 -0
  68. package/src/virtual.d.ts +11 -0
  69. package/styles/base.css +117 -0
@@ -0,0 +1,89 @@
1
+ export interface SearchableItem {
2
+ readonly urn: string
3
+ readonly title: string
4
+ readonly identifier?: string
5
+ readonly bodyType?: string
6
+ readonly kind?: string
7
+ readonly year?: number
8
+ }
9
+
10
+ export interface FilterState {
11
+ readonly query: string
12
+ readonly bodies: ReadonlySet<string>
13
+ readonly kinds: ReadonlySet<string>
14
+ readonly years: ReadonlySet<number>
15
+ }
16
+
17
+ export const EMPTY_STATE: FilterState = {
18
+ query: '',
19
+ bodies: new Set(),
20
+ kinds: new Set(),
21
+ years: new Set(),
22
+ }
23
+
24
+ export function filterItems<T extends SearchableItem>(
25
+ items: readonly T[],
26
+ state: FilterState,
27
+ ): T[] {
28
+ const q = state.query.trim().toLowerCase()
29
+ return items.filter((item) => {
30
+ if (state.bodies.size > 0 && !state.bodies.has(item.bodyType ?? '')) return false
31
+ if (state.kinds.size > 0 && !state.kinds.has(item.kind ?? '')) return false
32
+ if (state.years.size > 0 && !state.years.has(item.year ?? -1)) return false
33
+ if (q) {
34
+ const hay = `${item.title} ${item.urn} ${item.identifier ?? ''}`.toLowerCase()
35
+ if (!hay.includes(q)) return false
36
+ }
37
+ return true
38
+ })
39
+ }
40
+
41
+ export interface FacetCounts {
42
+ readonly bodies: ReadonlyMap<string, number>
43
+ readonly kinds: ReadonlyMap<string, number>
44
+ readonly years: ReadonlyMap<number, number>
45
+ }
46
+
47
+ export function countFacets<T extends SearchableItem>(items: readonly T[]): FacetCounts {
48
+ const bodies = new Map<string, number>()
49
+ const kinds = new Map<string, number>()
50
+ const years = new Map<number, number>()
51
+ for (const item of items) {
52
+ if (item.bodyType) bodies.set(item.bodyType, (bodies.get(item.bodyType) ?? 0) + 1)
53
+ if (item.kind) kinds.set(item.kind, (kinds.get(item.kind) ?? 0) + 1)
54
+ if (typeof item.year === 'number') years.set(item.year, (years.get(item.year) ?? 0) + 1)
55
+ }
56
+ return { bodies, kinds, years }
57
+ }
58
+
59
+ export function toggle<K>(set: ReadonlySet<K>, key: K): Set<K> {
60
+ const next = new Set(set)
61
+ if (next.has(key)) next.delete(key)
62
+ else next.add(key)
63
+ return next
64
+ }
65
+
66
+ export function encodeState(state: FilterState): string {
67
+ const params = new URLSearchParams()
68
+ if (state.query) params.set('q', state.query)
69
+ if (state.bodies.size > 0) params.set('bodies', [...state.bodies].sort().join(','))
70
+ if (state.kinds.size > 0) params.set('kinds', [...state.kinds].sort().join(','))
71
+ if (state.years.size > 0) params.set('years', [...state.years].sort().map(String).join(','))
72
+ const s = params.toString()
73
+ return s ? `#${s}` : ''
74
+ }
75
+
76
+ export function decodeState(hash: string): FilterState {
77
+ const trimmed = hash.startsWith('#') ? hash.slice(1) : hash
78
+ if (!trimmed) return { ...EMPTY_STATE, bodies: new Set(), kinds: new Set(), years: new Set() }
79
+ const params = new URLSearchParams(trimmed)
80
+ const bodies = (params.get('bodies') ?? '').split(',').filter(Boolean)
81
+ const kinds = (params.get('kinds') ?? '').split(',').filter(Boolean)
82
+ const years = (params.get('years') ?? '').split(',').filter(Boolean).map(Number).filter(Number.isFinite)
83
+ return {
84
+ query: params.get('q') ?? '',
85
+ bodies: new Set(bodies),
86
+ kinds: new Set(kinds),
87
+ years: new Set(years),
88
+ }
89
+ }
@@ -0,0 +1,166 @@
1
+ import {
2
+ type FilterState,
3
+ type SearchableItem,
4
+ EMPTY_STATE,
5
+ decodeState,
6
+ encodeState,
7
+ filterItems,
8
+ toggle,
9
+ } from './search-filter-core.js'
10
+
11
+ interface FetchResponse {
12
+ items: SearchableItem[]
13
+ facetBodies?: string[]
14
+ facetKinds?: string[]
15
+ facetYears?: number[]
16
+ }
17
+
18
+ function appendListItem(list: ParentNode, item: SearchableItem, basePath: string): void {
19
+ const li = document.createElement('li')
20
+ li.className = 'edoxen-search-filter__result'
21
+ const link = document.createElement('a')
22
+ link.href = `${basePath}/${encodeURIComponent(item.urn)}`
23
+ link.textContent = item.title || item.urn
24
+ li.appendChild(link)
25
+ if (item.bodyType) {
26
+ const badge = document.createElement('span')
27
+ badge.className = 'edoxen-badge'
28
+ badge.textContent = item.bodyType
29
+ li.appendChild(badge)
30
+ }
31
+ list.appendChild(li)
32
+ }
33
+
34
+ function makeFacetChip(label: string, count: number, active: boolean, onToggle: () => void): HTMLElement {
35
+ const btn = document.createElement('button')
36
+ btn.type = 'button'
37
+ btn.className = 'edoxen-search-filter__facet'
38
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false')
39
+ btn.textContent = `${label} (${count})`
40
+ btn.addEventListener('click', onToggle)
41
+ return btn
42
+ }
43
+
44
+ class SearchFilter extends HTMLElement {
45
+ private items: SearchableItem[] = []
46
+ private state: FilterState = { ...EMPTY_STATE, bodies: new Set(), kinds: new Set(), years: new Set() }
47
+ private basePath = '/decisions'
48
+ private endpoint = '/data/decisions.json'
49
+
50
+ async connectedCallback(): Promise<void> {
51
+ this.endpoint = this.dataset.endpoint ?? this.endpoint
52
+ this.basePath = this.dataset.basePath ?? this.basePath
53
+ this.state = decodeState(window.location.hash)
54
+
55
+ this.renderShell()
56
+
57
+ if (this.dataset.items) {
58
+ this.handlePayload(JSON.parse(this.dataset.items) as FetchResponse)
59
+ } else {
60
+ try {
61
+ const res = await fetch(this.endpoint)
62
+ const payload = (await res.json()) as FetchResponse
63
+ this.handlePayload(payload)
64
+ } catch {
65
+ this.renderError(`Failed to load ${this.endpoint}`)
66
+ }
67
+ }
68
+ }
69
+
70
+ private handlePayload(payload: FetchResponse): void {
71
+ this.items = payload.items
72
+ this.render()
73
+ }
74
+
75
+ private renderShell(): void {
76
+ this.innerHTML = ''
77
+ const form = document.createElement('form')
78
+ form.className = 'edoxen-search-filter__form'
79
+ form.setAttribute('role', 'search')
80
+
81
+ const input = document.createElement('input')
82
+ input.type = 'search'
83
+ input.placeholder = 'Search…'
84
+ input.value = this.state.query
85
+ input.setAttribute('aria-label', 'Search decisions')
86
+ input.addEventListener('input', () => {
87
+ this.state = { ...this.state, query: input.value }
88
+ this.syncHash()
89
+ this.render()
90
+ })
91
+ form.appendChild(input)
92
+
93
+ const facets = document.createElement('div')
94
+ facets.className = 'edoxen-search-filter__facets'
95
+ facets.setAttribute('data-role', 'facets')
96
+ form.appendChild(facets)
97
+
98
+ const results = document.createElement('ul')
99
+ results.className = 'edoxen-search-filter__results'
100
+ results.setAttribute('data-role', 'results')
101
+ form.appendChild(results)
102
+
103
+ this.appendChild(form)
104
+ }
105
+
106
+ private render(): void {
107
+ const facetsEl = this.querySelector('[data-role="facets"]')
108
+ const resultsEl = this.querySelector('[data-role="results"]')
109
+ if (!facetsEl || !resultsEl) return
110
+
111
+ facetsEl.innerHTML = ''
112
+ const bodies = new Set<string>()
113
+ const kinds = new Set<string>()
114
+ const years = new Set<number>()
115
+ for (const item of this.items) {
116
+ if (item.bodyType) bodies.add(item.bodyType)
117
+ if (item.kind) kinds.add(item.kind)
118
+ if (typeof item.year === 'number') years.add(item.year)
119
+ }
120
+
121
+ for (const body of [...bodies].sort()) {
122
+ const chip = makeFacetChip(body, this.items.filter((i) => i.bodyType === body).length, this.state.bodies.has(body), () => {
123
+ this.state = { ...this.state, bodies: toggle(this.state.bodies, body) }
124
+ this.syncHash()
125
+ this.render()
126
+ })
127
+ facetsEl.appendChild(chip)
128
+ }
129
+ for (const kind of [...kinds].sort()) {
130
+ const chip = makeFacetChip(kind, this.items.filter((i) => i.kind === kind).length, this.state.kinds.has(kind), () => {
131
+ this.state = { ...this.state, kinds: toggle(this.state.kinds, kind) }
132
+ this.syncHash()
133
+ this.render()
134
+ })
135
+ facetsEl.appendChild(chip)
136
+ }
137
+
138
+ resultsEl.innerHTML = ''
139
+ const matches = filterItems(this.items, this.state)
140
+ if (matches.length === 0) {
141
+ const empty = document.createElement('li')
142
+ empty.className = 'edoxen-empty'
143
+ empty.textContent = 'No matches.'
144
+ resultsEl.appendChild(empty)
145
+ return
146
+ }
147
+ for (const item of matches) appendListItem(resultsEl, item, this.basePath)
148
+ }
149
+
150
+ private syncHash(): void {
151
+ const hash = encodeState(this.state)
152
+ if (hash !== window.location.hash) {
153
+ window.history.replaceState(null, '', hash || window.location.pathname)
154
+ }
155
+ }
156
+
157
+ private renderError(message: string): void {
158
+ this.innerHTML = ''
159
+ const p = document.createElement('p')
160
+ p.className = 'edoxen-search-filter__error'
161
+ p.textContent = message
162
+ this.appendChild(p)
163
+ }
164
+ }
165
+
166
+ customElements.define('search-filter', SearchFilter)
@@ -0,0 +1,35 @@
1
+ class SectionTabs extends HTMLElement {
2
+ connectedCallback(): void {
3
+ const tabs = Array.from(this.querySelectorAll('[data-spelling]'))
4
+ if (tabs.length <= 1) return
5
+
6
+ const target = this.dataset.target
7
+ if (!target) return
8
+
9
+ tabs.forEach((tab) => {
10
+ const button = tab as HTMLButtonElement
11
+ if (button.tagName !== 'BUTTON') return
12
+ button.addEventListener('click', () => {
13
+ const spelling = button.dataset.spelling
14
+ if (!spelling) return
15
+
16
+ tabs.forEach((t) => {
17
+ const other = t as HTMLButtonElement
18
+ other.setAttribute('aria-pressed', other === button ? 'true' : 'false')
19
+ })
20
+
21
+ const blocks = document.querySelectorAll(`[data-tab-target="${target}"]`)
22
+ blocks.forEach((block) => {
23
+ const el = block as HTMLElement
24
+ const match = el.dataset.spelling === spelling
25
+ el.hidden = !match
26
+ })
27
+ })
28
+ })
29
+
30
+ const firstActive = tabs.find((t) => t.getAttribute('aria-pressed') === 'true') ?? tabs[0]
31
+ if (firstActive) (firstActive as HTMLButtonElement).click()
32
+ }
33
+ }
34
+
35
+ customElements.define('section-tabs', SectionTabs)
@@ -0,0 +1,33 @@
1
+ const STORAGE_KEY = 'edoxen-theme'
2
+ const ATTR = 'data-theme'
3
+
4
+ function applyTheme(next: 'light' | 'dark') {
5
+ document.documentElement.setAttribute(ATTR, next)
6
+ }
7
+
8
+ function preferredTheme(): 'light' | 'dark' {
9
+ const stored = localStorage.getItem(STORAGE_KEY)
10
+ if (stored === 'light' || stored === 'dark') return stored
11
+ return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
12
+ }
13
+
14
+ class ThemeToggle extends HTMLElement {
15
+ connectedCallback() {
16
+ const initial = preferredTheme()
17
+ applyTheme(initial)
18
+ const btn = document.createElement('button')
19
+ btn.type = 'button'
20
+ btn.setAttribute('aria-label', 'Toggle dark mode')
21
+ btn.textContent = initial === 'dark' ? '☀' : '☾'
22
+ btn.addEventListener('click', () => {
23
+ const current = document.documentElement.getAttribute(ATTR) === 'dark' ? 'dark' : 'light'
24
+ const next = current === 'dark' ? 'light' : 'dark'
25
+ applyTheme(next)
26
+ localStorage.setItem(STORAGE_KEY, next)
27
+ btn.textContent = next === 'dark' ? '☀' : '☾'
28
+ })
29
+ this.appendChild(btn)
30
+ }
31
+ }
32
+
33
+ customElements.define('theme-toggle', ThemeToggle)
@@ -0,0 +1,29 @@
1
+ class UrnCopy extends HTMLElement {
2
+ connectedCallback() {
3
+ const text = this.dataset.text ?? this.textContent ?? ''
4
+ if (!text) return
5
+ const btn = document.createElement('button')
6
+ btn.type = 'button'
7
+ btn.className = 'edoxen-urn-copy__button'
8
+ btn.setAttribute('aria-label', `Copy ${this.dataset.label ?? 'URN'} to clipboard`)
9
+ btn.textContent = 'Copy'
10
+ btn.addEventListener('click', async () => {
11
+ try {
12
+ await navigator.clipboard.writeText(text)
13
+ btn.textContent = 'Copied'
14
+ setTimeout(() => {
15
+ btn.textContent = 'Copy'
16
+ }, 1500)
17
+ } catch {
18
+ btn.textContent = 'Press Ctrl+C'
19
+ setTimeout(() => {
20
+ btn.textContent = 'Copy'
21
+ }, 1500)
22
+ }
23
+ })
24
+ this.innerHTML = ''
25
+ this.appendChild(btn)
26
+ }
27
+ }
28
+
29
+ customElements.define('urn-copy', UrnCopy)
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Decision, Meeting } from '@edoxen/edoxen'
3
+
4
+ import { decisionJsonLd, meetingJsonLd } from './index.js'
5
+
6
+ const ctx = { siteUrl: 'https://example.org', siteTitle: 'X', defaultLocale: 'en' }
7
+
8
+ const decision = {
9
+ identifier: [{ prefix: 'TEST', number: '1' }],
10
+ kind: 'resolution',
11
+ urn: 'urn:test:resolution:1',
12
+ body_type: 'committee',
13
+ doi: '10.63493/test',
14
+ dates: [
15
+ { type: 'effective', date: '2024-06-15' },
16
+ { type: 'published', date: '2024-06-20' },
17
+ ],
18
+ title: [{ spelling: 'eng', value: 'First test decision' }],
19
+ categories: ['Tests'],
20
+ } as unknown as Decision
21
+
22
+ const meeting = {
23
+ identifier: [{ prefix: 'TEST', number: '2024' }],
24
+ type: 'plenary',
25
+ urn: 'urn:test:meeting:2024',
26
+ body_type: 'committee',
27
+ status: 'completed',
28
+ date_range: { start: '2024-06-15', end: '2024-06-20' },
29
+ city: 'CNSHA',
30
+ country_code: 'DE',
31
+ title: [{ spelling: 'eng', value: '2024 Plenary' }],
32
+ } as unknown as Meeting
33
+
34
+ describe('decisionJsonLd', () => {
35
+ it('produces a Legislation document with the canonical URL', () => {
36
+ const jsonld = decisionJsonLd(decision, ctx)
37
+ expect(jsonld['@type']).toBe('Legislation')
38
+ expect(jsonld['@context']).toBe('https://schema.org')
39
+ expect(jsonld.name).toBe('First test decision')
40
+ expect(jsonld.identifier).toBe('urn:test:resolution:1')
41
+ expect(jsonld.url).toBe('https://example.org/decisions/urn%3Atest%3Aresolution%3A1')
42
+ })
43
+
44
+ it('links the DOI as sameAs', () => {
45
+ expect(decisionJsonLd(decision, ctx).sameAs).toBe('https://doi.org/10.63493/test')
46
+ })
47
+
48
+ it('exposes effective + published dates', () => {
49
+ const jsonld = decisionJsonLd(decision, ctx)
50
+ expect(jsonld.legislationDate).toBe('2024-06-15')
51
+ expect(jsonld.datePublished).toBe('2024-06-20')
52
+ })
53
+
54
+ it('emits undefined sameAs when no DOI', () => {
55
+ const noDoi = { ...decision, doi: undefined } as Decision
56
+ expect(decisionJsonLd(noDoi, ctx).sameAs).toBeUndefined()
57
+ })
58
+ })
59
+
60
+ describe('meetingJsonLd', () => {
61
+ it('produces an Event document with start/end dates', () => {
62
+ const jsonld = meetingJsonLd(meeting, ctx)
63
+ expect(jsonld['@type']).toBe('Event')
64
+ expect(jsonld.startDate).toBe('2024-06-15')
65
+ expect(jsonld.endDate).toBe('2024-06-20')
66
+ expect(jsonld.eventStatus).toBe('https://schema.org/EventCompleted')
67
+ })
68
+
69
+ it('includes a Place when city + country are present', () => {
70
+ const jsonld = meetingJsonLd(meeting, ctx)
71
+ expect(jsonld.location).toEqual({
72
+ '@type': 'Place',
73
+ address: { '@type': 'PostalAddress', addressCountry: 'DE' },
74
+ })
75
+ })
76
+ })
@@ -0,0 +1,85 @@
1
+ import type { Decision, Meeting, LocalizedString } from '@edoxen/edoxen'
2
+
3
+ import type { DecisionListItem, MeetingListItem } from '../data/index.js'
4
+
5
+ export interface SeoContext {
6
+ readonly siteUrl: string
7
+ readonly siteTitle: string
8
+ readonly defaultLocale: string
9
+ }
10
+
11
+ export interface JsonLd {
12
+ readonly '@context': 'https://schema.org'
13
+ readonly '@type': string
14
+ readonly [key: string]: unknown
15
+ }
16
+
17
+ function firstLocalizedValue(list: readonly LocalizedString[] | undefined, fallback: string): string {
18
+ return list?.[0]?.value ?? fallback
19
+ }
20
+
21
+ export function decisionJsonLd(decision: Decision, ctx: SeoContext): JsonLd {
22
+ const title = firstLocalizedValue(decision.title, decision.urn ?? '')
23
+ const url = `${ctx.siteUrl}/decisions/${encodeURIComponent(decision.urn ?? '')}`
24
+ const sameAs = decision.doi ? `https://doi.org/${decision.doi}` : undefined
25
+ const datePublished = (decision.dates ?? []).find((d) => d.type === 'published')?.date
26
+ const legislationDate = (decision.dates ?? []).find((d) => d.type === 'effective')?.date
27
+
28
+ return {
29
+ '@context': 'https://schema.org',
30
+ '@type': 'Legislation',
31
+ name: title,
32
+ identifier: decision.urn,
33
+ url,
34
+ sameAs,
35
+ datePublished,
36
+ legislationDate,
37
+ legislationPassedBy: decision.meeting?.venue ?? undefined,
38
+ keywords: (decision.categories ?? []).join(', ') || undefined,
39
+ }
40
+ }
41
+
42
+ export function decisionListItemJsonLd(item: DecisionListItem, ctx: SeoContext): JsonLd {
43
+ return {
44
+ '@context': 'https://schema.org',
45
+ '@type': 'Legislation',
46
+ name: item.title,
47
+ identifier: item.urn,
48
+ url: `${ctx.siteUrl}/decisions/${encodeURIComponent(item.urn)}`,
49
+ legislationType: item.kind || undefined,
50
+ }
51
+ }
52
+
53
+ export function meetingJsonLd(meeting: Meeting, ctx: SeoContext): JsonLd {
54
+ const title = firstLocalizedValue(meeting.title, meeting.urn ?? '')
55
+ const url = `${ctx.siteUrl}/meetings/${encodeURIComponent(meeting.urn ?? '')}`
56
+ const eventStatus = meeting.status === 'completed'
57
+ ? 'https://schema.org/EventCompleted'
58
+ : 'https://schema.org/EventScheduled'
59
+
60
+ return {
61
+ '@context': 'https://schema.org',
62
+ '@type': 'Event',
63
+ name: title,
64
+ identifier: meeting.urn,
65
+ url,
66
+ startDate: meeting.date_range?.start,
67
+ endDate: meeting.date_range?.end,
68
+ eventStatus,
69
+ location: meeting.city
70
+ ? { '@type': 'Place', address: { '@type': 'PostalAddress', addressCountry: meeting.country_code } }
71
+ : undefined,
72
+ }
73
+ }
74
+
75
+ export function meetingListItemJsonLd(item: MeetingListItem, ctx: SeoContext): JsonLd {
76
+ return {
77
+ '@context': 'https://schema.org',
78
+ '@type': 'Event',
79
+ name: item.title,
80
+ identifier: item.urn,
81
+ url: `${ctx.siteUrl}/meetings/${encodeURIComponent(item.urn)}`,
82
+ startDate: item.startDate,
83
+ endDate: item.endDate,
84
+ }
85
+ }
@@ -0,0 +1,11 @@
1
+ declare module 'virtual:edoxen-config' {
2
+ import type { EdoxenConfig } from './config/index.js'
3
+ const config: EdoxenConfig
4
+ export default config
5
+ }
6
+
7
+ declare module 'virtual:edoxen-payloads' {
8
+ import type { PagePayloads } from './data/index.js'
9
+ const payloads: PagePayloads
10
+ export default payloads
11
+ }
@@ -0,0 +1,117 @@
1
+ /* Edoxen base stylesheet — global defaults only.
2
+ Component styles live inside .astro files (Astro scopes them).
3
+ Every color, font, and radius goes through a --edoxen-* custom
4
+ property declared by `generateCssTokens` from config. */
5
+
6
+ *,
7
+ *::before,
8
+ *::after {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ html {
13
+ font-family: var(--edoxen-font-sans, system-ui, -apple-system, sans-serif);
14
+ color: var(--edoxen-color-text);
15
+ background: var(--edoxen-color-background);
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ display: flex;
21
+ flex-direction: column;
22
+ min-height: 100vh;
23
+ }
24
+
25
+ a {
26
+ color: var(--edoxen-color-accent);
27
+ text-decoration: none;
28
+ }
29
+
30
+ a:hover,
31
+ a:focus-visible {
32
+ text-decoration: underline;
33
+ }
34
+
35
+ .edoxen-header,
36
+ .edoxen-main,
37
+ .edoxen-footer {
38
+ width: 100%;
39
+ max-width: 72rem;
40
+ margin: 0 auto;
41
+ padding: 0 var(--edoxen-spacing-page, 1.5rem);
42
+ }
43
+
44
+ .edoxen-header {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ gap: 1rem;
49
+ padding-top: 1rem;
50
+ padding-bottom: 1rem;
51
+ border-bottom: 1px solid var(--edoxen-color-border);
52
+ }
53
+
54
+ .edoxen-header__brand {
55
+ display: inline-flex;
56
+ align-items: center;
57
+ gap: 0.5rem;
58
+ color: var(--edoxen-color-text);
59
+ font-weight: 600;
60
+ }
61
+
62
+ .edoxen-header__brand img {
63
+ height: 2rem;
64
+ width: auto;
65
+ }
66
+
67
+ .edoxen-header__nav {
68
+ display: flex;
69
+ gap: 1rem;
70
+ flex-wrap: wrap;
71
+ }
72
+
73
+ .edoxen-main {
74
+ flex: 1;
75
+ padding-top: 2rem;
76
+ padding-bottom: 3rem;
77
+ }
78
+
79
+ .edoxen-footer {
80
+ border-top: 1px solid var(--edoxen-color-border);
81
+ padding-top: 1.5rem;
82
+ padding-bottom: 1.5rem;
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 0.75rem;
86
+ color: var(--edoxen-color-muted);
87
+ font-size: 0.9rem;
88
+ }
89
+
90
+ .edoxen-footer__social {
91
+ display: flex;
92
+ gap: 1rem;
93
+ list-style: none;
94
+ padding: 0;
95
+ margin: 0;
96
+ }
97
+
98
+ .edoxen-badge {
99
+ display: inline-block;
100
+ padding: 0.15rem 0.55rem;
101
+ border-radius: var(--edoxen-radius-sm);
102
+ font-size: 0.8rem;
103
+ font-weight: 500;
104
+ background: var(--edoxen-color-muted);
105
+ color: #fff;
106
+ }
107
+
108
+ :root[data-theme="dark"] body {
109
+ background: var(--edoxen-color-background);
110
+ }
111
+
112
+ @media print {
113
+ .edoxen-header,
114
+ .edoxen-footer {
115
+ display: none;
116
+ }
117
+ }