@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,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
|
+
})
|
package/src/data/lint.ts
ADDED
|
@@ -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
|
+
})
|
package/src/data/load.ts
ADDED
|
@@ -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
|
+
}
|