@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,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { LocalizedString } from '@edoxen/edoxen'
3
+
4
+ import {
5
+ availableSpellings,
6
+ getLocaleFromUrl,
7
+ localizedHref,
8
+ pickLocalizedString,
9
+ pickLocalizedValue,
10
+ threeToTwo,
11
+ twoToThree,
12
+ } from './index.js'
13
+
14
+ function ls(spelling: string, value: string): LocalizedString {
15
+ return { spelling, value }
16
+ }
17
+
18
+ describe('twoToThree / threeToTwo', () => {
19
+ it('maps ISO 639-1 to ISO 639-3', () => {
20
+ expect(twoToThree('en')).toBe('eng')
21
+ expect(twoToThree('fr')).toBe('fra')
22
+ })
23
+
24
+ it('passes through unknown codes', () => {
25
+ expect(twoToThree('xx')).toBe('xx')
26
+ expect(threeToTwo('xxx')).toBe('xxx')
27
+ })
28
+
29
+ it('round-trips for known codes', () => {
30
+ expect(threeToTwo(twoToThree('en'))).toBe('en')
31
+ })
32
+ })
33
+
34
+ describe('pickLocalizedString', () => {
35
+ const list = [ls('eng', 'Hello'), ls('fra', 'Bonjour'), ls('zho-Hans', '你好')]
36
+
37
+ it('matches the exact 2-letter locale via the 3-letter mapping', () => {
38
+ expect(pickLocalizedString(list, 'en')?.value).toBe('Hello')
39
+ })
40
+
41
+ it('matches the exact 3-letter locale directly', () => {
42
+ expect(pickLocalizedString(list, 'fra')?.value).toBe('Bonjour')
43
+ })
44
+
45
+ it('matches language+script combinations', () => {
46
+ expect(pickLocalizedString(list, 'zh')?.value).toBe('你好')
47
+ })
48
+
49
+ it('falls back to the first entry when no match exists', () => {
50
+ expect(pickLocalizedString(list, 'ja')?.value).toBe('Hello')
51
+ })
52
+
53
+ it('returns null when the list is empty', () => {
54
+ expect(pickLocalizedString([], 'en')).toBeNull()
55
+ expect(pickLocalizedString(undefined, 'en')).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('pickLocalizedValue', () => {
60
+ it('returns the value, not the wrapper', () => {
61
+ expect(pickLocalizedValue([ls('eng', 'Hi')], 'en')).toBe('Hi')
62
+ })
63
+
64
+ it('falls back to the provided default', () => {
65
+ expect(pickLocalizedValue([], 'en', 'none')).toBe('none')
66
+ })
67
+ })
68
+
69
+ describe('availableSpellings', () => {
70
+ it('lists distinct spellings in stable order', () => {
71
+ expect(
72
+ availableSpellings([ls('fra', 'a'), ls('eng', 'b'), ls('fra', 'c')]),
73
+ ).toEqual(['eng', 'fra'])
74
+ })
75
+
76
+ it('omits empty spellings', () => {
77
+ expect(availableSpellings([ls('', 'x')])).toEqual([])
78
+ })
79
+ })
80
+
81
+ describe('getLocaleFromUrl', () => {
82
+ const locales = [
83
+ { code: 'en', routePrefix: '' },
84
+ { code: 'fr', routePrefix: '/fr' },
85
+ { code: 'de', routePrefix: '/de' },
86
+ ]
87
+
88
+ it('returns the default locale with no prefix for root paths', () => {
89
+ expect(getLocaleFromUrl('/', locales, 'en')).toEqual({ locale: 'en', prefix: '' })
90
+ expect(getLocaleFromUrl('/decisions/x', locales, 'en')).toEqual({ locale: 'en', prefix: '' })
91
+ })
92
+
93
+ it('detects the /fr prefix', () => {
94
+ expect(getLocaleFromUrl('/fr', locales, 'en')).toEqual({ locale: 'fr', prefix: '/fr' })
95
+ expect(getLocaleFromUrl('/fr/decisions/x', locales, 'en')).toEqual({ locale: 'fr', prefix: '/fr' })
96
+ })
97
+
98
+ it('handles prefix declared without leading slash', () => {
99
+ const locs = [{ code: 'fr', routePrefix: 'fr' }]
100
+ expect(getLocaleFromUrl('/fr/decisions', locs, 'en').locale).toBe('fr')
101
+ })
102
+ })
103
+
104
+ describe('localizedHref', () => {
105
+ const locales = [
106
+ { code: 'en', routePrefix: '' },
107
+ { code: 'fr', routePrefix: '/fr' },
108
+ ]
109
+
110
+ it('returns the href unchanged for the default locale', () => {
111
+ expect(localizedHref('/decisions/x', 'en', locales, 'en')).toBe('/decisions/x')
112
+ })
113
+
114
+ it('prefixes the href for non-default locales', () => {
115
+ expect(localizedHref('/decisions/x', 'fr', locales, 'en')).toBe('/fr/decisions/x')
116
+ })
117
+
118
+ it('handles hrefs without leading slash', () => {
119
+ expect(localizedHref('decisions', 'fr', locales, 'en')).toBe('/fr/decisions')
120
+ })
121
+ })
@@ -0,0 +1,90 @@
1
+ import type { LocalizedString } from '@edoxen/edoxen'
2
+
3
+ const TWO_TO_THREE: Readonly<Record<string, string>> = {
4
+ en: 'eng', fr: 'fra', de: 'deu', es: 'spa', it: 'ita',
5
+ ja: 'jpn', zh: 'zho', ko: 'kor', ru: 'rus', pt: 'por',
6
+ nl: 'nld', sv: 'swe', pl: 'pol', tr: 'tur', ar: 'ara',
7
+ he: 'heb', hi: 'hin',
8
+ }
9
+
10
+ const THREE_TO_TWO: Readonly<Record<string, string>> = Object.fromEntries(
11
+ Object.entries(TWO_TO_THREE).map(([two, three]) => [three, two]),
12
+ )
13
+
14
+ export function twoToThree(code: string): string {
15
+ return TWO_TO_THREE[code] ?? code
16
+ }
17
+
18
+ export function threeToTwo(code: string): string {
19
+ return THREE_TO_TWO[code] ?? code
20
+ }
21
+
22
+ export interface LocaleResolution {
23
+ readonly locale: string
24
+ readonly prefix: string
25
+ }
26
+
27
+ export function getLocaleFromUrl(
28
+ pathname: string,
29
+ locales: ReadonlyArray<{ code: string; routePrefix?: string }>,
30
+ defaultLocale: string,
31
+ ): LocaleResolution {
32
+ for (const entry of locales) {
33
+ const prefix = entry.routePrefix ?? ''
34
+ if (!prefix) continue
35
+ const normalized = prefix.startsWith('/') ? prefix : `/${prefix}`
36
+ if (pathname === normalized || pathname.startsWith(`${normalized}/`)) {
37
+ return { locale: entry.code, prefix: normalized }
38
+ }
39
+ }
40
+ return { locale: defaultLocale, prefix: '' }
41
+ }
42
+
43
+ export function localizedHref(
44
+ href: string,
45
+ locale: string,
46
+ locales: ReadonlyArray<{ code: string; routePrefix?: string }>,
47
+ defaultLocale: string,
48
+ ): string {
49
+ if (locale === defaultLocale) return href
50
+ const entry = locales.find((l) => l.code === locale)
51
+ const prefix = entry?.routePrefix ?? ''
52
+ if (!prefix) return href
53
+ const normalized = prefix.startsWith('/') ? prefix : `/${prefix}`
54
+ const slash = href.startsWith('/') ? '' : '/'
55
+ return `${normalized}${slash}${href}`
56
+ }
57
+
58
+ export function pickLocalizedString(
59
+ list: readonly LocalizedString[] | undefined,
60
+ locale: string,
61
+ ): LocalizedString | null {
62
+ if (!list || list.length === 0) return null
63
+
64
+ const norm = locale.toLowerCase()
65
+ const three = twoToThree(norm)
66
+
67
+ let hit = list.find((ls) => (ls.spelling ?? '').toLowerCase() === norm || (ls.spelling ?? '').toLowerCase() === three)
68
+ if (hit) return hit
69
+
70
+ hit = list.find((ls) => {
71
+ const sp = (ls.spelling ?? '').toLowerCase()
72
+ return sp.startsWith(`${norm}-`) || sp.startsWith(`${three}-`) || sp.startsWith(`${three}:`)
73
+ })
74
+ if (hit) return hit
75
+
76
+ return list[0] ?? null
77
+ }
78
+
79
+ export function pickLocalizedValue(
80
+ list: readonly LocalizedString[] | undefined,
81
+ locale: string,
82
+ fallback = '',
83
+ ): string {
84
+ return pickLocalizedString(list, locale)?.value ?? fallback
85
+ }
86
+
87
+ export function availableSpellings(list: readonly LocalizedString[] | undefined): readonly string[] {
88
+ if (!list) return []
89
+ return [...new Set(list.map((ls) => ls.spelling).filter((s): s is string => Boolean(s)))].sort()
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ export {
2
+ EdoxenConfigSchema,
3
+ SiteSchema,
4
+ DataSchema,
5
+ OutputSchema,
6
+ BodySchema,
7
+ LocaleEntrySchema,
8
+ ThemeSchema,
9
+ NavItemSchema,
10
+ SocialItemSchema,
11
+ FeaturesSchema,
12
+ defineConfig,
13
+ resolveDataPaths,
14
+ activeDataKeys,
15
+ generateCssTokens,
16
+ type EdoxenConfig,
17
+ type EdoxenConfigInput,
18
+ type SiteConfig,
19
+ type DataConfig,
20
+ type OutputConfig,
21
+ type BodyEntry,
22
+ type LocaleEntry,
23
+ type ThemeConfig,
24
+ type NavItem,
25
+ type SocialItem,
26
+ type FeaturesConfig,
27
+ type ResolvedDataPaths,
28
+ type DataPathKey,
29
+ } from './config/index.js'
30
+
31
+ export {
32
+ loadAll,
33
+ validateAll,
34
+ buildProjectFromLoaded,
35
+ preparePayloads,
36
+ prepareDecisionsList,
37
+ prepareMeetingsList,
38
+ lintProject,
39
+ type LoadedData,
40
+ type LoadError,
41
+ type LoadResult,
42
+ type DataSource,
43
+ type ValidationReport,
44
+ type DecisionListItem,
45
+ type DecisionListPayload,
46
+ type DecisionListFacets,
47
+ type MeetingListItem,
48
+ type MeetingListPayload,
49
+ type MeetingListFacets,
50
+ type PagePayloads,
51
+ type LintReport,
52
+ type LintFinding,
53
+ type LintSeverity,
54
+ } from './data/index.js'
55
+
56
+ export {
57
+ decisionJsonLd,
58
+ decisionListItemJsonLd,
59
+ meetingJsonLd,
60
+ meetingListItemJsonLd,
61
+ type SeoContext,
62
+ type JsonLd,
63
+ } from './seo/index.js'
64
+
65
+ export {
66
+ pickLocalizedString,
67
+ pickLocalizedValue,
68
+ availableSpellings,
69
+ twoToThree,
70
+ threeToTwo,
71
+ } from './i18n/index.js'
72
+
73
+ export { EdoxenBrowserError, formatValidationErrors } from './errors.js'
74
+ export { default as edoxenBrowser, type IntegrationOptions } from './integration.js'
@@ -0,0 +1,233 @@
1
+ import type { AstroIntegration, AstroIntegrationLogger } from 'astro'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { extname } from 'node:path'
5
+
6
+ import {
7
+ EdoxenConfigSchema,
8
+ type EdoxenConfig,
9
+ type EdoxenConfigInput,
10
+ } from './config/index.js'
11
+ import {
12
+ loadAll,
13
+ validateAll,
14
+ buildProjectFromLoaded,
15
+ preparePayloads,
16
+ type PagePayloads,
17
+ } from './data/index.js'
18
+ import { EdoxenBrowserError, formatValidationErrors } from './errors.js'
19
+
20
+ export interface IntegrationOptions {
21
+ config: EdoxenConfigInput
22
+ injectRoutes?: boolean
23
+ }
24
+
25
+ const DEFAULT_ROUTE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
26
+ ['/', 'index.astro'],
27
+ ['/decisions', 'decisions/index.astro'],
28
+ ['/decisions/[urn]', 'decisions/[urn].astro'],
29
+ ['/meetings', 'meetings/index.astro'],
30
+ ['/meetings/[urn]', 'meetings/[urn].astro'],
31
+ ['/about', 'about.astro'],
32
+ ['/404', '404.astro'],
33
+ ]
34
+
35
+ const LOCALIZED_ROUTE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
36
+ ['/[lang]', '[lang]/index.astro'],
37
+ ['/[lang]/decisions', '[lang]/decisions/index.astro'],
38
+ ['/[lang]/decisions/[urn]', '[lang]/decisions/[urn].astro'],
39
+ ['/[lang]/meetings', '[lang]/meetings/index.astro'],
40
+ ['/[lang]/meetings/[urn]', '[lang]/meetings/[urn].astro'],
41
+ ['/[lang]/about', '[lang]/about.astro'],
42
+ ]
43
+
44
+ interface IntegrationCache {
45
+ readonly config: EdoxenConfig
46
+ readonly payloads: PagePayloads
47
+ readonly redirects: ReadonlyArray<readonly [string, string, AstroRedirectStatus]>
48
+ }
49
+
50
+ type AstroRedirectStatus = 300 | 301 | 302 | 303 | 304 | 307 | 308
51
+
52
+ interface LegacyRedirect {
53
+ from: string
54
+ to: string
55
+ status?: AstroRedirectStatus
56
+ }
57
+
58
+ function normalizeRedirectStatus(n: number | undefined): AstroRedirectStatus {
59
+ const allowed: readonly number[] = [300, 301, 302, 303, 304, 307, 308]
60
+ return (allowed.includes(n ?? 301) ? n : 301) as AstroRedirectStatus
61
+ }
62
+
63
+ async function readRedirects(dataDir: string): Promise<LegacyRedirect[]> {
64
+ try {
65
+ const raw = await readFile(`${dataDir}/redirects.yaml`, 'utf8')
66
+ const { parse } = await import('yaml')
67
+ const parsed = parse(raw) as Array<{ from?: string; to?: string; status?: number }> | null
68
+ if (!Array.isArray(parsed)) return []
69
+ return parsed
70
+ .filter((r) => r && typeof r.from === 'string' && typeof r.to === 'string')
71
+ .map((r) => ({
72
+ from: r.from as string,
73
+ to: r.to as string,
74
+ status: normalizeRedirectStatus(r.status),
75
+ }))
76
+ } catch {
77
+ return []
78
+ }
79
+ }
80
+
81
+ async function buildCache(opts: IntegrationOptions, logger: AstroIntegrationLogger): Promise<IntegrationCache> {
82
+ const parseResult = EdoxenConfigSchema.safeParse(opts.config)
83
+ if (!parseResult.success) {
84
+ throw new EdoxenBrowserError(
85
+ 'config',
86
+ 'edoxen.config.ts failed schema validation',
87
+ parseResult.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),
88
+ )
89
+ }
90
+ const cfg = parseResult.data
91
+ logger.info(`Loading data from ${cfg.data.decisions}`)
92
+
93
+ const loaded = await loadAll(cfg.data)
94
+ if (!loaded.ok) {
95
+ throw new EdoxenBrowserError(
96
+ 'load',
97
+ 'Failed to load one or more data sources',
98
+ loaded.error.map((e) => `${e.source} (${e.path}): ${e.cause.message}`),
99
+ )
100
+ }
101
+
102
+ const report = await validateAll(loaded.value)
103
+ if (!report.valid) {
104
+ const details: string[] = []
105
+ if (report.decisions) details.push(...formatValidationErrors(report.decisions.errors))
106
+ if (report.meetings) details.push(...formatValidationErrors(report.meetings.errors))
107
+ throw new EdoxenBrowserError('validate', 'Schema validation failed for one or more data sources', details)
108
+ }
109
+
110
+ const project = buildProjectFromLoaded(loaded.value)
111
+ const payloads = preparePayloads(project)
112
+ const redirects = await readRedirects(cfg.data.decisions.replace(/\/[^/]+$/, ''))
113
+ logger.info(`Loaded ${project.decisions.length} decisions, ${project.meetings.length} meetings, ${redirects.length} redirects`)
114
+ return {
115
+ config: cfg,
116
+ payloads,
117
+ redirects: redirects.map((r) => [r.from, r.to, r.status ?? 301] as const),
118
+ }
119
+ }
120
+
121
+ const VIRTUAL_CONFIG = 'virtual:edoxen-config'
122
+ const VIRTUAL_PAYLOADS = 'virtual:edoxen-payloads'
123
+
124
+ function dataEndpointPayload(cache: IntegrationCache, name: 'decisions' | 'meetings'): string {
125
+ if (name === 'decisions') {
126
+ return JSON.stringify({
127
+ items: cache.payloads.decisionsList.items,
128
+ facetBodies: [...cache.payloads.decisionsList.facets.bodies],
129
+ facetKinds: [...cache.payloads.decisionsList.facets.kinds],
130
+ facetYears: [...cache.payloads.decisionsList.facets.years],
131
+ })
132
+ }
133
+ return JSON.stringify({
134
+ items: cache.payloads.meetingsList.items,
135
+ facetDecades: [...cache.payloads.meetingsList.facets.decades],
136
+ facetBodies: [...cache.payloads.meetingsList.facets.bodies],
137
+ facetCountries: [...cache.payloads.meetingsList.facets.countries],
138
+ })
139
+ }
140
+
141
+ function serveDataEndpoint(req: unknown, res: { setHeader: (k: string, v: string) => void; end: (s: string) => void; statusCode: number }, cache: IntegrationCache, name: 'decisions' | 'meetings'): void {
142
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
143
+ res.setHeader('Cache-Control', 'no-store')
144
+ res.end(dataEndpointPayload(cache, name))
145
+ }
146
+
147
+ export default function edoxenBrowser(opts: IntegrationOptions): AstroIntegration {
148
+ let cache: IntegrationCache | null = null
149
+ const injectRoutes = opts.injectRoutes ?? true
150
+ const pagesRoot = new URL('./astro/pages/', import.meta.url)
151
+
152
+ return {
153
+ name: '@edoxen/browser',
154
+ hooks: {
155
+ 'astro:config:setup': async ({ logger, updateConfig, injectRoute }) => {
156
+ cache = await buildCache(opts, logger)
157
+
158
+ if (injectRoutes && injectRoute) {
159
+ for (const [pattern, file] of DEFAULT_ROUTE_PATTERNS) {
160
+ injectRoute({ pattern, entrypoint: new URL(file, pagesRoot) })
161
+ }
162
+ const nonDefaultLocales = cache.config.locales.filter((l) => (l.routePrefix ?? '') !== '')
163
+ if (nonDefaultLocales.length > 0) {
164
+ for (const [pattern, file] of LOCALIZED_ROUTE_PATTERNS) {
165
+ injectRoute({ pattern, entrypoint: new URL(file, pagesRoot) })
166
+ }
167
+ }
168
+ }
169
+
170
+ const redirectMap: Record<string, { status: AstroRedirectStatus; destination: string }> = {}
171
+ for (const [from, to, status] of cache.redirects) {
172
+ redirectMap[from] = { status, destination: to }
173
+ }
174
+
175
+ updateConfig({
176
+ redirects: redirectMap,
177
+ vite: {
178
+ plugins: [
179
+ {
180
+ name: 'edoxen-virtual-modules',
181
+ resolveId(id: string): string | null {
182
+ if (id === VIRTUAL_CONFIG) return `\0${VIRTUAL_CONFIG}`
183
+ if (id === VIRTUAL_PAYLOADS) return `\0${VIRTUAL_PAYLOADS}`
184
+ return null
185
+ },
186
+ load(id: string): string | null {
187
+ if (!cache) throw new EdoxenBrowserError('build', 'Integration cache not ready')
188
+ if (id === `\0${VIRTUAL_CONFIG}`) {
189
+ return `export default ${JSON.stringify(cache.config)}`
190
+ }
191
+ if (id === `\0${VIRTUAL_PAYLOADS}`) {
192
+ return `export default ${JSON.stringify(cache.payloads)}`
193
+ }
194
+ return null
195
+ },
196
+ configureServer(server: { middlewares: { use: (handler: (req: unknown, res: unknown, next: () => void) => void) => void } }) {
197
+ server.middlewares.use((req, res, next) => {
198
+ if (!cache) {
199
+ next()
200
+ return
201
+ }
202
+ const url = (req as { url?: string }).url ?? ''
203
+ if (url === '/data/decisions.json') {
204
+ serveDataEndpoint(req, res as never, cache, 'decisions')
205
+ return
206
+ }
207
+ if (url === '/data/meetings.json') {
208
+ serveDataEndpoint(req, res as never, cache, 'meetings')
209
+ return
210
+ }
211
+ next()
212
+ })
213
+ },
214
+ },
215
+ ],
216
+ },
217
+ })
218
+ },
219
+
220
+ 'astro:build:done': async ({ dir }: { dir: URL }) => {
221
+ if (!cache) return
222
+ const dataDir = new URL('data/', dir)
223
+ await mkdir(dataDir, { recursive: true })
224
+ await writeFile(new URL('decisions.json', dataDir), dataEndpointPayload(cache, 'decisions'))
225
+ await writeFile(new URL('meetings.json', dataDir), dataEndpointPayload(cache, 'meetings'))
226
+ },
227
+ },
228
+ }
229
+ }
230
+
231
+ export const playgroundDir = fileURLToPath(new URL('../', import.meta.url))
232
+ export type { PagePayloads }
233
+ export const acceptedExt = extname
@@ -0,0 +1,18 @@
1
+ class DecadeScroller extends HTMLElement {
2
+ connectedCallback(): void {
3
+ const links = this.querySelectorAll('a[href^="#decade-"]')
4
+ links.forEach((link) => {
5
+ link.addEventListener('click', (event) => {
6
+ const href = (event.currentTarget as HTMLAnchorElement).getAttribute('href')
7
+ if (!href) return
8
+ const target = document.querySelector(href)
9
+ if (!target) return
10
+ event.preventDefault()
11
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
12
+ window.history.replaceState(null, '', href)
13
+ })
14
+ })
15
+ }
16
+ }
17
+
18
+ customElements.define('decade-scroller', DecadeScroller)
@@ -0,0 +1,12 @@
1
+ class PrintButton extends HTMLElement {
2
+ connectedCallback(): void {
3
+ const btn = document.createElement('button')
4
+ btn.type = 'button'
5
+ btn.className = 'edoxen-print-button'
6
+ btn.textContent = this.dataset.label ?? 'Print'
7
+ btn.addEventListener('click', () => window.print())
8
+ this.appendChild(btn)
9
+ }
10
+ }
11
+
12
+ customElements.define('print-button', PrintButton)
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ EMPTY_STATE,
5
+ countFacets,
6
+ decodeState,
7
+ encodeState,
8
+ filterItems,
9
+ toggle,
10
+ type SearchableItem,
11
+ } from './search-filter-core.js'
12
+
13
+ const items: SearchableItem[] = [
14
+ { urn: 'urn:a:1', title: 'First decision', bodyType: 'ciml', kind: 'resolution', year: 2024 },
15
+ { urn: 'urn:a:2', title: 'Second decision', bodyType: 'ciml', kind: 'recommendation', year: 2023 },
16
+ { urn: 'urn:b:1', title: 'Other body decision', bodyType: 'conference', kind: 'resolution', year: 2024 },
17
+ ]
18
+
19
+ describe('filterItems', () => {
20
+ it('returns everything with an empty state', () => {
21
+ expect(filterItems(items, EMPTY_STATE).length).toBe(3)
22
+ })
23
+
24
+ it('filters by substring query against title + urn', () => {
25
+ expect(filterItems(items, { ...EMPTY_STATE, query: 'first' }).map((i) => i.urn)).toEqual(['urn:a:1'])
26
+ expect(filterItems(items, { ...EMPTY_STATE, query: 'urn:b' }).map((i) => i.urn)).toEqual(['urn:b:1'])
27
+ })
28
+
29
+ it('filters by body', () => {
30
+ const state = { ...EMPTY_STATE, bodies: new Set(['ciml']) }
31
+ expect(filterItems(items, state).length).toBe(2)
32
+ })
33
+
34
+ it('combines query + body filters', () => {
35
+ const state = { ...EMPTY_STATE, query: 'decision', bodies: new Set(['conference']) }
36
+ expect(filterItems(items, state).map((i) => i.urn)).toEqual(['urn:b:1'])
37
+ })
38
+
39
+ it('is case-insensitive on the query', () => {
40
+ expect(filterItems(items, { ...EMPTY_STATE, query: 'FIRST' }).map((i) => i.urn)).toEqual(['urn:a:1'])
41
+ })
42
+ })
43
+
44
+ describe('countFacets', () => {
45
+ it('counts distinct bodies, kinds, years', () => {
46
+ const facets = countFacets(items)
47
+ expect(facets.bodies.get('ciml')).toBe(2)
48
+ expect(facets.bodies.get('conference')).toBe(1)
49
+ expect(facets.kinds.get('resolution')).toBe(2)
50
+ expect(facets.years.get(2024)).toBe(2)
51
+ })
52
+ })
53
+
54
+ describe('toggle', () => {
55
+ it('adds then removes a key', () => {
56
+ const s1 = toggle(new Set<string>(), 'a')
57
+ expect([...s1]).toEqual(['a'])
58
+ const s2 = toggle(s1, 'a')
59
+ expect([...s2]).toEqual([])
60
+ })
61
+ })
62
+
63
+ describe('encodeState / decodeState', () => {
64
+ it('round-trips a complex state through URL hash', () => {
65
+ const state = {
66
+ query: 'publishes',
67
+ bodies: new Set(['ciml', 'conference']),
68
+ kinds: new Set(['resolution']),
69
+ years: new Set([2023, 2024]),
70
+ }
71
+ const hash = encodeState(state)
72
+ const restored = decodeState(hash)
73
+ expect(restored.query).toBe(state.query)
74
+ expect([...restored.bodies].sort()).toEqual(['ciml', 'conference'])
75
+ expect([...restored.kinds]).toEqual(['resolution'])
76
+ expect([...restored.years].sort()).toEqual([2023, 2024])
77
+ })
78
+
79
+ it('returns empty state for empty hash', () => {
80
+ const restored = decodeState('')
81
+ expect(restored.query).toBe('')
82
+ expect(restored.bodies.size).toBe(0)
83
+ })
84
+ })