@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.
- package/README.md +45 -0
- package/dist/cli.js +4475 -0
- package/package.json +82 -0
- package/src/astro/components/ActionTypeBadge.astro +28 -0
- package/src/astro/components/AgendaTable.astro +49 -0
- package/src/astro/components/BodyBadge.astro +28 -0
- package/src/astro/components/CommitteeCard.astro +58 -0
- package/src/astro/components/CountryFlag.astro +34 -0
- package/src/astro/components/DecadeTimeline.astro +52 -0
- package/src/astro/components/DecisionList.astro +66 -0
- package/src/astro/components/EmptyState.astro +19 -0
- package/src/astro/components/LocaleTabs.astro +44 -0
- package/src/astro/components/MeetingList.astro +63 -0
- package/src/astro/components/MinutesSection.astro +59 -0
- package/src/astro/components/OfficerList.astro +49 -0
- package/src/astro/components/PageHero.astro +42 -0
- package/src/astro/components/PrevNextNav.astro +62 -0
- package/src/astro/components/ReferenceDocuments.astro +38 -0
- package/src/astro/components/UrnBar.astro +49 -0
- package/src/astro/layouts/BaseLayout.astro +79 -0
- package/src/astro/layouts/DecisionLayout.astro +21 -0
- package/src/astro/layouts/MeetingLayout.astro +21 -0
- package/src/astro/pages/404.astro +10 -0
- package/src/astro/pages/[lang]/about.astro +65 -0
- package/src/astro/pages/[lang]/decisions/[urn].astro +126 -0
- package/src/astro/pages/[lang]/decisions/index.astro +27 -0
- package/src/astro/pages/[lang]/index.astro +50 -0
- package/src/astro/pages/[lang]/meetings/[urn].astro +114 -0
- package/src/astro/pages/[lang]/meetings/index.astro +27 -0
- package/src/astro/pages/about.astro +65 -0
- package/src/astro/pages/decisions/[urn].astro +122 -0
- package/src/astro/pages/decisions/index.astro +67 -0
- package/src/astro/pages/index.astro +50 -0
- package/src/astro/pages/meetings/[urn].astro +110 -0
- package/src/astro/pages/meetings/index.astro +20 -0
- package/src/cli/index.ts +195 -0
- package/src/config/index.ts +33 -0
- package/src/config/paths.spec.ts +59 -0
- package/src/config/paths.ts +26 -0
- package/src/config/schema.spec.ts +263 -0
- package/src/config/schema.ts +132 -0
- package/src/config/tokens.spec.ts +57 -0
- package/src/config/tokens.ts +48 -0
- package/src/data/index.ts +36 -0
- package/src/data/lint.spec.ts +79 -0
- package/src/data/lint.ts +77 -0
- package/src/data/load.spec.ts +58 -0
- package/src/data/load.ts +50 -0
- package/src/data/prepare.spec.ts +137 -0
- package/src/data/prepare.ts +199 -0
- package/src/data/project.ts +11 -0
- package/src/data/validate.ts +38 -0
- package/src/errors.ts +25 -0
- package/src/i18n/index.spec.ts +121 -0
- package/src/i18n/index.ts +90 -0
- package/src/index.ts +74 -0
- package/src/integration.ts +233 -0
- package/src/islands/decade-scroller.ts +18 -0
- package/src/islands/print-button.ts +12 -0
- package/src/islands/search-filter-core.spec.ts +84 -0
- package/src/islands/search-filter-core.ts +89 -0
- package/src/islands/search-filter.ts +166 -0
- package/src/islands/section-tabs.ts +35 -0
- package/src/islands/theme-toggle.ts +33 -0
- package/src/islands/urn-copy.ts +29 -0
- package/src/seo/index.spec.ts +76 -0
- package/src/seo/index.ts +85 -0
- package/src/virtual.d.ts +11 -0
- 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
|
+
})
|
package/src/seo/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -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
|
+
}
|
package/styles/base.css
ADDED
|
@@ -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
|
+
}
|