@pep/term-deck 1.0.10
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/LICENSE +21 -0
- package/README.md +356 -0
- package/bin/term-deck.ts +45 -0
- package/examples/slides/01-welcome.md +9 -0
- package/examples/slides/02-features.md +12 -0
- package/examples/slides/03-colors.md +17 -0
- package/examples/slides/04-ascii-art.md +11 -0
- package/examples/slides/05-gradients.md +14 -0
- package/examples/slides/06-themes.md +13 -0
- package/examples/slides/07-markdown.md +13 -0
- package/examples/slides/08-controls.md +13 -0
- package/examples/slides/09-thanks.md +11 -0
- package/examples/slides/deck.config.ts +13 -0
- package/examples/slides-hacker/01-welcome.md +9 -0
- package/examples/slides-hacker/02-features.md +12 -0
- package/examples/slides-hacker/03-colors.md +17 -0
- package/examples/slides-hacker/04-ascii-art.md +11 -0
- package/examples/slides-hacker/05-gradients.md +14 -0
- package/examples/slides-hacker/06-themes.md +13 -0
- package/examples/slides-hacker/07-markdown.md +13 -0
- package/examples/slides-hacker/08-controls.md +13 -0
- package/examples/slides-hacker/09-thanks.md +11 -0
- package/examples/slides-hacker/deck.config.ts +13 -0
- package/examples/slides-matrix/01-welcome.md +9 -0
- package/examples/slides-matrix/02-features.md +12 -0
- package/examples/slides-matrix/03-colors.md +17 -0
- package/examples/slides-matrix/04-ascii-art.md +11 -0
- package/examples/slides-matrix/05-gradients.md +14 -0
- package/examples/slides-matrix/06-themes.md +13 -0
- package/examples/slides-matrix/07-markdown.md +13 -0
- package/examples/slides-matrix/08-controls.md +13 -0
- package/examples/slides-matrix/09-thanks.md +11 -0
- package/examples/slides-matrix/deck.config.ts +13 -0
- package/examples/slides-minimal/01-welcome.md +9 -0
- package/examples/slides-minimal/02-features.md +12 -0
- package/examples/slides-minimal/03-colors.md +17 -0
- package/examples/slides-minimal/04-ascii-art.md +11 -0
- package/examples/slides-minimal/05-gradients.md +14 -0
- package/examples/slides-minimal/06-themes.md +13 -0
- package/examples/slides-minimal/07-markdown.md +13 -0
- package/examples/slides-minimal/08-controls.md +13 -0
- package/examples/slides-minimal/09-thanks.md +11 -0
- package/examples/slides-minimal/deck.config.ts +13 -0
- package/examples/slides-neon/01-welcome.md +9 -0
- package/examples/slides-neon/02-features.md +12 -0
- package/examples/slides-neon/03-colors.md +17 -0
- package/examples/slides-neon/04-ascii-art.md +11 -0
- package/examples/slides-neon/05-gradients.md +14 -0
- package/examples/slides-neon/06-themes.md +13 -0
- package/examples/slides-neon/07-markdown.md +13 -0
- package/examples/slides-neon/08-controls.md +13 -0
- package/examples/slides-neon/09-thanks.md +11 -0
- package/examples/slides-neon/deck.config.ts +13 -0
- package/examples/slides-retro/01-welcome.md +9 -0
- package/examples/slides-retro/02-features.md +12 -0
- package/examples/slides-retro/03-colors.md +17 -0
- package/examples/slides-retro/04-ascii-art.md +11 -0
- package/examples/slides-retro/05-gradients.md +14 -0
- package/examples/slides-retro/06-themes.md +13 -0
- package/examples/slides-retro/07-markdown.md +13 -0
- package/examples/slides-retro/08-controls.md +13 -0
- package/examples/slides-retro/09-thanks.md +11 -0
- package/examples/slides-retro/deck.config.ts +13 -0
- package/package.json +66 -0
- package/src/cli/__tests__/errors.test.ts +201 -0
- package/src/cli/__tests__/help.test.ts +157 -0
- package/src/cli/__tests__/init.test.ts +110 -0
- package/src/cli/commands/export.ts +33 -0
- package/src/cli/commands/init.ts +125 -0
- package/src/cli/commands/present.ts +29 -0
- package/src/cli/errors.ts +77 -0
- package/src/core/__tests__/slide.test.ts +1759 -0
- package/src/core/__tests__/theme.test.ts +1103 -0
- package/src/core/slide.ts +509 -0
- package/src/core/theme.ts +388 -0
- package/src/export/__tests__/recorder.test.ts +566 -0
- package/src/export/recorder.ts +639 -0
- package/src/index.ts +36 -0
- package/src/presenter/__tests__/main.test.ts +244 -0
- package/src/presenter/main.ts +658 -0
- package/src/renderer/__tests__/screen-extended.test.ts +801 -0
- package/src/renderer/__tests__/screen.test.ts +525 -0
- package/src/renderer/screen.ts +671 -0
- package/src/schemas/__tests__/config.test.ts +429 -0
- package/src/schemas/__tests__/slide.test.ts +349 -0
- package/src/schemas/__tests__/theme.test.ts +970 -0
- package/src/schemas/__tests__/validation.test.ts +256 -0
- package/src/schemas/config.ts +58 -0
- package/src/schemas/slide.ts +56 -0
- package/src/schemas/theme.ts +203 -0
- package/src/schemas/validation.ts +64 -0
- package/src/themes/matrix/index.ts +53 -0
- package/themes/hacker.ts +53 -0
- package/themes/minimal.ts +53 -0
- package/themes/neon.ts +53 -0
- package/themes/retro.ts +53 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { z, ZodError } from 'zod'
|
|
3
|
+
import { ValidationError, formatZodError, safeParse } from '../validation'
|
|
4
|
+
|
|
5
|
+
describe('ValidationError', () => {
|
|
6
|
+
it('extends Error', () => {
|
|
7
|
+
const error = new ValidationError('test message')
|
|
8
|
+
expect(error).toBeInstanceOf(Error)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('has name "ValidationError"', () => {
|
|
12
|
+
const error = new ValidationError('test message')
|
|
13
|
+
expect(error.name).toBe('ValidationError')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('stores the message', () => {
|
|
17
|
+
const error = new ValidationError('Something went wrong')
|
|
18
|
+
expect(error.message).toBe('Something went wrong')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('can be caught as Error', () => {
|
|
22
|
+
let caught: Error | null = null
|
|
23
|
+
try {
|
|
24
|
+
throw new ValidationError('test')
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (e instanceof Error) {
|
|
27
|
+
caught = e
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
expect(caught).not.toBeNull()
|
|
31
|
+
expect(caught?.name).toBe('ValidationError')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('can be identified by instanceof', () => {
|
|
35
|
+
const error = new ValidationError('test')
|
|
36
|
+
expect(error instanceof ValidationError).toBe(true)
|
|
37
|
+
expect(error instanceof Error).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('formatZodError', () => {
|
|
42
|
+
it('formats single field error with path', () => {
|
|
43
|
+
const schema = z.object({
|
|
44
|
+
name: z.string().min(1, { message: 'Name is required' }),
|
|
45
|
+
})
|
|
46
|
+
const result = schema.safeParse({ name: '' })
|
|
47
|
+
expect(result.success).toBe(false)
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
const formatted = formatZodError(result.error, 'config')
|
|
50
|
+
expect(formatted).toBe('Invalid config:\n - name: Name is required')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('formats nested field error with dotted path', () => {
|
|
55
|
+
const schema = z.object({
|
|
56
|
+
colors: z.object({
|
|
57
|
+
primary: z.string().regex(/^#[0-9a-fA-F]{6}$/, {
|
|
58
|
+
message: 'Must be a valid hex color',
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
const result = schema.safeParse({ colors: { primary: 'red' } })
|
|
63
|
+
expect(result.success).toBe(false)
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
const formatted = formatZodError(result.error, 'theme')
|
|
66
|
+
expect(formatted).toBe('Invalid theme:\n - colors.primary: Must be a valid hex color')
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('formats multiple errors', () => {
|
|
71
|
+
const schema = z.object({
|
|
72
|
+
name: z.string().min(1, { message: 'Name is required' }),
|
|
73
|
+
age: z.number().min(0, { message: 'Age must be positive' }),
|
|
74
|
+
})
|
|
75
|
+
const result = schema.safeParse({ name: '', age: -1 })
|
|
76
|
+
expect(result.success).toBe(false)
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
const formatted = formatZodError(result.error, 'person')
|
|
79
|
+
expect(formatted).toContain('Invalid person:')
|
|
80
|
+
expect(formatted).toContain('name: Name is required')
|
|
81
|
+
expect(formatted).toContain('age: Age must be positive')
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('formats error without path (root level)', () => {
|
|
86
|
+
const schema = z.string().min(1, { message: 'Value is required' })
|
|
87
|
+
const result = schema.safeParse('')
|
|
88
|
+
expect(result.success).toBe(false)
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
const formatted = formatZodError(result.error, 'input')
|
|
91
|
+
expect(formatted).toBe('Invalid input:\n - Value is required')
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('formats deeply nested paths', () => {
|
|
96
|
+
const schema = z.object({
|
|
97
|
+
settings: z.object({
|
|
98
|
+
display: z.object({
|
|
99
|
+
theme: z.object({
|
|
100
|
+
color: z.string().min(1, { message: 'Color is required' }),
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
}),
|
|
104
|
+
})
|
|
105
|
+
const result = schema.safeParse({
|
|
106
|
+
settings: { display: { theme: { color: '' } } },
|
|
107
|
+
})
|
|
108
|
+
expect(result.success).toBe(false)
|
|
109
|
+
if (!result.success) {
|
|
110
|
+
const formatted = formatZodError(result.error, 'config')
|
|
111
|
+
expect(formatted).toContain('settings.display.theme.color: Color is required')
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('formats array index in path', () => {
|
|
116
|
+
const schema = z.object({
|
|
117
|
+
items: z.array(z.string().min(1, { message: 'Item cannot be empty' })),
|
|
118
|
+
})
|
|
119
|
+
const result = schema.safeParse({ items: ['valid', ''] })
|
|
120
|
+
expect(result.success).toBe(false)
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
const formatted = formatZodError(result.error, 'list')
|
|
123
|
+
expect(formatted).toContain('items.1: Item cannot be empty')
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('includes context in error message', () => {
|
|
128
|
+
const schema = z.object({ name: z.string() })
|
|
129
|
+
const result = schema.safeParse({ name: 123 })
|
|
130
|
+
expect(result.success).toBe(false)
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
const formatted = formatZodError(result.error, 'slide frontmatter')
|
|
133
|
+
expect(formatted).toStartWith('Invalid slide frontmatter:')
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('safeParse', () => {
|
|
139
|
+
it('returns parsed data for valid input', () => {
|
|
140
|
+
const schema = z.object({
|
|
141
|
+
name: z.string(),
|
|
142
|
+
count: z.number().default(0),
|
|
143
|
+
})
|
|
144
|
+
const result = safeParse(schema, { name: 'test' }, 'config')
|
|
145
|
+
expect(result).toEqual({ name: 'test', count: 0 })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('applies schema defaults', () => {
|
|
149
|
+
const schema = z.object({
|
|
150
|
+
enabled: z.boolean().default(true),
|
|
151
|
+
value: z.number().default(42),
|
|
152
|
+
})
|
|
153
|
+
const result = safeParse(schema, {}, 'settings')
|
|
154
|
+
expect(result).toEqual({ enabled: true, value: 42 })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('throws ValidationError for invalid input', () => {
|
|
158
|
+
const schema = z.object({
|
|
159
|
+
name: z.string().min(1),
|
|
160
|
+
})
|
|
161
|
+
expect(() => safeParse(schema, { name: '' }, 'config')).toThrow(ValidationError)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('thrown error contains formatted message', () => {
|
|
165
|
+
const schema = z.object({
|
|
166
|
+
email: z.string().email({ message: 'Invalid email format' }),
|
|
167
|
+
})
|
|
168
|
+
try {
|
|
169
|
+
safeParse(schema, { email: 'not-an-email' }, 'user')
|
|
170
|
+
expect(true).toBe(false) // Should not reach here
|
|
171
|
+
} catch (e) {
|
|
172
|
+
expect(e).toBeInstanceOf(ValidationError)
|
|
173
|
+
if (e instanceof ValidationError) {
|
|
174
|
+
expect(e.message).toContain('Invalid user:')
|
|
175
|
+
expect(e.message).toContain('email: Invalid email format')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('thrown error has name ValidationError', () => {
|
|
181
|
+
const schema = z.number()
|
|
182
|
+
try {
|
|
183
|
+
safeParse(schema, 'not a number', 'value')
|
|
184
|
+
expect(true).toBe(false) // Should not reach here
|
|
185
|
+
} catch (e) {
|
|
186
|
+
expect(e).toBeInstanceOf(ValidationError)
|
|
187
|
+
if (e instanceof ValidationError) {
|
|
188
|
+
expect(e.name).toBe('ValidationError')
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('works with complex schemas', () => {
|
|
194
|
+
const schema = z.object({
|
|
195
|
+
name: z.string().min(1),
|
|
196
|
+
colors: z.object({
|
|
197
|
+
primary: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
|
198
|
+
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
|
199
|
+
}),
|
|
200
|
+
settings: z.object({
|
|
201
|
+
enabled: z.boolean().default(false),
|
|
202
|
+
}).optional(),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const validData = {
|
|
206
|
+
name: 'test',
|
|
207
|
+
colors: {
|
|
208
|
+
primary: '#ff0066',
|
|
209
|
+
accent: '#00ff66',
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = safeParse(schema, validData, 'theme')
|
|
214
|
+
expect(result.name).toBe('test')
|
|
215
|
+
expect(result.colors.primary).toBe('#ff0066')
|
|
216
|
+
expect(result.settings).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('preserves type inference', () => {
|
|
220
|
+
const schema = z.object({
|
|
221
|
+
count: z.number(),
|
|
222
|
+
name: z.string(),
|
|
223
|
+
items: z.array(z.string()),
|
|
224
|
+
})
|
|
225
|
+
const result = safeParse(schema, { count: 5, name: 'test', items: ['a', 'b'] }, 'data')
|
|
226
|
+
|
|
227
|
+
// Type inference test - these should compile without errors
|
|
228
|
+
const count: number = result.count
|
|
229
|
+
const name: string = result.name
|
|
230
|
+
const items: string[] = result.items
|
|
231
|
+
|
|
232
|
+
expect(count).toBe(5)
|
|
233
|
+
expect(name).toBe('test')
|
|
234
|
+
expect(items).toEqual(['a', 'b'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('handles union types', () => {
|
|
238
|
+
const schema = z.object({
|
|
239
|
+
value: z.union([z.string(), z.array(z.string())]),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const stringResult = safeParse(schema, { value: 'hello' }, 'config')
|
|
243
|
+
expect(stringResult.value).toBe('hello')
|
|
244
|
+
|
|
245
|
+
const arrayResult = safeParse(schema, { value: ['a', 'b'] }, 'config')
|
|
246
|
+
expect(arrayResult.value).toEqual(['a', 'b'])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('rejects invalid union types with clear error', () => {
|
|
250
|
+
const schema = z.object({
|
|
251
|
+
value: z.union([z.string(), z.number()]),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
expect(() => safeParse(schema, { value: [] }, 'config')).toThrow(ValidationError)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { ThemeSchema } from './theme'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Schema for presentation settings.
|
|
6
|
+
* Controls how the presentation behaves during runtime.
|
|
7
|
+
*/
|
|
8
|
+
export const SettingsSchema = z.object({
|
|
9
|
+
// Start slide (0-indexed)
|
|
10
|
+
startSlide: z.number().min(0).default(0),
|
|
11
|
+
// Loop back to first slide after last
|
|
12
|
+
loop: z.boolean().default(false),
|
|
13
|
+
// Auto-advance slides (ms, 0 = disabled)
|
|
14
|
+
autoAdvance: z.number().min(0).default(0),
|
|
15
|
+
// Show slide numbers
|
|
16
|
+
showSlideNumbers: z.boolean().default(false),
|
|
17
|
+
// Show progress bar
|
|
18
|
+
showProgress: z.boolean().default(false),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type Settings = z.infer<typeof SettingsSchema>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Schema for export settings.
|
|
25
|
+
* Controls the output dimensions and quality of exported videos/GIFs.
|
|
26
|
+
*/
|
|
27
|
+
export const ExportSettingsSchema = z.object({
|
|
28
|
+
// Output width in characters (min 80, max 400)
|
|
29
|
+
width: z.number().min(80).max(400).default(120),
|
|
30
|
+
// Output height in characters (min 24, max 100)
|
|
31
|
+
height: z.number().min(24).max(100).default(40),
|
|
32
|
+
// Frames per second for video (min 10, max 60)
|
|
33
|
+
fps: z.number().min(10).max(60).default(30),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type ExportSettings = z.infer<typeof ExportSettingsSchema>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Schema for validating deck configuration (deck.config.ts).
|
|
40
|
+
* Defines the complete configuration for a presentation deck.
|
|
41
|
+
*/
|
|
42
|
+
export const DeckConfigSchema = z.object({
|
|
43
|
+
// Presentation metadata
|
|
44
|
+
title: z.string().optional(),
|
|
45
|
+
author: z.string().optional(),
|
|
46
|
+
date: z.string().optional(),
|
|
47
|
+
|
|
48
|
+
// Theme (already validated Theme object)
|
|
49
|
+
theme: ThemeSchema,
|
|
50
|
+
|
|
51
|
+
// Presentation settings
|
|
52
|
+
settings: SettingsSchema.optional(),
|
|
53
|
+
|
|
54
|
+
// Export settings
|
|
55
|
+
export: ExportSettingsSchema.optional(),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export type DeckConfig = z.infer<typeof DeckConfigSchema>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for validating slide frontmatter.
|
|
5
|
+
* Defines the metadata for a single slide.
|
|
6
|
+
*/
|
|
7
|
+
export const SlideFrontmatterSchema = z.object({
|
|
8
|
+
// Required: window title
|
|
9
|
+
title: z.string().min(1, {
|
|
10
|
+
message: 'Slide must have a title',
|
|
11
|
+
}),
|
|
12
|
+
|
|
13
|
+
// ASCII art text (figlet) - can be a single line or multiple lines
|
|
14
|
+
bigText: z.union([
|
|
15
|
+
z.string(),
|
|
16
|
+
z.array(z.string()),
|
|
17
|
+
]).optional(),
|
|
18
|
+
|
|
19
|
+
// Which gradient to use for bigText
|
|
20
|
+
gradient: z.string().optional(),
|
|
21
|
+
|
|
22
|
+
// Override theme for this slide
|
|
23
|
+
theme: z.string().optional(),
|
|
24
|
+
|
|
25
|
+
// Transition effect
|
|
26
|
+
transition: z.enum([
|
|
27
|
+
'glitch', // Default: glitch reveal line by line
|
|
28
|
+
'fade', // Fade in
|
|
29
|
+
'instant', // No animation
|
|
30
|
+
'typewriter', // Character by character
|
|
31
|
+
]).default('glitch'),
|
|
32
|
+
|
|
33
|
+
// Custom metadata (ignored by renderer, useful for tooling)
|
|
34
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export type SlideFrontmatter = z.infer<typeof SlideFrontmatterSchema>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Schema for a complete slide after parsing.
|
|
41
|
+
* Includes the parsed frontmatter, body content, optional notes, and metadata.
|
|
42
|
+
*/
|
|
43
|
+
export const SlideSchema = z.object({
|
|
44
|
+
// Parsed frontmatter
|
|
45
|
+
frontmatter: SlideFrontmatterSchema,
|
|
46
|
+
// Markdown body content
|
|
47
|
+
body: z.string(),
|
|
48
|
+
// Presenter notes (extracted from <!-- notes --> block)
|
|
49
|
+
notes: z.string().optional(),
|
|
50
|
+
// Source file path
|
|
51
|
+
sourcePath: z.string(),
|
|
52
|
+
// Slide index in deck (0-indexed)
|
|
53
|
+
index: z.number(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export type Slide = z.infer<typeof SlideSchema>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for validating hex color strings.
|
|
5
|
+
* Accepts 6-digit hex colors with # prefix (e.g., #ff0066)
|
|
6
|
+
*/
|
|
7
|
+
export const HexColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, {
|
|
8
|
+
message: 'Color must be a valid hex color (e.g., #ff0066)',
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export type HexColor = z.infer<typeof HexColorSchema>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Schema for validating gradient color arrays.
|
|
15
|
+
* A gradient requires at least 2 hex colors.
|
|
16
|
+
*/
|
|
17
|
+
export const GradientSchema = z.array(HexColorSchema).min(2, {
|
|
18
|
+
message: 'Gradient must have at least 2 colors',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type Gradient = z.infer<typeof GradientSchema>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Schema for validating theme objects.
|
|
25
|
+
* Defines the visual appearance of the presentation deck.
|
|
26
|
+
*/
|
|
27
|
+
export const ThemeSchema = z.object({
|
|
28
|
+
// Theme metadata
|
|
29
|
+
name: z.string().min(1, { message: 'Theme name is required' }),
|
|
30
|
+
description: z.string().optional(),
|
|
31
|
+
author: z.string().optional(),
|
|
32
|
+
version: z.string().optional(),
|
|
33
|
+
|
|
34
|
+
// Color palette
|
|
35
|
+
colors: z.object({
|
|
36
|
+
primary: HexColorSchema,
|
|
37
|
+
secondary: HexColorSchema.optional(),
|
|
38
|
+
accent: HexColorSchema,
|
|
39
|
+
background: HexColorSchema,
|
|
40
|
+
text: HexColorSchema,
|
|
41
|
+
muted: HexColorSchema,
|
|
42
|
+
success: HexColorSchema.optional(),
|
|
43
|
+
warning: HexColorSchema.optional(),
|
|
44
|
+
error: HexColorSchema.optional(),
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
// Named gradients for bigText
|
|
48
|
+
gradients: z.record(z.string(), GradientSchema).refine(
|
|
49
|
+
(g) => Object.keys(g).length >= 1,
|
|
50
|
+
{ message: 'At least one gradient must be defined' }
|
|
51
|
+
),
|
|
52
|
+
|
|
53
|
+
// Glyph set for matrix rain background
|
|
54
|
+
glyphs: z.string().min(10, {
|
|
55
|
+
message: 'Glyph set must have at least 10 characters',
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
// Animation settings
|
|
59
|
+
animations: z.object({
|
|
60
|
+
// Speed multiplier (1.0 = normal, 0.5 = half speed, 2.0 = double speed)
|
|
61
|
+
revealSpeed: z.number().min(0.1).max(5.0).default(1.0),
|
|
62
|
+
// Matrix rain density (number of drops)
|
|
63
|
+
matrixDensity: z.number().min(10).max(200).default(50),
|
|
64
|
+
// Glitch effect iterations
|
|
65
|
+
glitchIterations: z.number().min(1).max(20).default(5),
|
|
66
|
+
// Delay between lines during reveal (ms)
|
|
67
|
+
lineDelay: z.number().min(0).max(500).default(30),
|
|
68
|
+
// Matrix rain update interval (ms)
|
|
69
|
+
matrixInterval: z.number().min(20).max(200).default(80),
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
// Window appearance
|
|
73
|
+
window: z.object({
|
|
74
|
+
// Border style
|
|
75
|
+
borderStyle: z.enum(['line', 'double', 'rounded', 'none']).default('line'),
|
|
76
|
+
// Shadow effect
|
|
77
|
+
shadow: z.boolean().default(true),
|
|
78
|
+
// Padding inside windows
|
|
79
|
+
padding: z.object({
|
|
80
|
+
top: z.number().min(0).max(5).default(1),
|
|
81
|
+
bottom: z.number().min(0).max(5).default(1),
|
|
82
|
+
left: z.number().min(0).max(10).default(2),
|
|
83
|
+
right: z.number().min(0).max(10).default(2),
|
|
84
|
+
}).optional(),
|
|
85
|
+
}).optional(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export type Theme = z.infer<typeof ThemeSchema>
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Color Token System
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Valid color tokens for inline styling in slide body content.
|
|
96
|
+
* Tokens can be either:
|
|
97
|
+
* - Built-in colors: GREEN, ORANGE, CYAN, PINK, WHITE, GRAY
|
|
98
|
+
* - Theme-mapped colors: PRIMARY, SECONDARY, ACCENT, MUTED, TEXT, BACKGROUND
|
|
99
|
+
*
|
|
100
|
+
* Usage in slides: {GREEN}colored text{/}
|
|
101
|
+
*/
|
|
102
|
+
export const ColorTokens = [
|
|
103
|
+
'GREEN',
|
|
104
|
+
'ORANGE',
|
|
105
|
+
'CYAN',
|
|
106
|
+
'PINK',
|
|
107
|
+
'WHITE',
|
|
108
|
+
'GRAY',
|
|
109
|
+
'PRIMARY', // Maps to theme.colors.primary
|
|
110
|
+
'SECONDARY', // Maps to theme.colors.secondary
|
|
111
|
+
'ACCENT', // Maps to theme.colors.accent
|
|
112
|
+
'MUTED', // Maps to theme.colors.muted
|
|
113
|
+
'TEXT', // Maps to theme.colors.text
|
|
114
|
+
'BACKGROUND', // Maps to theme.colors.background
|
|
115
|
+
] as const
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Type for valid color token names.
|
|
119
|
+
*/
|
|
120
|
+
export type ColorToken = typeof ColorTokens[number]
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pattern for matching color tokens in slide content.
|
|
124
|
+
* Matches: {GREEN}, {ORANGE}, {CYAN}, {PINK}, {WHITE}, {GRAY},
|
|
125
|
+
* {PRIMARY}, {SECONDARY}, {ACCENT}, {MUTED}, {TEXT}, {BACKGROUND}, {/}
|
|
126
|
+
*
|
|
127
|
+
* The {/} token closes any open color tag.
|
|
128
|
+
*/
|
|
129
|
+
export const COLOR_TOKEN_PATTERN = /\{(GREEN|ORANGE|CYAN|PINK|WHITE|GRAY|PRIMARY|SECONDARY|ACCENT|MUTED|TEXT|BACKGROUND|\/)\}/g
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Partial Theme for Extension
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Deep partial utility type that makes all nested properties optional.
|
|
137
|
+
* Used for theme extension where only specific fields need to be overridden.
|
|
138
|
+
*/
|
|
139
|
+
export type DeepPartial<T> = T extends object ? {
|
|
140
|
+
[P in keyof T]?: DeepPartial<T[P]>
|
|
141
|
+
} : T
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Partial theme type for use with theme.extend() functionality.
|
|
145
|
+
* All fields (including nested) become optional.
|
|
146
|
+
*/
|
|
147
|
+
export type PartialTheme = DeepPartial<Theme>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Schema for validating partial theme objects.
|
|
151
|
+
* All fields become optional recursively, allowing partial overrides.
|
|
152
|
+
* Used for theme extension validation.
|
|
153
|
+
*/
|
|
154
|
+
export const PartialThemeSchema = ThemeSchema.deepPartial()
|
|
155
|
+
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Default Theme
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Default matrix/cyberpunk theme.
|
|
162
|
+
* Used when no theme is specified or as a base for theme extension.
|
|
163
|
+
*/
|
|
164
|
+
export const DEFAULT_THEME: Theme = {
|
|
165
|
+
name: 'matrix',
|
|
166
|
+
description: 'Default cyberpunk/matrix theme',
|
|
167
|
+
|
|
168
|
+
colors: {
|
|
169
|
+
primary: '#00cc66',
|
|
170
|
+
accent: '#ff6600',
|
|
171
|
+
background: '#0a0a0a',
|
|
172
|
+
text: '#ffffff',
|
|
173
|
+
muted: '#666666',
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
gradients: {
|
|
177
|
+
fire: ['#ff6600', '#ff3300', '#ff0066'],
|
|
178
|
+
cool: ['#00ccff', '#0066ff', '#6600ff'],
|
|
179
|
+
pink: ['#ff0066', '#ff0099', '#cc00ff'],
|
|
180
|
+
hf: ['#99cc00', '#00cc66', '#00cccc'],
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
glyphs: 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789',
|
|
184
|
+
|
|
185
|
+
animations: {
|
|
186
|
+
revealSpeed: 1.0,
|
|
187
|
+
matrixDensity: 50,
|
|
188
|
+
glitchIterations: 5,
|
|
189
|
+
lineDelay: 30,
|
|
190
|
+
matrixInterval: 80,
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
window: {
|
|
194
|
+
borderStyle: 'line',
|
|
195
|
+
shadow: true,
|
|
196
|
+
padding: {
|
|
197
|
+
top: 1,
|
|
198
|
+
bottom: 1,
|
|
199
|
+
left: 2,
|
|
200
|
+
right: 2,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z, ZodError } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom error class for validation failures.
|
|
5
|
+
* Extends Error with a specific name for easy identification.
|
|
6
|
+
*/
|
|
7
|
+
export class ValidationError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'ValidationError'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format Zod errors into user-friendly messages.
|
|
16
|
+
* Formats each issue with its field path and message.
|
|
17
|
+
*
|
|
18
|
+
* @param error - The ZodError to format
|
|
19
|
+
* @param context - A description of what was being validated (e.g., "theme", "slide frontmatter")
|
|
20
|
+
* @returns A formatted string with all validation issues
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const error = new ZodError([...])
|
|
24
|
+
* formatZodError(error, 'theme')
|
|
25
|
+
* // Returns:
|
|
26
|
+
* // "Invalid theme:
|
|
27
|
+
* // - colors.primary: Color must be a valid hex color (e.g., #ff0066)
|
|
28
|
+
* // - name: Theme name is required"
|
|
29
|
+
*/
|
|
30
|
+
export function formatZodError(error: ZodError, context: string): string {
|
|
31
|
+
const issues = error.issues.map((issue) => {
|
|
32
|
+
const path = issue.path.join('.')
|
|
33
|
+
return ` - ${path ? `${path}: ` : ''}${issue.message}`
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return `Invalid ${context}:\n${issues.join('\n')}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse data with a Zod schema and throw a ValidationError with friendly messages on failure.
|
|
41
|
+
*
|
|
42
|
+
* @param schema - The Zod schema to validate against
|
|
43
|
+
* @param data - The data to validate
|
|
44
|
+
* @param context - A description of what's being validated (e.g., "theme", "slide frontmatter")
|
|
45
|
+
* @returns The parsed and validated data
|
|
46
|
+
* @throws {ValidationError} If validation fails
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const theme = safeParse(ThemeSchema, rawData, 'theme')
|
|
50
|
+
* // Throws ValidationError with formatted message if invalid
|
|
51
|
+
*/
|
|
52
|
+
export function safeParse<T>(
|
|
53
|
+
schema: z.ZodSchema<T>,
|
|
54
|
+
data: unknown,
|
|
55
|
+
context: string
|
|
56
|
+
): T {
|
|
57
|
+
const result = schema.safeParse(data)
|
|
58
|
+
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
throw new ValidationError(formatZodError(result.error, context))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result.data
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createTheme } from '../../core/theme'
|
|
2
|
+
|
|
3
|
+
const yaml = `
|
|
4
|
+
name: matrix
|
|
5
|
+
description: Default cyberpunk/matrix theme
|
|
6
|
+
author: term-deck
|
|
7
|
+
version: 1.0.0
|
|
8
|
+
|
|
9
|
+
colors:
|
|
10
|
+
primary: "#00cc66"
|
|
11
|
+
accent: "#ff6600"
|
|
12
|
+
background: "#0a0a0a"
|
|
13
|
+
text: "#ffffff"
|
|
14
|
+
muted: "#666666"
|
|
15
|
+
|
|
16
|
+
gradients:
|
|
17
|
+
fire:
|
|
18
|
+
- "#ff6600"
|
|
19
|
+
- "#ff3300"
|
|
20
|
+
- "#ff0066"
|
|
21
|
+
cool:
|
|
22
|
+
- "#00ccff"
|
|
23
|
+
- "#0066ff"
|
|
24
|
+
- "#6600ff"
|
|
25
|
+
pink:
|
|
26
|
+
- "#ff0066"
|
|
27
|
+
- "#ff0099"
|
|
28
|
+
- "#cc00ff"
|
|
29
|
+
hf:
|
|
30
|
+
- "#99cc00"
|
|
31
|
+
- "#00cc66"
|
|
32
|
+
- "#00cccc"
|
|
33
|
+
|
|
34
|
+
glyphs: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789"
|
|
35
|
+
|
|
36
|
+
animations:
|
|
37
|
+
revealSpeed: 1.0
|
|
38
|
+
matrixDensity: 50
|
|
39
|
+
glitchIterations: 5
|
|
40
|
+
lineDelay: 30
|
|
41
|
+
matrixInterval: 80
|
|
42
|
+
|
|
43
|
+
window:
|
|
44
|
+
borderStyle: line
|
|
45
|
+
shadow: true
|
|
46
|
+
padding:
|
|
47
|
+
top: 1
|
|
48
|
+
bottom: 1
|
|
49
|
+
left: 2
|
|
50
|
+
right: 2
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
export default createTheme(yaml)
|