@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,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { activeDataKeys, resolveDataPaths } from './paths.js'
4
+ import type { DataConfig } from './schema.js'
5
+
6
+ function cfg(data: DataConfig): DataConfig {
7
+ return data
8
+ }
9
+
10
+ describe('resolveDataPaths', () => {
11
+ it('returns decisions plus null for omitted entities', () => {
12
+ const paths = resolveDataPaths(cfg({ decisions: './data/decisions' }))
13
+ expect(paths).toEqual({
14
+ decisions: './data/decisions',
15
+ meetings: null,
16
+ agendas: null,
17
+ minutes: null,
18
+ committee: null,
19
+ })
20
+ })
21
+
22
+ it('preserves every provided entity path', () => {
23
+ const paths = resolveDataPaths(cfg({
24
+ decisions: './data/decisions',
25
+ meetings: './data/meetings',
26
+ agendas: './data/agendas',
27
+ minutes: './data/minutes',
28
+ committee: './data/committee.yaml',
29
+ }))
30
+ expect(paths.committee).toBe('./data/committee.yaml')
31
+ expect(paths.minutes).toBe('./data/minutes')
32
+ })
33
+ })
34
+
35
+ describe('activeDataKeys', () => {
36
+ it('lists only keys that have a path', () => {
37
+ expect(activeDataKeys(cfg({ decisions: './data/decisions' }))).toEqual(['decisions'])
38
+ })
39
+
40
+ it('lists every key when all are present, in canonical order', () => {
41
+ const keys = activeDataKeys(cfg({
42
+ decisions: './d',
43
+ meetings: './m',
44
+ agendas: './a',
45
+ minutes: './min',
46
+ committee: './c.yaml',
47
+ }))
48
+ expect(keys).toEqual(['decisions', 'meetings', 'agendas', 'minutes', 'committee'])
49
+ })
50
+
51
+ it('keeps stable order regardless of object key order', () => {
52
+ const keys = activeDataKeys(cfg({
53
+ committee: './c.yaml',
54
+ decisions: './d',
55
+ meetings: './m',
56
+ }))
57
+ expect(keys).toEqual(['decisions', 'meetings', 'committee'])
58
+ })
59
+ })
@@ -0,0 +1,26 @@
1
+ import type { DataConfig } from './schema.js'
2
+
3
+ export interface ResolvedDataPaths {
4
+ readonly decisions: string
5
+ readonly meetings: string | null
6
+ readonly agendas: string | null
7
+ readonly minutes: string | null
8
+ readonly committee: string | null
9
+ }
10
+
11
+ const KEYS = ['decisions', 'meetings', 'agendas', 'minutes', 'committee'] as const
12
+ export type DataPathKey = (typeof KEYS)[number]
13
+
14
+ export function resolveDataPaths(data: DataConfig): ResolvedDataPaths {
15
+ return {
16
+ decisions: data.decisions,
17
+ meetings: data.meetings ?? null,
18
+ agendas: data.agendas ?? null,
19
+ minutes: data.minutes ?? null,
20
+ committee: data.committee ?? null,
21
+ }
22
+ }
23
+
24
+ export function activeDataKeys(data: DataConfig): readonly DataPathKey[] {
25
+ return KEYS.filter((k) => data[k] !== undefined)
26
+ }
@@ -0,0 +1,263 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ BodySchema,
5
+ EdoxenConfigSchema,
6
+ FeaturesSchema,
7
+ LocaleEntrySchema,
8
+ NavItemSchema,
9
+ SocialItemSchema,
10
+ defineConfig,
11
+ } from './schema.js'
12
+
13
+ describe('EdoxenConfigSchema', () => {
14
+ describe('site', () => {
15
+ it('accepts a minimal config and applies defaults', () => {
16
+ const cfg = EdoxenConfigSchema.parse({
17
+ site: { title: 'X', url: 'https://x.org' },
18
+ data: { decisions: './data/decisions' },
19
+ })
20
+
21
+ expect(cfg.site.title).toBe('X')
22
+ expect(cfg.site.description).toBe('')
23
+ expect(cfg.site.basePath).toBe('/')
24
+ expect(cfg.site.locale).toBe('en')
25
+ })
26
+
27
+ it('rejects an empty title', () => {
28
+ expect(() =>
29
+ EdoxenConfigSchema.parse({
30
+ site: { title: '', url: 'https://x.org' },
31
+ data: { decisions: './data/decisions' },
32
+ }),
33
+ ).toThrow()
34
+ })
35
+
36
+ it('rejects an invalid URL', () => {
37
+ expect(() =>
38
+ EdoxenConfigSchema.parse({
39
+ site: { title: 'X', url: 'bad' },
40
+ data: { decisions: './data/decisions' },
41
+ }),
42
+ ).toThrow()
43
+ })
44
+
45
+ it('rejects a malformed basePath', () => {
46
+ expect(() =>
47
+ EdoxenConfigSchema.parse({
48
+ site: { title: 'X', url: 'https://x.org', basePath: 'resolutions' },
49
+ data: { decisions: './data/decisions' },
50
+ }),
51
+ ).toThrow()
52
+ })
53
+
54
+ it('rejects a non-ISO-639 locale', () => {
55
+ expect(() =>
56
+ EdoxenConfigSchema.parse({
57
+ site: { title: 'X', url: 'https://x.org', locale: 'english' },
58
+ data: { decisions: './data/decisions' },
59
+ }),
60
+ ).toThrow()
61
+ })
62
+ })
63
+
64
+ describe('data', () => {
65
+ it('requires decisions path; other entities optional', () => {
66
+ const cfg = EdoxenConfigSchema.parse({
67
+ site: { title: 'X', url: 'https://x.org' },
68
+ data: { decisions: './data/decisions' },
69
+ })
70
+ expect(cfg.data.decisions).toBe('./data/decisions')
71
+ expect(cfg.data.meetings).toBeUndefined()
72
+ })
73
+
74
+ it('accepts all entity paths', () => {
75
+ const cfg = EdoxenConfigSchema.parse({
76
+ site: { title: 'X', url: 'https://x.org' },
77
+ data: {
78
+ decisions: './data/decisions',
79
+ meetings: './data/meetings',
80
+ agendas: './data/agendas',
81
+ minutes: './data/minutes',
82
+ committee: './data/committee.yaml',
83
+ },
84
+ })
85
+ expect(cfg.data.committee).toBe('./data/committee.yaml')
86
+ })
87
+
88
+ it('rejects config without decisions path', () => {
89
+ expect(() =>
90
+ EdoxenConfigSchema.parse({
91
+ site: { title: 'X', url: 'https://x.org' },
92
+ data: {},
93
+ }),
94
+ ).toThrow()
95
+ })
96
+ })
97
+
98
+ describe('bodies', () => {
99
+ it('defaults to a single committee body', () => {
100
+ const cfg = EdoxenConfigSchema.parse({
101
+ site: { title: 'X', url: 'https://x.org' },
102
+ data: { decisions: './data/decisions' },
103
+ })
104
+ expect(cfg.bodies).toEqual([{ code: 'committee', name: 'Committee' }])
105
+ })
106
+
107
+ it('accepts OIML-style multi-body with colors', () => {
108
+ const cfg = EdoxenConfigSchema.parse({
109
+ site: { title: 'X', url: 'https://x.org' },
110
+ data: { decisions: './data/decisions' },
111
+ bodies: [
112
+ { code: 'ciml', name: 'CIML', color: '#1e40af', textColor: '#ffffff' },
113
+ { code: 'conference', name: 'Conference', color: '#7c2d12' },
114
+ ],
115
+ })
116
+ expect(cfg.bodies[0]?.code).toBe('ciml')
117
+ expect(cfg.bodies[1]?.color).toBe('#7c2d12')
118
+ })
119
+
120
+ it('rejects an invalid body code', () => {
121
+ expect(() =>
122
+ BodySchema.parse({ code: 'CIML', name: 'CIML' }),
123
+ ).toThrow()
124
+ })
125
+
126
+ it('rejects a non-hex color', () => {
127
+ expect(() =>
128
+ BodySchema.parse({ code: 'ciml', name: 'CIML', color: 'blue' }),
129
+ ).toThrow()
130
+ })
131
+ })
132
+
133
+ describe('locales', () => {
134
+ it('defaults to a single English locale', () => {
135
+ const cfg = EdoxenConfigSchema.parse({
136
+ site: { title: 'X', url: 'https://x.org' },
137
+ data: { decisions: './data/decisions' },
138
+ })
139
+ expect(cfg.locales).toEqual([{ code: 'en', label: 'English', routePrefix: '' }])
140
+ })
141
+
142
+ it('accepts bilingual EN/FR with route prefix', () => {
143
+ const cfg = EdoxenConfigSchema.parse({
144
+ site: { title: 'X', url: 'https://x.org' },
145
+ data: { decisions: './data/decisions' },
146
+ locales: [
147
+ { code: 'en', label: 'English' },
148
+ { code: 'fr', label: 'Français', routePrefix: '/fr' },
149
+ ],
150
+ })
151
+ expect(cfg.locales[1]?.routePrefix).toBe('/fr')
152
+ })
153
+
154
+ it('rejects a 4-letter locale code', () => {
155
+ expect(() => LocaleEntrySchema.parse({ code: 'eng1', label: 'X' })).toThrow()
156
+ })
157
+ })
158
+
159
+ describe('theme', () => {
160
+ it('applies full defaults when omitted', () => {
161
+ const cfg = EdoxenConfigSchema.parse({
162
+ site: { title: 'X', url: 'https://x.org' },
163
+ data: { decisions: './data/decisions' },
164
+ })
165
+ expect(cfg.theme.primary).toBe('#1f2937')
166
+ expect(cfg.theme.dark.surface).toBe('#1f2937')
167
+ expect(cfg.theme.radius).toBe('0.25rem')
168
+ })
169
+
170
+ it('rejects a non-hex theme color', () => {
171
+ const cfg = {
172
+ site: { title: 'X', url: 'https://x.org' },
173
+ data: { decisions: './data/decisions' },
174
+ theme: { primary: 'red' },
175
+ }
176
+ expect(() => EdoxenConfigSchema.parse(cfg)).toThrow()
177
+ })
178
+
179
+ it('accepts custom properties for consumer overrides', () => {
180
+ const cfg = EdoxenConfigSchema.parse({
181
+ site: { title: 'X', url: 'https://x.org' },
182
+ data: { decisions: './data/decisions' },
183
+ theme: { customProperties: { 'font-display': 'Inter, sans-serif' } },
184
+ })
185
+ expect(cfg.theme.customProperties['font-display']).toBe('Inter, sans-serif')
186
+ })
187
+ })
188
+
189
+ describe('nav', () => {
190
+ it('rejects an href that is not absolute or http(s)/mailto', () => {
191
+ expect(() =>
192
+ NavItemSchema.parse({ label: 'Home', href: 'relative/path' }),
193
+ ).toThrow()
194
+ })
195
+
196
+ it('accepts absolute path, http, https, and mailto', () => {
197
+ expect(NavItemSchema.parse({ label: 'A', href: '/' }).href).toBe('/')
198
+ expect(NavItemSchema.parse({ label: 'B', href: '/decisions' }).href).toBe('/decisions')
199
+ expect(NavItemSchema.parse({ label: 'C', href: 'https://example.org' }).href).toBe('https://example.org')
200
+ expect(NavItemSchema.parse({ label: 'D', href: 'mailto:x@y.org' }).href).toBe('mailto:x@y.org')
201
+ })
202
+ })
203
+
204
+ describe('social', () => {
205
+ it('accepts a known icon enum', () => {
206
+ expect(SocialItemSchema.parse({ label: 'GH', href: 'https://gh.org', icon: 'github' }).icon).toBe('github')
207
+ })
208
+
209
+ it('rejects an unknown icon', () => {
210
+ expect(() =>
211
+ SocialItemSchema.parse({ label: 'X', href: 'https://x.org', icon: 'myspace' }),
212
+ ).toThrow()
213
+ })
214
+
215
+ it('rejects a non-URL href', () => {
216
+ expect(() =>
217
+ SocialItemSchema.parse({ label: 'X', href: '/relative' }),
218
+ ).toThrow()
219
+ })
220
+ })
221
+
222
+ describe('features', () => {
223
+ it('applies defaults', () => {
224
+ const f = FeaturesSchema.parse({})
225
+ expect(f.search).toBe(true)
226
+ expect(f.doi).toBe(false)
227
+ expect(f.pagination.enabled).toBe(false)
228
+ expect(f.pagination.pageSize).toBe(50)
229
+ })
230
+
231
+ it('accepts pagination override', () => {
232
+ const f = FeaturesSchema.parse({ pagination: { enabled: true, pageSize: 100 } })
233
+ expect(f.pagination.pageSize).toBe(100)
234
+ })
235
+
236
+ it('rejects a non-positive page size', () => {
237
+ expect(() =>
238
+ FeaturesSchema.parse({ pagination: { enabled: true, pageSize: 0 } }),
239
+ ).toThrow()
240
+ })
241
+ })
242
+
243
+ describe('defineConfig', () => {
244
+ it('returns a parsed config with defaults filled in', () => {
245
+ const cfg = defineConfig({
246
+ site: { title: 'My Site', url: 'https://mine.org' },
247
+ data: { decisions: './data/decisions' },
248
+ })
249
+ expect(cfg.site.title).toBe('My Site')
250
+ expect(cfg.site.locale).toBe('en')
251
+ expect(cfg.features.search).toBe(true)
252
+ })
253
+
254
+ it('throws on invalid input with a ZodError', () => {
255
+ expect(() =>
256
+ EdoxenConfigSchema.parse({
257
+ site: { title: 'X' },
258
+ data: { decisions: './data/decisions' },
259
+ }),
260
+ ).toThrow()
261
+ })
262
+ })
263
+ })
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod'
2
+
3
+ // Validation patterns. Centralised so call sites stay DRY and the rules
4
+ // can be swapped for `@iso24229/iso639-data` lookups when that package
5
+ // publishes (see TODO.browser/25-audit-and-improvements.md §7.5).
6
+ const LOCALE_RE = /^[a-z]{2,3}$/
7
+ const SCRIPT_RE = /^[A-Z][a-z]{3}$/
8
+ const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
9
+ const BODY_CODE_RE = /^[a-z][a-z0-9_-]*$/
10
+ const HREF_RE = /^(\/[^/]*|https?:|mailto:)/
11
+ const PATH_RE = /.+/
12
+
13
+ export const SiteSchema = z.object({
14
+ title: z.string().min(1),
15
+ description: z.string().default(''),
16
+ url: z.string().url(),
17
+ basePath: z.string().regex(/^\/.*\/$|^\/$/, 'must be a root "/" or "/path/"').default('/'),
18
+ locale: z.string().regex(LOCALE_RE, 'must be an ISO 639-1/3 code').default('en'),
19
+ })
20
+ export type SiteConfig = z.infer<typeof SiteSchema>
21
+
22
+ export const DataSchema = z.object({
23
+ decisions: z.string().regex(PATH_RE),
24
+ meetings: z.string().regex(PATH_RE).optional(),
25
+ agendas: z.string().regex(PATH_RE).optional(),
26
+ minutes: z.string().regex(PATH_RE).optional(),
27
+ committee: z.string().regex(PATH_RE).optional(),
28
+ })
29
+ export type DataConfig = z.infer<typeof DataSchema>
30
+
31
+ export const OutputSchema = z.object({
32
+ dir: z.string().default('./dist'),
33
+ sitemap: z.boolean().default(true),
34
+ robots: z.boolean().default(true),
35
+ })
36
+ export type OutputConfig = z.infer<typeof OutputSchema>
37
+
38
+ export const BodySchema = z.object({
39
+ code: z.string().regex(BODY_CODE_RE, 'lowercase letters, digits, _ or -'),
40
+ name: z.string().min(1),
41
+ color: z.string().regex(HEX_RE).optional(),
42
+ textColor: z.string().regex(HEX_RE).optional(),
43
+ icon: z.string().optional(),
44
+ })
45
+ export type BodyEntry = z.infer<typeof BodySchema>
46
+
47
+ export const LocaleEntrySchema = z.object({
48
+ code: z.string().regex(LOCALE_RE, 'must be an ISO 639-1/3 code'),
49
+ label: z.string().min(1),
50
+ routePrefix: z.string().default(''),
51
+ })
52
+ export type LocaleEntry = z.infer<typeof LocaleEntrySchema>
53
+
54
+ const colorFields = z.string().regex(HEX_RE)
55
+
56
+ export const ThemeSchema = z.object({
57
+ primary: colorFields.default('#1f2937'),
58
+ accent: colorFields.default('#3b82f6'),
59
+ surface: colorFields.default('#ffffff'),
60
+ background: colorFields.default('#f9fafb'),
61
+ text: colorFields.default('#1f2937'),
62
+ muted: colorFields.default('#6b7280'),
63
+ border: colorFields.default('#e5e7eb'),
64
+ success: colorFields.default('#10b981'),
65
+ warning: colorFields.default('#f59e0b'),
66
+ danger: colorFields.default('#ef4444'),
67
+ dark: z.object({
68
+ primary: colorFields.default('#f9fafb'),
69
+ accent: colorFields.default('#3b82f6'),
70
+ surface: colorFields.default('#1f2937'),
71
+ background: colorFields.default('#111827'),
72
+ text: colorFields.default('#f9fafb'),
73
+ muted: colorFields.default('#9ca3af'),
74
+ border: colorFields.default('#374151'),
75
+ }).default({}),
76
+ logos: z.object({
77
+ primary: z.string().optional(),
78
+ footer: z.string().optional(),
79
+ favicon: z.string().optional(),
80
+ }).default({}),
81
+ fontFamily: z.string().optional(),
82
+ radius: z.string().default('0.25rem'),
83
+ customProperties: z.record(z.string()).default({}),
84
+ })
85
+ export type ThemeConfig = z.infer<typeof ThemeSchema>
86
+
87
+ export const NavItemSchema = z.object({
88
+ label: z.string().min(1),
89
+ href: z.string().regex(HREF_RE, 'must start with "/", "http:", "https:", or "mailto:"'),
90
+ locale: z.string().regex(LOCALE_RE).optional(),
91
+ })
92
+ export type NavItem = z.infer<typeof NavItemSchema>
93
+
94
+ export const SocialItemSchema = z.object({
95
+ label: z.string().min(1),
96
+ href: z.string().url(),
97
+ icon: z.enum(['github', 'email', 'linkedin', 'twitter', 'website', 'rss']).optional(),
98
+ })
99
+ export type SocialItem = z.infer<typeof SocialItemSchema>
100
+
101
+ export const FeaturesSchema = z.object({
102
+ search: z.boolean().default(true),
103
+ timeline: z.boolean().default(true),
104
+ urnCopy: z.boolean().default(true),
105
+ doi: z.boolean().default(false),
106
+ darkMode: z.boolean().default(true),
107
+ printStyles: z.boolean().default(true),
108
+ pagination: z.object({
109
+ enabled: z.boolean().default(false),
110
+ pageSize: z.number().int().positive().default(50),
111
+ }).default({}),
112
+ })
113
+ export type FeaturesConfig = z.infer<typeof FeaturesSchema>
114
+
115
+ export const EdoxenConfigSchema = z.object({
116
+ site: SiteSchema,
117
+ data: DataSchema,
118
+ output: OutputSchema.default({}),
119
+ bodies: z.array(BodySchema).default([{ code: 'committee', name: 'Committee' }]),
120
+ locales: z.array(LocaleEntrySchema).default([{ code: 'en', label: 'English', routePrefix: '' }]),
121
+ theme: ThemeSchema.default({}),
122
+ nav: z.array(NavItemSchema).default([]),
123
+ social: z.array(SocialItemSchema).default([]),
124
+ features: FeaturesSchema.default({}),
125
+ })
126
+
127
+ export type EdoxenConfig = z.infer<typeof EdoxenConfigSchema>
128
+ export type EdoxenConfigInput = z.input<typeof EdoxenConfigSchema>
129
+
130
+ export function defineConfig(config: EdoxenConfigInput): EdoxenConfig {
131
+ return EdoxenConfigSchema.parse(config)
132
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { generateCssTokens } from './tokens.js'
4
+ import { ThemeSchema } from './schema.js'
5
+
6
+ function parseTheme(input: Record<string, unknown>) {
7
+ return ThemeSchema.parse(input)
8
+ }
9
+
10
+ describe('generateCssTokens', () => {
11
+ it('emits a :root block with every default color token', () => {
12
+ const theme = parseTheme({})
13
+ const css = generateCssTokens(theme)
14
+ expect(css).toContain(':root {')
15
+ expect(css).toContain('--edoxen-color-primary: #1f2937;')
16
+ expect(css).toContain('--edoxen-color-accent: #3b82f6;')
17
+ expect(css).toContain('--edoxen-color-success: #10b981;')
18
+ expect(css).toContain('--edoxen-radius-sm: 0.25rem;')
19
+ })
20
+
21
+ it('does not double-emit radius as a color', () => {
22
+ const theme = parseTheme({})
23
+ const css = generateCssTokens(theme)
24
+ expect(css).not.toContain('--edoxen-color-radius')
25
+ expect(css).not.toContain('--edoxen-color-font-family')
26
+ })
27
+
28
+ it('emits font-family token under --edoxen-font-sans', () => {
29
+ const theme = parseTheme({ fontFamily: 'Inter, sans-serif' })
30
+ expect(generateCssTokens(theme)).toContain('--edoxen-font-sans: Inter, sans-serif;')
31
+ })
32
+
33
+ it('emits dark-mode tokens under [data-theme="dark"]', () => {
34
+ const theme = parseTheme({})
35
+ const css = generateCssTokens(theme)
36
+ expect(css).toContain(':root[data-theme="dark"] { --edoxen-color-surface: #1f2937; }')
37
+ expect(css).toContain(':root[data-theme="dark"] { --edoxen-color-background: #111827; }')
38
+ })
39
+
40
+ it('reflects consumer overrides', () => {
41
+ const theme = parseTheme({ primary: '#ff0000' })
42
+ expect(generateCssTokens(theme)).toContain('--edoxen-color-primary: #ff0000;')
43
+ })
44
+
45
+ it('emits custom properties as --edoxen-* kebab-case tokens', () => {
46
+ const theme = parseTheme({ customProperties: { fontDisplay: 'Inter', 'hero-size': '4rem' } })
47
+ const css = generateCssTokens(theme)
48
+ expect(css).toContain('--edoxen-font-display: Inter;')
49
+ expect(css).toContain('--edoxen-hero-size: 4rem;')
50
+ })
51
+
52
+ it('skips nested object fields from the root :root block', () => {
53
+ const theme = parseTheme({})
54
+ const css = generateCssTokens(theme)
55
+ expect(css).not.toContain('[object Object]')
56
+ })
57
+ })
@@ -0,0 +1,48 @@
1
+ import type { ThemeConfig } from './schema.js'
2
+
3
+ const TOKEN_NAME_OVERRIDES: Record<string, string | null> = {
4
+ customProperties: null,
5
+ logos: null,
6
+ dark: null,
7
+ fontFamily: '--edoxen-font-sans',
8
+ radius: '--edoxen-radius-sm',
9
+ }
10
+
11
+ function tokenName(key: string): string | null {
12
+ const override = TOKEN_NAME_OVERRIDES[key]
13
+ if (override === null) return null
14
+ if (override) return override
15
+ return `--edoxen-color-${kebab(key)}`
16
+ }
17
+
18
+ function kebab(s: string): string {
19
+ return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
20
+ }
21
+
22
+ export function generateCssTokens(theme: ThemeConfig): string {
23
+ const lines: string[] = [':root {']
24
+
25
+ for (const [key, value] of Object.entries(theme)) {
26
+ if (typeof value !== 'string') continue
27
+ const name = tokenName(key)
28
+ if (name) lines.push(` ${name}: ${value};`)
29
+ }
30
+
31
+ lines.push('}')
32
+
33
+ for (const [key, value] of Object.entries(theme.dark)) {
34
+ if (typeof value !== 'string') continue
35
+ const name = tokenName(key)
36
+ if (name) lines.push(`:root[data-theme="dark"] { ${name}: ${value}; }`)
37
+ }
38
+
39
+ if (theme.customProperties && Object.keys(theme.customProperties).length > 0) {
40
+ lines.push(':root {')
41
+ for (const [k, v] of Object.entries(theme.customProperties)) {
42
+ lines.push(` --edoxen-${kebab(k)}: ${v};`)
43
+ }
44
+ lines.push('}')
45
+ }
46
+
47
+ return lines.join('\n')
48
+ }
@@ -0,0 +1,36 @@
1
+ export {
2
+ loadAll,
3
+ type LoadedData,
4
+ type LoadError,
5
+ type LoadResult,
6
+ type DataSource,
7
+ } from './load.js'
8
+
9
+ export {
10
+ validateAll,
11
+ type ValidationReport,
12
+ } from './validate.js'
13
+
14
+ export {
15
+ buildProjectFromLoaded,
16
+ } from './project.js'
17
+
18
+ export {
19
+ preparePayloads,
20
+ prepareDecisionsList,
21
+ prepareMeetingsList,
22
+ type DecisionListItem,
23
+ type DecisionListPayload,
24
+ type DecisionListFacets,
25
+ type MeetingListItem,
26
+ type MeetingListPayload,
27
+ type MeetingListFacets,
28
+ type PagePayloads,
29
+ } from './prepare.js'
30
+
31
+ export {
32
+ lintProject,
33
+ type LintReport,
34
+ type LintFinding,
35
+ type LintSeverity,
36
+ } from './lint.js'