@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,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildProject, type Decision, type Meeting } from '@edoxen/edoxen'
3
+
4
+ import { lintProject } from './lint.js'
5
+
6
+ function decision(urn: string, extra: Partial<Decision> = {}): Decision {
7
+ return {
8
+ identifier: [{ prefix: 'TEST', number: urn }],
9
+ kind: 'resolution',
10
+ urn,
11
+ localizations: [{ language_code: 'eng', title: urn }],
12
+ ...extra,
13
+ } as Decision
14
+ }
15
+
16
+ function meeting(urn: string, extra: Partial<Meeting> = {}): Meeting {
17
+ return {
18
+ identifier: [{ prefix: 'TEST', number: urn }],
19
+ type: 'plenary',
20
+ urn,
21
+ ...extra,
22
+ } as Meeting
23
+ }
24
+
25
+ describe('lintProject', () => {
26
+ it('returns ok=true with no findings for clean data', () => {
27
+ const project = buildProject({
28
+ decisions: [decision('urn:test:1')],
29
+ meetings: [],
30
+ })
31
+ const report = lintProject(project)
32
+ expect(report.ok).toBe(true)
33
+ expect(report.findings).toEqual([])
34
+ })
35
+
36
+ it('flags duplicate decision URNs as errors', () => {
37
+ const project = buildProject({
38
+ decisions: [decision('urn:test:dup'), decision('urn:test:dup')],
39
+ })
40
+ const report = lintProject(project)
41
+ expect(report.ok).toBe(false)
42
+ expect(report.findings.some((f) => f.code === 'DUPLICATE_DECISION_URN')).toBe(true)
43
+ })
44
+
45
+ it('warns on relations pointing to unknown decisions', () => {
46
+ const project = buildProject({
47
+ decisions: [
48
+ decision('urn:test:1', {
49
+ relations: [{ source: { prefix: 'TEST', number: '1' }, destination: { prefix: 'TEST', number: 'missing' }, type: 'cites' }],
50
+ }),
51
+ ],
52
+ })
53
+ const report = lintProject(project)
54
+ expect(report.ok).toBe(true)
55
+ expect(report.findings.some((f) => f.code === 'BROKEN_DECISION_RELATION')).toBe(true)
56
+ })
57
+
58
+ it('warns when a decision references an unknown meeting date', () => {
59
+ const project = buildProject({
60
+ decisions: [
61
+ decision('urn:test:1', { meeting: { date: '2024-06-15' } }),
62
+ ],
63
+ meetings: [meeting('urn:test:m1', { date_range: { start: '2024-06-15', end: '2024-06-15' } })],
64
+ })
65
+ const report = lintProject(project)
66
+ expect(report.findings.some((f) => f.code === 'ORPHAN_DECISION_MEETING')).toBe(true)
67
+ })
68
+
69
+ it('does not flag a decision whose meeting date matches a real meeting', () => {
70
+ const project = buildProject({
71
+ decisions: [
72
+ decision('urn:test:1', { meeting: { date: '2024-06-15' } }),
73
+ ],
74
+ meetings: [meeting('urn:test:meeting-1', { urn: '2024-06-15', date_range: { start: '2024-06-15', end: '2024-06-15' } })],
75
+ })
76
+ const report = lintProject(project)
77
+ expect(report.findings.some((f) => f.code === 'ORPHAN_DECISION_MEETING')).toBe(false)
78
+ })
79
+ })
@@ -0,0 +1,77 @@
1
+ import type { EdoxenProject } from '@edoxen/edoxen'
2
+
3
+ export type LintSeverity = 'error' | 'warning'
4
+
5
+ export interface LintFinding {
6
+ readonly code: string
7
+ readonly severity: LintSeverity
8
+ readonly message: string
9
+ }
10
+
11
+ export interface LintReport {
12
+ readonly findings: readonly LintFinding[]
13
+ readonly ok: boolean
14
+ }
15
+
16
+ function uniq<T>(values: Iterable<T>): T[] {
17
+ return [...new Set(values)]
18
+ }
19
+
20
+ function decisionIdentifierKey(d: { identifier?: { prefix: string; number: string }[] }): string {
21
+ const head = d.identifier?.[0]
22
+ return head ? `${head.prefix}-${head.number}` : ''
23
+ }
24
+
25
+ export function lintProject(project: EdoxenProject): LintReport {
26
+ const findings: LintFinding[] = []
27
+
28
+ const urnCounts = new Map<string, number>()
29
+ for (const d of project.decisions) {
30
+ if (!d.urn) continue
31
+ urnCounts.set(d.urn, (urnCounts.get(d.urn) ?? 0) + 1)
32
+ }
33
+ for (const [urn, count] of urnCounts) {
34
+ if (count > 1) {
35
+ findings.push({
36
+ code: 'DUPLICATE_DECISION_URN',
37
+ severity: 'error',
38
+ message: `Decision URN ${urn} appears ${count} times`,
39
+ })
40
+ }
41
+ }
42
+
43
+ const decisionIds = new Set<string>()
44
+ for (const d of project.decisions) {
45
+ const key = decisionIdentifierKey(d)
46
+ if (key) decisionIds.add(key)
47
+ }
48
+ for (const d of project.decisions) {
49
+ for (const r of d.relations ?? []) {
50
+ const key = decisionIdentifierKey({ identifier: [r.destination] })
51
+ if (key && !decisionIds.has(key)) {
52
+ findings.push({
53
+ code: 'BROKEN_DECISION_RELATION',
54
+ severity: 'warning',
55
+ message: `Decision ${d.urn} references unknown decision ${key}`,
56
+ })
57
+ }
58
+ }
59
+ }
60
+
61
+ const meetingUrns = new Set(project.meetings.map((m) => m.urn).filter((u): u is string => Boolean(u)))
62
+ for (const d of project.decisions) {
63
+ const meetingDate = d.meeting?.date
64
+ if (meetingDate && !meetingUrns.has(meetingDate) && !project.meetingByUrn(meetingDate)) {
65
+ findings.push({
66
+ code: 'ORPHAN_DECISION_MEETING',
67
+ severity: 'warning',
68
+ message: `Decision ${d.urn} references unknown meeting key ${meetingDate}`,
69
+ })
70
+ }
71
+ }
72
+
73
+ return {
74
+ findings: uniq(findings),
75
+ ok: !findings.some((f) => f.severity === 'error'),
76
+ }
77
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, resolve } from 'node:path'
4
+
5
+ import { loadAll } from './load.js'
6
+ import type { DataConfig } from '../config/schema.js'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const fixtures = resolve(here, '../../test/fixtures')
10
+
11
+ function dataConfig(decisions = './decisions', meetings?: string): DataConfig {
12
+ return {
13
+ decisions: resolve(fixtures, decisions),
14
+ ...(meetings ? { meetings: resolve(fixtures, meetings) } : {}),
15
+ }
16
+ }
17
+
18
+ describe('loadAll', () => {
19
+ it('loads every decision from a directory of fixtures', async () => {
20
+ const result = await loadAll(dataConfig())
21
+ expect(result.ok).toBe(true)
22
+ if (!result.ok) return
23
+ expect(result.value.decisions?.decisions.length).toBe(2)
24
+ expect(result.value.decisions?.decisions[0]?.urn).toBe('urn:test:resolution:1')
25
+ })
26
+
27
+ it('returns ok with no meetings when none requested', async () => {
28
+ const result = await loadAll(dataConfig())
29
+ expect(result.ok).toBe(true)
30
+ if (!result.ok) return
31
+ expect(result.value.meetings).toBeUndefined()
32
+ })
33
+
34
+ it('loads meetings when configured', async () => {
35
+ const result = await loadAll(dataConfig('./decisions', './meetings'))
36
+ expect(result.ok).toBe(true)
37
+ if (!result.ok) return
38
+ expect(result.value.meetings?.meetings.length).toBe(2)
39
+ expect(result.value.meetings?.meetings[0]?.urn).toBe('urn:test:meeting:2024')
40
+ })
41
+
42
+ it('reports a structured error when the decisions path does not exist', async () => {
43
+ const result = await loadAll(dataConfig('./missing-decisions'))
44
+ expect(result.ok).toBe(false)
45
+ if (result.ok) return
46
+ expect(result.error.length).toBeGreaterThanOrEqual(1)
47
+ expect(result.error[0]?.source).toBe('decisions')
48
+ expect(result.error[0]?.path).toMatch(/missing-decisions$/)
49
+ expect(result.error[0]?.cause).toBeInstanceOf(Error)
50
+ })
51
+
52
+ it('reports one error per failing source', async () => {
53
+ const result = await loadAll(dataConfig('./missing-decisions', './missing-meetings'))
54
+ expect(result.ok).toBe(false)
55
+ if (result.ok) return
56
+ expect(result.error.map((e) => e.source).sort()).toEqual(['decisions', 'meetings'])
57
+ })
58
+ })
@@ -0,0 +1,50 @@
1
+ import {
2
+ loadDecisions,
3
+ loadMeetings,
4
+ type LoadedDecisions,
5
+ type LoadedMeetings,
6
+ } from '@edoxen/edoxen'
7
+
8
+ import type { DataConfig } from '../config/schema.js'
9
+
10
+ export interface LoadedData {
11
+ decisions?: LoadedDecisions
12
+ meetings?: LoadedMeetings
13
+ }
14
+
15
+ export type DataSource = 'decisions' | 'meetings'
16
+
17
+ export interface LoadError {
18
+ readonly source: DataSource
19
+ readonly path: string
20
+ readonly cause: Error
21
+ }
22
+
23
+ export type LoadResult =
24
+ | { readonly ok: true; readonly value: LoadedData }
25
+ | { readonly ok: false; readonly error: LoadError[] }
26
+
27
+ function wrapError(source: DataSource, path: string, e: unknown): LoadError {
28
+ return { source, path, cause: e instanceof Error ? e : new Error(String(e)) }
29
+ }
30
+
31
+ export async function loadAll(data: DataConfig): Promise<LoadResult> {
32
+ const errors: LoadError[] = []
33
+ const out: LoadedData = {}
34
+
35
+ try {
36
+ out.decisions = await loadDecisions(data.decisions)
37
+ } catch (e) {
38
+ errors.push(wrapError('decisions', data.decisions, e))
39
+ }
40
+
41
+ if (data.meetings) {
42
+ try {
43
+ out.meetings = await loadMeetings(data.meetings)
44
+ } catch (e) {
45
+ errors.push(wrapError('meetings', data.meetings, e))
46
+ }
47
+ }
48
+
49
+ return errors.length === 0 ? { ok: true, value: out } : { ok: false, error: errors }
50
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, resolve } from 'node:path'
4
+
5
+ import { loadAll } from './load.js'
6
+ import { validateAll } from './validate.js'
7
+ import { buildProjectFromLoaded } from './project.js'
8
+ import { prepareDecisionsList, prepareMeetingsList, preparePayloads } from './prepare.js'
9
+ import type { DataConfig } from '../config/schema.js'
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url))
12
+ const fixtures = resolve(here, '../../test/fixtures')
13
+
14
+ const cfg: DataConfig = {
15
+ decisions: resolve(fixtures, './decisions'),
16
+ meetings: resolve(fixtures, './meetings'),
17
+ }
18
+
19
+ describe('validateAll', () => {
20
+ it('returns valid=true for clean fixtures', async () => {
21
+ const loaded = await loadAll(cfg)
22
+ if (!loaded.ok) throw new Error('load failed')
23
+ const report = await validateAll(loaded.value)
24
+ expect(report.valid).toBe(true)
25
+ })
26
+
27
+ it('returns the per-source ValidateResult for inspection', async () => {
28
+ const loaded = await loadAll(cfg)
29
+ if (!loaded.ok) throw new Error('load failed')
30
+ const report = await validateAll(loaded.value)
31
+ expect(report.decisions?.valid).toBe(true)
32
+ expect(report.meetings?.valid).toBe(true)
33
+ })
34
+ })
35
+
36
+ describe('buildProjectFromLoaded', () => {
37
+ it('joins decisions + meetings + committee into an EdoxenProject', async () => {
38
+ const loaded = await loadAll(cfg)
39
+ if (!loaded.ok) throw new Error('load failed')
40
+ const project = buildProjectFromLoaded(loaded.value)
41
+ expect(project.decisions.length).toBe(2)
42
+ expect(project.meetings.length).toBe(2)
43
+ expect(project.committee).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('prepareDecisionsList', () => {
48
+ it('produces a list sorted by date descending', async () => {
49
+ const loaded = await loadAll(cfg)
50
+ if (!loaded.ok) throw new Error('load failed')
51
+ const project = buildProjectFromLoaded(loaded.value)
52
+ const payload = prepareDecisionsList(project)
53
+ expect(payload.items.map((i) => i.urn)).toEqual([
54
+ 'urn:test:resolution:1',
55
+ 'urn:test:resolution:2',
56
+ ])
57
+ })
58
+
59
+ it('derives identifier from prefix + number', async () => {
60
+ const loaded = await loadAll(cfg)
61
+ if (!loaded.ok) throw new Error('load failed')
62
+ const payload = prepareDecisionsList(buildProjectFromLoaded(loaded.value))
63
+ expect(payload.items[0]?.identifier).toBe('TEST-1')
64
+ })
65
+
66
+ it('surfaces body_type for join against cfg.bodies', async () => {
67
+ const loaded = await loadAll(cfg)
68
+ if (!loaded.ok) throw new Error('load failed')
69
+ const payload = prepareDecisionsList(buildProjectFromLoaded(loaded.value))
70
+ expect(payload.items.every((i) => i.bodyType === 'committee')).toBe(true)
71
+ expect(payload.facets.bodies).toEqual(['committee'])
72
+ })
73
+
74
+ it('exposes year + kind facets from the data', async () => {
75
+ const loaded = await loadAll(cfg)
76
+ if (!loaded.ok) throw new Error('load failed')
77
+ const payload = prepareDecisionsList(buildProjectFromLoaded(loaded.value))
78
+ expect(payload.facets.years).toEqual([2023, 2024])
79
+ expect(payload.facets.kinds).toEqual(['recommendation', 'resolution'])
80
+ })
81
+
82
+ it('carries meetingUrn (meeting date key) for back-links to /meetings/[urn]', async () => {
83
+ const loaded = await loadAll(cfg)
84
+ if (!loaded.ok) throw new Error('load failed')
85
+ const payload = prepareDecisionsList(buildProjectFromLoaded(loaded.value))
86
+ expect(payload.items[0]?.meetingUrn).toBe('2024-06-15')
87
+ })
88
+ })
89
+
90
+ describe('prepareMeetingsList', () => {
91
+ it('produces a list sorted by start date descending', async () => {
92
+ const loaded = await loadAll(cfg)
93
+ if (!loaded.ok) throw new Error('load failed')
94
+ const payload = prepareMeetingsList(buildProjectFromLoaded(loaded.value))
95
+ expect(payload.items.map((i) => i.urn)).toEqual([
96
+ 'urn:test:meeting:2024',
97
+ 'urn:test:meeting:2023',
98
+ ])
99
+ })
100
+
101
+ it('exposes decade + body + country facets', async () => {
102
+ const loaded = await loadAll(cfg)
103
+ if (!loaded.ok) throw new Error('load failed')
104
+ const payload = prepareMeetingsList(buildProjectFromLoaded(loaded.value))
105
+ expect(payload.facets.decades).toEqual([2020])
106
+ expect(payload.facets.bodies).toEqual(['committee'])
107
+ expect(payload.facets.countries).toEqual(['DE', 'FR'])
108
+ })
109
+
110
+ it('exposes year + city for display', async () => {
111
+ const loaded = await loadAll(cfg)
112
+ if (!loaded.ok) throw new Error('load failed')
113
+ const payload = prepareMeetingsList(buildProjectFromLoaded(loaded.value))
114
+ expect(payload.items[0]?.year).toBe(2024)
115
+ expect(payload.items[0]?.city).toBe('CNSHA')
116
+ })
117
+ })
118
+
119
+ describe('preparePayloads', () => {
120
+ it('returns both list payloads in one call', async () => {
121
+ const loaded = await loadAll(cfg)
122
+ if (!loaded.ok) throw new Error('load failed')
123
+ const payloads = preparePayloads(buildProjectFromLoaded(loaded.value))
124
+ expect(payloads.decisionsList.items.length).toBe(2)
125
+ expect(payloads.meetingsList.items.length).toBe(2)
126
+ })
127
+
128
+ it('exposes decisionByUrn and meetingByUrn maps for detail pages', async () => {
129
+ const loaded = await loadAll(cfg)
130
+ if (!loaded.ok) throw new Error('load failed')
131
+ const payloads = preparePayloads(buildProjectFromLoaded(loaded.value))
132
+ expect(payloads.decisionByUrn['urn:test:resolution:1']).toBeDefined()
133
+ expect(payloads.decisionByUrn['urn:test:resolution:1']?.body_type).toBe('committee')
134
+ expect(payloads.meetingByUrn['urn:test:meeting:2024']).toBeDefined()
135
+ expect(payloads.meetingByUrn['urn:test:meeting:2024']?.city).toBe('CNSHA')
136
+ })
137
+ })
@@ -0,0 +1,199 @@
1
+ import type {
2
+ Action,
3
+ Approval,
4
+ Consideration,
5
+ Decision,
6
+ EdoxenProject,
7
+ LocalizedString,
8
+ Meeting,
9
+ StructuredIdentifier,
10
+ } from '@edoxen/edoxen'
11
+
12
+ export interface DecisionListItem {
13
+ readonly urn: string
14
+ readonly identifier: string
15
+ readonly title: string
16
+ readonly kind: string
17
+ readonly dates: readonly { readonly type: string; readonly date: string }[]
18
+ readonly meetingUrn?: string
19
+ readonly bodyType?: string
20
+ }
21
+
22
+ export interface DecisionListFacets {
23
+ readonly years: readonly number[]
24
+ readonly kinds: readonly string[]
25
+ readonly bodies: readonly string[]
26
+ readonly meetingUrns: readonly string[]
27
+ }
28
+
29
+ export interface DecisionListPayload {
30
+ readonly items: readonly DecisionListItem[]
31
+ readonly facets: DecisionListFacets
32
+ }
33
+
34
+ export interface MeetingListItem {
35
+ readonly urn: string
36
+ readonly identifier: string
37
+ readonly title: string
38
+ readonly startDate?: string
39
+ readonly endDate?: string
40
+ readonly year?: number
41
+ readonly bodyType?: string
42
+ readonly city?: string
43
+ readonly countryCode?: string
44
+ readonly status?: string
45
+ }
46
+
47
+ export interface MeetingListFacets {
48
+ readonly decades: readonly number[]
49
+ readonly bodies: readonly string[]
50
+ readonly countries: readonly string[]
51
+ }
52
+
53
+ export interface MeetingListPayload {
54
+ readonly items: readonly MeetingListItem[]
55
+ readonly facets: MeetingListFacets
56
+ }
57
+
58
+ export interface PagePayloads {
59
+ readonly decisionsList: DecisionListPayload
60
+ readonly meetingsList: MeetingListPayload
61
+ readonly decisionByUrn: Readonly<Record<string, Decision>>
62
+ readonly meetingByUrn: Readonly<Record<string, Meeting>>
63
+ }
64
+
65
+ function formatIdentifier(ids: readonly StructuredIdentifier[] | undefined): string {
66
+ if (!ids || ids.length === 0) return ''
67
+ const head = ids[0]
68
+ if (!head) return ''
69
+ return `${head.prefix}-${head.number}`
70
+ }
71
+
72
+ function primaryValue(list: readonly LocalizedString[] | undefined, fallback: string): string {
73
+ const first = list?.[0]?.value
74
+ return first && first.length > 0 ? first : fallback
75
+ }
76
+
77
+ function meetingKeyOf(d: Decision): string | undefined {
78
+ return d.meeting?.date ?? d.meeting?.venue
79
+ }
80
+
81
+ function effectiveYearOf(d: Decision): number | null {
82
+ for (const dt of d.dates ?? []) {
83
+ const v = dt.date
84
+ if (typeof v === 'string' && v.length >= 4) {
85
+ const year = Number(v.slice(0, 4))
86
+ if (Number.isInteger(year)) return year
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ function yearOfMeeting(m: Meeting): number | null {
93
+ const start = m.date_range?.start
94
+ if (typeof start !== 'string' || start.length < 4) return null
95
+ const year = Number(start.slice(0, 4))
96
+ return Number.isInteger(year) ? year : null
97
+ }
98
+
99
+ function decadeOf(year: number): number {
100
+ return Math.floor(year / 10) * 10
101
+ }
102
+
103
+ function uniqSorted(values: Iterable<string>): readonly string[] {
104
+ const set = new Set<string>()
105
+ for (const v of values) if (v && v.length > 0) set.add(v)
106
+ return [...set].sort()
107
+ }
108
+
109
+ function uniqSortedNumbers(values: Iterable<number>): readonly number[] {
110
+ const set = new Set<number>()
111
+ for (const v of values) if (Number.isFinite(v)) set.add(v)
112
+ return [...set].sort((a, b) => a - b)
113
+ }
114
+
115
+ function toDecisionListItem(d: Decision): DecisionListItem {
116
+ const meetingKey = meetingKeyOf(d)
117
+ return {
118
+ urn: d.urn ?? '',
119
+ identifier: formatIdentifier(d.identifier),
120
+ title: primaryValue(d.title, d.urn ?? formatIdentifier(d.identifier)),
121
+ kind: d.kind ?? '',
122
+ dates: (d.dates ?? []).map((dt) => ({ type: dt.type ?? '', date: dt.date ?? '' })),
123
+ meetingUrn: meetingKey,
124
+ bodyType: d.body_type,
125
+ }
126
+ }
127
+
128
+ function toMeetingListItem(m: Meeting): MeetingListItem {
129
+ const year = yearOfMeeting(m)
130
+ return {
131
+ urn: m.urn ?? '',
132
+ identifier: formatIdentifier(m.identifier),
133
+ title: primaryValue(m.title, m.urn ?? formatIdentifier(m.identifier)),
134
+ startDate: m.date_range?.start,
135
+ endDate: m.date_range?.end,
136
+ year: year ?? undefined,
137
+ bodyType: m.body_type,
138
+ city: m.city,
139
+ countryCode: m.country_code,
140
+ status: m.status,
141
+ }
142
+ }
143
+
144
+ export function prepareDecisionsList(project: EdoxenProject): DecisionListPayload {
145
+ const items = [...project.decisions]
146
+ .map(toDecisionListItem)
147
+ .sort((a, b) => (b.dates[0]?.date ?? '').localeCompare(a.dates[0]?.date ?? ''))
148
+
149
+ return {
150
+ items,
151
+ facets: {
152
+ years: uniqSortedNumbers(project.decisions.map((d) => effectiveYearOf(d) ?? -1).filter((y) => y > 0)),
153
+ kinds: uniqSorted(project.decisions.map((d) => d.kind ?? '')),
154
+ bodies: uniqSorted(project.decisions.map((d) => d.body_type ?? '')),
155
+ meetingUrns: uniqSorted(project.decisions.map((d) => meetingKeyOf(d) ?? '')),
156
+ },
157
+ }
158
+ }
159
+
160
+ export function prepareMeetingsList(project: EdoxenProject): MeetingListPayload {
161
+ const items = [...project.meetings]
162
+ .map(toMeetingListItem)
163
+ .sort((a, b) => (b.startDate ?? '').localeCompare(a.startDate ?? ''))
164
+
165
+ const decadesSet = new Set<number>()
166
+ for (const m of project.meetings) {
167
+ const y = yearOfMeeting(m)
168
+ if (y !== null) decadesSet.add(decadeOf(y))
169
+ }
170
+
171
+ return {
172
+ items,
173
+ facets: {
174
+ decades: uniqSortedNumbers(decadesSet),
175
+ bodies: uniqSorted(project.meetings.map((m) => m.body_type ?? '')),
176
+ countries: uniqSorted(project.meetings.map((m) => m.country_code ?? '')),
177
+ },
178
+ }
179
+ }
180
+
181
+ export function preparePayloads(project: EdoxenProject): PagePayloads {
182
+ const decisionByUrn: Record<string, Decision> = {}
183
+ for (const d of project.decisions) {
184
+ if (d.urn) decisionByUrn[d.urn] = d
185
+ }
186
+ const meetingByUrn: Record<string, Meeting> = {}
187
+ for (const m of project.meetings) {
188
+ if (m.urn) meetingByUrn[m.urn] = m
189
+ }
190
+
191
+ return {
192
+ decisionsList: prepareDecisionsList(project),
193
+ meetingsList: prepareMeetingsList(project),
194
+ decisionByUrn,
195
+ meetingByUrn,
196
+ }
197
+ }
198
+
199
+ export type { Action, Approval, Consideration }
@@ -0,0 +1,11 @@
1
+ import { buildProject, type EdoxenProject } from '@edoxen/edoxen'
2
+
3
+ import type { LoadedData } from './load.js'
4
+
5
+ export function buildProjectFromLoaded(data: LoadedData): EdoxenProject {
6
+ return buildProject({
7
+ decisions: data.decisions?.decisions,
8
+ meetings: data.meetings?.meetings,
9
+ committee: data.meetings?.series?.[0] ?? null,
10
+ })
11
+ }
@@ -0,0 +1,38 @@
1
+ import { validateDecisions, validateMeetings, type ValidateResult } from '@edoxen/edoxen'
2
+
3
+ import type { LoadedData } from './load.js'
4
+
5
+ export interface ValidationReport {
6
+ decisions?: ValidateResult
7
+ meetings?: ValidateResult
8
+ valid: boolean
9
+ }
10
+
11
+ function decisionsCollectionShape(data: NonNullable<LoadedData['decisions']>): unknown {
12
+ return { metadata: data.metadata, decisions: data.decisions }
13
+ }
14
+
15
+ function meetingsCollectionShape(data: NonNullable<LoadedData['meetings']>): unknown {
16
+ const out: Record<string, unknown> = { meetings: data.meetings }
17
+ if (data.metadata) out.metadata = data.metadata
18
+ return out
19
+ }
20
+
21
+ export async function validateAll(data: LoadedData): Promise<ValidationReport> {
22
+ let valid = true
23
+ const report: ValidationReport = { valid }
24
+
25
+ if (data.decisions) {
26
+ const result = await validateDecisions(decisionsCollectionShape(data.decisions))
27
+ report.decisions = result
28
+ if (!result.valid) valid = false
29
+ }
30
+
31
+ if (data.meetings) {
32
+ const result = await validateMeetings(meetingsCollectionShape(data.meetings))
33
+ report.meetings = result
34
+ if (!result.valid) valid = false
35
+ }
36
+
37
+ return { decisions: report.decisions, meetings: report.meetings, valid }
38
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,25 @@
1
+ export type IntegrationStage = 'config' | 'load' | 'validate' | 'build' | 'smoke' | 'lint'
2
+
3
+ export class EdoxenBrowserError extends Error {
4
+ override readonly name = 'EdoxenBrowserError'
5
+ readonly stage: IntegrationStage
6
+ readonly details: readonly string[]
7
+
8
+ constructor(stage: IntegrationStage, message: string, details: readonly string[] = []) {
9
+ super(message)
10
+ this.stage = stage
11
+ this.details = details
12
+ }
13
+
14
+ override toString(): string {
15
+ const head = `[edoxen:${this.stage}] ${this.message}`
16
+ if (this.details.length === 0) return head
17
+ return `${head}\n${this.details.map((d) => ` - ${d}`).join('\n')}`
18
+ }
19
+ }
20
+
21
+ export function formatValidationErrors(
22
+ errors: readonly { readonly path: string; readonly message: string }[],
23
+ ): readonly string[] {
24
+ return errors.map((e) => (e.path ? `${e.path}: ${e.message}` : e.message))
25
+ }