@sutrata/editor 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 (337) hide show
  1. package/LICENSE +674 -0
  2. package/dist/ai/OnboardingDialog.d.ts +6 -0
  3. package/dist/ai/OnboardingDialog.js +115 -0
  4. package/dist/ai/VoiceConfirmDialog.d.ts +9 -0
  5. package/dist/ai/VoiceConfirmDialog.js +37 -0
  6. package/dist/ai/VoiceToolbar.d.ts +2 -0
  7. package/dist/ai/VoiceToolbar.js +177 -0
  8. package/dist/ai/ai-client.d.ts +71 -0
  9. package/dist/ai/ai-client.js +494 -0
  10. package/dist/ai/commands.d.ts +12 -0
  11. package/dist/ai/commands.js +140 -0
  12. package/dist/ai/generated-providers.json +3696 -0
  13. package/dist/ai/prompts.d.ts +25 -0
  14. package/dist/ai/prompts.js +59 -0
  15. package/dist/ai/voice-service.d.ts +15 -0
  16. package/dist/ai/voice-service.js +81 -0
  17. package/dist/autocomplete/AutocompletePlugin.d.ts +2 -0
  18. package/dist/autocomplete/AutocompletePlugin.js +19 -0
  19. package/dist/autocomplete/suggestions.d.ts +25 -0
  20. package/dist/autocomplete/suggestions.js +126 -0
  21. package/dist/context/DocumentContext.d.ts +68 -0
  22. package/dist/context/DocumentContext.js +605 -0
  23. package/dist/desktop-api.d.ts +40 -0
  24. package/dist/editor/EditorView.d.ts +2 -0
  25. package/dist/editor/EditorView.js +78 -0
  26. package/dist/editor/SelectionMenu.d.ts +2 -0
  27. package/dist/editor/SelectionMenu.js +102 -0
  28. package/dist/editor/attr-pencil-plugin.d.ts +17 -0
  29. package/dist/editor/attr-pencil-plugin.js +158 -0
  30. package/dist/editor/character-decoration-plugin.d.ts +26 -0
  31. package/dist/editor/character-decoration-plugin.js +77 -0
  32. package/dist/editor/character-view.d.ts +35 -0
  33. package/dist/editor/character-view.js +112 -0
  34. package/dist/editor/editor-bus.d.ts +14 -0
  35. package/dist/editor/editor-bus.js +39 -0
  36. package/dist/editor/find-highlight-plugin.d.ts +7 -0
  37. package/dist/editor/find-highlight-plugin.js +57 -0
  38. package/dist/editor/frontmatter-field-view.d.ts +10 -0
  39. package/dist/editor/frontmatter-field-view.js +48 -0
  40. package/dist/editor/frontmatter-field.d.ts +21 -0
  41. package/dist/editor/frontmatter-field.js +116 -0
  42. package/dist/editor/grapheme-cursor.d.ts +10 -0
  43. package/dist/editor/grapheme-cursor.js +104 -0
  44. package/dist/editor/ime-plugin.d.ts +18 -0
  45. package/dist/editor/ime-plugin.js +71 -0
  46. package/dist/editor/insert-helper.d.ts +1 -0
  47. package/dist/editor/insert-helper.js +42 -0
  48. package/dist/editor/keymap.d.ts +3 -0
  49. package/dist/editor/keymap.js +131 -0
  50. package/dist/editor/language-class-plugin.d.ts +7 -0
  51. package/dist/editor/language-class-plugin.js +75 -0
  52. package/dist/editor/plugins.d.ts +1 -0
  53. package/dist/editor/plugins.js +25 -0
  54. package/dist/editor/prosemirror-to-sutra.d.ts +2 -0
  55. package/dist/editor/prosemirror-to-sutra.js +208 -0
  56. package/dist/editor/scene-heading-view.d.ts +27 -0
  57. package/dist/editor/scene-heading-view.js +136 -0
  58. package/dist/editor/schema.d.ts +3 -0
  59. package/dist/editor/schema.js +135 -0
  60. package/dist/editor/sutra-to-prosemirror.d.ts +3 -0
  61. package/dist/editor/sutra-to-prosemirror.js +154 -0
  62. package/dist/editor/transliterate-input.d.ts +10 -0
  63. package/dist/editor/transliterate-input.js +88 -0
  64. package/dist/extensions/storage-adapter.d.ts +59 -0
  65. package/dist/extensions/storage-adapter.js +9 -0
  66. package/dist/extensions/types.d.ts +73 -0
  67. package/dist/extensions/types.js +12 -0
  68. package/dist/file/docx-exporter.d.ts +8 -0
  69. package/dist/file/docx-exporter.js +494 -0
  70. package/dist/file/docx-importer.d.ts +10 -0
  71. package/dist/file/docx-importer.js +283 -0
  72. package/dist/file/print-paginator.d.ts +29 -0
  73. package/dist/file/print-paginator.js +289 -0
  74. package/dist/file/templates.d.ts +2 -0
  75. package/dist/file/templates.js +66 -0
  76. package/dist/file/workflow-reports.d.ts +34 -0
  77. package/dist/file/workflow-reports.js +719 -0
  78. package/dist/findreplace/FindReplace.d.ts +2 -0
  79. package/dist/findreplace/FindReplace.js +164 -0
  80. package/dist/findreplace/find-engine.d.ts +29 -0
  81. package/dist/findreplace/find-engine.js +69 -0
  82. package/dist/findreplace/scroll-to-match.d.ts +29 -0
  83. package/dist/findreplace/scroll-to-match.js +74 -0
  84. package/dist/fonts/tofu-detect.d.ts +1 -0
  85. package/dist/fonts/tofu-detect.js +48 -0
  86. package/dist/i18n/LanguageContext.d.ts +10 -0
  87. package/dist/i18n/LanguageContext.js +174 -0
  88. package/dist/i18n/language-detect.d.ts +1 -0
  89. package/dist/i18n/language-detect.js +58 -0
  90. package/dist/i18n/useTranslation.d.ts +11 -0
  91. package/dist/i18n/useTranslation.js +60 -0
  92. package/dist/index.d.ts +11 -0
  93. package/dist/index.js +5 -0
  94. package/dist/locales/bn.json +143 -0
  95. package/dist/locales/en.json +143 -0
  96. package/dist/locales/gu.json +143 -0
  97. package/dist/locales/hi.json +143 -0
  98. package/dist/locales/kn.json +143 -0
  99. package/dist/locales/ml.json +143 -0
  100. package/dist/locales/mr.json +143 -0
  101. package/dist/locales/or.json +143 -0
  102. package/dist/locales/pa.json +143 -0
  103. package/dist/locales/si.json +143 -0
  104. package/dist/locales/ta.json +143 -0
  105. package/dist/locales/te.json +143 -0
  106. package/dist/navigator/SceneNavigator.d.ts +2 -0
  107. package/dist/navigator/SceneNavigator.js +612 -0
  108. package/dist/navigator/StatisticsDialog.d.ts +6 -0
  109. package/dist/navigator/StatisticsDialog.js +146 -0
  110. package/dist/navigator/scene-list.d.ts +16 -0
  111. package/dist/navigator/scene-list.js +70 -0
  112. package/dist/settings/SettingsDialog.d.ts +6 -0
  113. package/dist/settings/SettingsDialog.js +147 -0
  114. package/dist/shell/AppBar.d.ts +2 -0
  115. package/dist/shell/AppBar.js +34 -0
  116. package/dist/shell/AppShell.d.ts +2 -0
  117. package/dist/shell/AppShell.js +30 -0
  118. package/dist/shell/ConfirmDialog.d.ts +16 -0
  119. package/dist/shell/ConfirmDialog.js +23 -0
  120. package/dist/shell/ElementToolbar.d.ts +2 -0
  121. package/dist/shell/ElementToolbar.js +30 -0
  122. package/dist/shell/LanguageStatus.d.ts +2 -0
  123. package/dist/shell/LanguageStatus.js +65 -0
  124. package/dist/shell/StatusBar.d.ts +2 -0
  125. package/dist/shell/StatusBar.js +54 -0
  126. package/dist/shell/SutrataLogo.d.ts +8 -0
  127. package/dist/shell/SutrataLogo.js +7 -0
  128. package/dist/shell/Toast.d.ts +12 -0
  129. package/dist/shell/Toast.js +21 -0
  130. package/dist/shell/ToolbarButton.d.ts +11 -0
  131. package/dist/shell/ToolbarButton.js +4 -0
  132. package/dist/shell/VersionHistory.d.ts +12 -0
  133. package/dist/shell/VersionHistory.js +89 -0
  134. package/dist/shell/Watermark.d.ts +3 -0
  135. package/dist/shell/Watermark.js +10 -0
  136. package/dist/shell/fonts/fonts.css +1133 -0
  137. package/dist/shell/icons.d.ts +45 -0
  138. package/dist/shell/icons.js +51 -0
  139. package/dist/shell/toolbar-actions.d.ts +6 -0
  140. package/dist/shell/toolbar-actions.js +58 -0
  141. package/dist/source/SourceView.d.ts +1 -0
  142. package/dist/source/SourceView.js +106 -0
  143. package/dist/source/find-highlight-source.d.ts +14 -0
  144. package/dist/source/find-highlight-source.js +47 -0
  145. package/dist/source/sutra-lang.d.ts +2 -0
  146. package/dist/source/sutra-lang.js +97 -0
  147. package/dist/spellcheck/dictionaries.d.ts +9 -0
  148. package/dist/spellcheck/dictionaries.js +16 -0
  149. package/dist/spellcheck/hunspell.d.ts +23 -0
  150. package/dist/spellcheck/hunspell.js +94 -0
  151. package/dist/spellcheck/spellcheck-plugin.d.ts +18 -0
  152. package/dist/spellcheck/spellcheck-plugin.js +75 -0
  153. package/dist/styles/StyleDialog.d.ts +6 -0
  154. package/dist/styles/StyleDialog.js +63 -0
  155. package/dist/styles/builtin-styles.d.ts +43 -0
  156. package/dist/styles/builtin-styles.js +131 -0
  157. package/dist/styles/css-adapter.d.ts +32 -0
  158. package/dist/styles/css-adapter.js +71 -0
  159. package/dist/styles/docx-adapter.d.ts +7 -0
  160. package/dist/styles/docx-adapter.js +84 -0
  161. package/dist/styles/embed-fonts.d.ts +20 -0
  162. package/dist/styles/embed-fonts.js +81 -0
  163. package/dist/styles/pagination.d.ts +19 -0
  164. package/dist/styles/pagination.js +22 -0
  165. package/dist/styles/print-adapter.d.ts +6 -0
  166. package/dist/styles/print-adapter.js +288 -0
  167. package/dist/styles/registry.d.ts +6 -0
  168. package/dist/styles/registry.js +14 -0
  169. package/dist/styles/screenplay.css +3006 -0
  170. package/dist/styles/script-ranges.d.ts +6 -0
  171. package/dist/styles/script-ranges.js +17 -0
  172. package/dist/styles/types.d.ts +93 -0
  173. package/dist/styles/types.js +18 -0
  174. package/dist/styles/validate.d.ts +12 -0
  175. package/dist/styles/validate.js +105 -0
  176. package/dist/templates/templates.d.ts +10 -0
  177. package/dist/templates/templates.js +181 -0
  178. package/dist/titlepage/ExportDialog.d.ts +1 -0
  179. package/dist/titlepage/ExportDialog.js +152 -0
  180. package/dist/titlepage/TitlePageDialog.d.ts +6 -0
  181. package/dist/titlepage/TitlePageDialog.js +96 -0
  182. package/dist/titlepage/frontmatter-form.d.ts +56 -0
  183. package/dist/titlepage/frontmatter-form.js +245 -0
  184. package/dist/titlepage/languages.d.ts +10 -0
  185. package/dist/titlepage/languages.js +31 -0
  186. package/dist/types.d.ts +6 -0
  187. package/dist/types.js +1 -0
  188. package/package.json +47 -0
  189. package/scripts/copy-assets.mjs +16 -0
  190. package/scripts/generate-providers.mjs +127 -0
  191. package/src/ai/OnboardingDialog.tsx +390 -0
  192. package/src/ai/VoiceConfirmDialog.tsx +137 -0
  193. package/src/ai/VoiceToolbar.tsx +321 -0
  194. package/src/ai/ai-client.ts +596 -0
  195. package/src/ai/commands.ts +164 -0
  196. package/src/ai/generated-providers.json +3696 -0
  197. package/src/ai/prompts.ts +62 -0
  198. package/src/ai/voice-service.ts +96 -0
  199. package/src/autocomplete/AutocompletePlugin.ts +21 -0
  200. package/src/autocomplete/suggestions.ts +147 -0
  201. package/src/context/DocumentContext.tsx +648 -0
  202. package/src/desktop-api.d.ts +40 -0
  203. package/src/editor/EditorView.tsx +82 -0
  204. package/src/editor/SelectionMenu.tsx +152 -0
  205. package/src/editor/attr-pencil-plugin.ts +172 -0
  206. package/src/editor/character-decoration-plugin.ts +104 -0
  207. package/src/editor/editor-bus.ts +51 -0
  208. package/src/editor/find-highlight-plugin.ts +68 -0
  209. package/src/editor/frontmatter-field-view.ts +52 -0
  210. package/src/editor/frontmatter-field.ts +115 -0
  211. package/src/editor/grapheme-cursor.ts +99 -0
  212. package/src/editor/ime-plugin.ts +85 -0
  213. package/src/editor/insert-helper.ts +43 -0
  214. package/src/editor/keymap.ts +124 -0
  215. package/src/editor/language-class-plugin.ts +91 -0
  216. package/src/editor/plugins.ts +26 -0
  217. package/src/editor/prosemirror-to-sutra.ts +192 -0
  218. package/src/editor/scene-heading-view.ts +147 -0
  219. package/src/editor/schema.ts +156 -0
  220. package/src/editor/sutra-to-prosemirror.ts +156 -0
  221. package/src/extensions/storage-adapter.ts +65 -0
  222. package/src/extensions/types.ts +60 -0
  223. package/src/file/docx-exporter.ts +585 -0
  224. package/src/file/docx-importer.ts +313 -0
  225. package/src/file/print-paginator.ts +301 -0
  226. package/src/file/templates.ts +73 -0
  227. package/src/file/workflow-reports.ts +820 -0
  228. package/src/findreplace/FindReplace.tsx +242 -0
  229. package/src/findreplace/find-engine.ts +97 -0
  230. package/src/findreplace/scroll-to-match.ts +89 -0
  231. package/src/fonts/tofu-detect.ts +53 -0
  232. package/src/i18n/LanguageContext.tsx +207 -0
  233. package/src/i18n/language-detect.ts +73 -0
  234. package/src/i18n/useTranslation.tsx +74 -0
  235. package/src/index.ts +27 -0
  236. package/src/locales/bn.json +143 -0
  237. package/src/locales/en.json +143 -0
  238. package/src/locales/gu.json +143 -0
  239. package/src/locales/hi.json +143 -0
  240. package/src/locales/kn.json +143 -0
  241. package/src/locales/ml.json +143 -0
  242. package/src/locales/mr.json +143 -0
  243. package/src/locales/or.json +143 -0
  244. package/src/locales/pa.json +143 -0
  245. package/src/locales/si.json +143 -0
  246. package/src/locales/ta.json +143 -0
  247. package/src/locales/te.json +143 -0
  248. package/src/navigator/SceneNavigator.tsx +844 -0
  249. package/src/navigator/StatisticsDialog.tsx +249 -0
  250. package/src/navigator/scene-list.ts +84 -0
  251. package/src/settings/SettingsDialog.tsx +303 -0
  252. package/src/shell/AppBar.tsx +354 -0
  253. package/src/shell/AppShell.tsx +71 -0
  254. package/src/shell/ConfirmDialog.tsx +71 -0
  255. package/src/shell/ElementToolbar.tsx +175 -0
  256. package/src/shell/LanguageStatus.tsx +101 -0
  257. package/src/shell/StatusBar.tsx +135 -0
  258. package/src/shell/SutrataLogo.tsx +89 -0
  259. package/src/shell/Toast.tsx +46 -0
  260. package/src/shell/ToolbarButton.tsx +30 -0
  261. package/src/shell/VersionHistory.tsx +212 -0
  262. package/src/shell/Watermark.tsx +9 -0
  263. package/src/shell/fonts/fonts.css +1133 -0
  264. package/src/shell/icons.tsx +335 -0
  265. package/src/shell/toolbar-actions.ts +59 -0
  266. package/src/source/SourceView.tsx +111 -0
  267. package/src/source/find-highlight-source.ts +57 -0
  268. package/src/source/sutra-lang.ts +121 -0
  269. package/src/spellcheck/dictionaries.ts +21 -0
  270. package/src/spellcheck/hunspell.ts +110 -0
  271. package/src/spellcheck/spellcheck-plugin.ts +83 -0
  272. package/src/styles/StyleDialog.tsx +148 -0
  273. package/src/styles/builtin-styles.ts +139 -0
  274. package/src/styles/css-adapter.ts +76 -0
  275. package/src/styles/docx-adapter.ts +109 -0
  276. package/src/styles/embed-fonts.ts +81 -0
  277. package/src/styles/pagination.ts +27 -0
  278. package/src/styles/print-adapter.ts +288 -0
  279. package/src/styles/registry.ts +20 -0
  280. package/src/styles/screenplay.css +3006 -0
  281. package/src/styles/script-ranges.ts +17 -0
  282. package/src/styles/types.ts +104 -0
  283. package/src/styles/validate.ts +114 -0
  284. package/src/templates/templates.ts +194 -0
  285. package/src/titlepage/ExportDialog.tsx +494 -0
  286. package/src/titlepage/TitlePageDialog.tsx +312 -0
  287. package/src/titlepage/frontmatter-form.ts +280 -0
  288. package/src/titlepage/languages.ts +37 -0
  289. package/src/types.ts +7 -0
  290. package/tests/AppBar.test.tsx +35 -0
  291. package/tests/AppShell.test.tsx +22 -0
  292. package/tests/ElementToolbar.test.tsx +36 -0
  293. package/tests/LanguageStatus.test.tsx +129 -0
  294. package/tests/SelectionMenu.test.tsx +71 -0
  295. package/tests/ToolbarButton.test.tsx +24 -0
  296. package/tests/ai-onboarding.test.ts +102 -0
  297. package/tests/ai-status.test.tsx +72 -0
  298. package/tests/ai-structured.test.ts +94 -0
  299. package/tests/autocomplete/AutocompletePlugin.test.ts +20 -0
  300. package/tests/autocomplete/suggestions.test.ts +106 -0
  301. package/tests/document-context.test.tsx +565 -0
  302. package/tests/docx-importer.test.ts +160 -0
  303. package/tests/editor/character-decoration-plugin.test.ts +101 -0
  304. package/tests/editor/character-extension.test.ts +44 -0
  305. package/tests/editor/grapheme-cursor.test.ts +118 -0
  306. package/tests/editor/ime-plugin.test.ts +72 -0
  307. package/tests/editor/inline-note-bridge.test.ts +32 -0
  308. package/tests/editor/language-class-plugin.test.ts +265 -0
  309. package/tests/editor/title-page.test.ts +124 -0
  310. package/tests/embed-fonts.test.ts +17 -0
  311. package/tests/exporters.test.ts +365 -0
  312. package/tests/findreplace/FindReplace.test.tsx +391 -0
  313. package/tests/findreplace/find-engine.test.ts +86 -0
  314. package/tests/findreplace/scroll-to-match.test.ts +50 -0
  315. package/tests/fonts/tofu-detect.test.ts +122 -0
  316. package/tests/i18n/LanguageContext.test.tsx +146 -0
  317. package/tests/i18n/language-detect.test.ts +54 -0
  318. package/tests/i18n/locale-parity.test.ts +25 -0
  319. package/tests/i18n/useTranslation.test.tsx +45 -0
  320. package/tests/keymap.test.ts +18 -0
  321. package/tests/navigator/SceneNavigator.test.tsx +177 -0
  322. package/tests/navigator/StatisticsDialog.test.tsx +63 -0
  323. package/tests/navigator/scene-list.test.ts +71 -0
  324. package/tests/print-paginator.test.ts +82 -0
  325. package/tests/shell/VersionHistory.test.tsx +129 -0
  326. package/tests/spellcheck/hunspell.test.ts +82 -0
  327. package/tests/spellcheck/spellcheck-plugin.test.ts +56 -0
  328. package/tests/styles.test.ts +315 -0
  329. package/tests/templates/templates.test.ts +83 -0
  330. package/tests/titlepage/TitlePageDialog.test.tsx +90 -0
  331. package/tests/titlepage/apply-title-page.test.ts +61 -0
  332. package/tests/titlepage/frontmatter-form.test.ts +115 -0
  333. package/tests/toolbar-actions.test.ts +93 -0
  334. package/tests/voice-commands.test.ts +58 -0
  335. package/tsconfig.json +17 -0
  336. package/vitest.config.ts +11 -0
  337. package/vitest.setup.ts +65 -0
@@ -0,0 +1,129 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { VersionHistory } from '../../src/shell/VersionHistory'
3
+ import type { VersionEntry } from '../../src/types'
4
+
5
+ // Scoped to exclude the dialog's own "×" close button, which is also role="button"
6
+ // and renders before the version list in DOM order.
7
+ function clickOlderVersion() {
8
+ const items = document.querySelectorAll<HTMLButtonElement>('.cs-version-list-item')
9
+ fireEvent.click(items[1]!) // index 0 = "Current"
10
+ }
11
+
12
+ function makeVersions(): VersionEntry[] {
13
+ return [
14
+ { id: 'v2', timestamp: 200, content: 'In the last two weeks' },
15
+ { id: 'v1', timestamp: 100, content: 'In the last five weeks' },
16
+ ]
17
+ }
18
+
19
+ describe('VersionHistory word-level diff', () => {
20
+ it('highlights only the changed word, leaving the rest of the sentence unstyled', () => {
21
+ render(
22
+ <VersionHistory
23
+ versions={makeVersions()}
24
+ filePath="myscript.sutra"
25
+ onRestore={vi.fn()}
26
+ onClearAll={vi.fn().mockResolvedValue(undefined)}
27
+ visible={true}
28
+ onClose={() => {}}
29
+ />,
30
+ )
31
+
32
+ clickOlderVersion()
33
+
34
+ const removed = document.querySelector('.cs-version-diff-removed')
35
+ const added = document.querySelector('.cs-version-diff-added')
36
+
37
+ expect(removed?.textContent).toBe('five')
38
+ expect(added?.textContent).toBe('two')
39
+
40
+ // The unchanged words are present but carry no diff styling.
41
+ const content = document.querySelector('.cs-version-diff-content')
42
+ expect(content?.textContent).toContain('In the last')
43
+ expect(content?.textContent).toContain('weeks')
44
+ })
45
+
46
+ it('shows a no-differences message when the selected version matches current', () => {
47
+ const versions: VersionEntry[] = [
48
+ { id: 'v1', timestamp: 100, content: 'Same content.' },
49
+ { id: 'v0', timestamp: 50, content: 'Same content.' },
50
+ ]
51
+ render(
52
+ <VersionHistory
53
+ versions={versions}
54
+ filePath="myscript.sutra"
55
+ onRestore={vi.fn()}
56
+ onClearAll={vi.fn().mockResolvedValue(undefined)}
57
+ visible={true}
58
+ onClose={() => {}}
59
+ />,
60
+ )
61
+
62
+ clickOlderVersion()
63
+ expect(screen.getByText('No differences from the current version')).toBeInTheDocument()
64
+ })
65
+ })
66
+
67
+ describe('VersionHistory scrollbar change indicator', () => {
68
+ it('renders one marker per change so a change is findable in a long diff', () => {
69
+ render(
70
+ <VersionHistory
71
+ versions={makeVersions()}
72
+ filePath="myscript.sutra"
73
+ onRestore={vi.fn()}
74
+ onClearAll={vi.fn().mockResolvedValue(undefined)}
75
+ visible={true}
76
+ onClose={() => {}}
77
+ />,
78
+ )
79
+
80
+ clickOlderVersion()
81
+
82
+ const removedMarkers = document.querySelectorAll('.cs-version-diff-marker-removed')
83
+ const addedMarkers = document.querySelectorAll('.cs-version-diff-marker-added')
84
+ expect(removedMarkers.length).toBe(1)
85
+ expect(addedMarkers.length).toBe(1)
86
+ })
87
+
88
+ it('renders no markers (but keeps the minimap strip, unconditionally, for stable layout) when there are no differences', () => {
89
+ const versions: VersionEntry[] = [
90
+ { id: 'v1', timestamp: 100, content: 'Same content.' },
91
+ { id: 'v0', timestamp: 50, content: 'Same content.' },
92
+ ]
93
+ render(
94
+ <VersionHistory
95
+ versions={versions}
96
+ filePath="myscript.sutra"
97
+ onRestore={vi.fn()}
98
+ onClearAll={vi.fn().mockResolvedValue(undefined)}
99
+ visible={true}
100
+ onClose={() => {}}
101
+ />,
102
+ )
103
+
104
+ clickOlderVersion()
105
+ // The strip itself is always in the DOM (see VersionHistory.tsx comment: this
106
+ // keeps the diff pane's width — and thus every measured marker offset — stable
107
+ // from the very first render instead of shifting once markers pop in).
108
+ expect(document.querySelector('.cs-version-diff-minimap')).not.toBeNull()
109
+ expect(document.querySelectorAll('.cs-version-diff-marker').length).toBe(0)
110
+ })
111
+
112
+ it('clicking a marker scrolls the diff pane without throwing', () => {
113
+ render(
114
+ <VersionHistory
115
+ versions={makeVersions()}
116
+ filePath="myscript.sutra"
117
+ onRestore={vi.fn()}
118
+ onClearAll={vi.fn().mockResolvedValue(undefined)}
119
+ visible={true}
120
+ onClose={() => {}}
121
+ />,
122
+ )
123
+
124
+ clickOlderVersion()
125
+
126
+ const marker = document.querySelector<HTMLButtonElement>('.cs-version-diff-marker')!
127
+ expect(() => fireEvent.click(marker)).not.toThrow()
128
+ })
129
+ })
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createCheckerFromWords, addToCustomDictionary, clearCheckerCache, loadDictionary } from '../../src/spellcheck/hunspell'
3
+
4
+ describe('hunspell', () => {
5
+ beforeEach(() => {
6
+ clearCheckerCache()
7
+ })
8
+
9
+ describe('createCheckerFromWords (fixture dictionary)', () => {
10
+ it('check returns true for known words', () => {
11
+ const checker = createCheckerFromWords(['hello', 'world', 'screenplay'])
12
+ expect(checker.check('hello')).toBe(true)
13
+ expect(checker.check('world')).toBe(true)
14
+ })
15
+
16
+ it('check returns false for unknown words', () => {
17
+ const checker = createCheckerFromWords(['hello', 'world'])
18
+ expect(checker.check('xyzzy')).toBe(false)
19
+ })
20
+
21
+ it('check is case-insensitive', () => {
22
+ const checker = createCheckerFromWords(['hello'])
23
+ expect(checker.check('Hello')).toBe(true)
24
+ expect(checker.check('HELLO')).toBe(true)
25
+ })
26
+
27
+ it('suggest returns an array', () => {
28
+ const checker = createCheckerFromWords(['hello'])
29
+ const suggestions = checker.suggest('helo')
30
+ expect(Array.isArray(suggestions)).toBe(true)
31
+ })
32
+ })
33
+
34
+ describe('addToCustomDictionary', () => {
35
+ it('custom words pass check after being added', () => {
36
+ const checker = createCheckerFromWords(['hello'])
37
+ expect(checker.check('screenplay')).toBe(false)
38
+
39
+ // Add directly via the internal method (the checker is a SimpleSpellChecker)
40
+ ;(checker as any).addCustomWord('screenplay')
41
+ expect(checker.check('screenplay')).toBe(true)
42
+ })
43
+
44
+ it('addToCustomDictionary updates a cached checker', async () => {
45
+ // Prime the cache with an empty checker for lang 'en-test'
46
+ const cached = await loadDictionary('xx-custom')
47
+ expect(cached.check('screenplay')).toBe(false)
48
+
49
+ addToCustomDictionary('xx-custom', 'screenplay')
50
+ expect(cached.check('screenplay')).toBe(true)
51
+ })
52
+ })
53
+
54
+ describe('loadDictionary', () => {
55
+ it('returns a SpellChecker for a known language even with no dict file', async () => {
56
+ const checker = await loadDictionary('en')
57
+ expect(checker).toBeDefined()
58
+ expect(typeof checker.check).toBe('function')
59
+ expect(typeof checker.suggest).toBe('function')
60
+ })
61
+
62
+ it('caches the checker on second call', async () => {
63
+ const checker1 = await loadDictionary('ta')
64
+ const checker2 = await loadDictionary('ta')
65
+ expect(checker1).toBe(checker2) // same reference
66
+ })
67
+
68
+ it('returns empty checker for unknown language code', async () => {
69
+ const checker = await loadDictionary('xx')
70
+ expect(checker.check('anything')).toBe(false)
71
+ })
72
+
73
+ it('returns a checker with check and suggest for all manifest languages', async () => {
74
+ for (const lang of ['en', 'hi', 'ta', 'te', 'kn', 'ml', 'bn', 'gu', 'pa', 'or']) {
75
+ clearCheckerCache()
76
+ const checker = await loadDictionary(lang)
77
+ expect(typeof checker.check).toBe('function')
78
+ expect(typeof checker.suggest).toBe('function')
79
+ }
80
+ })
81
+ })
82
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { EditorState } from 'prosemirror-state'
3
+ import { schema } from '../../src/editor/schema'
4
+ import { createSpellcheckPlugin, setSpellcheckLanguage, tokenizeWords } from '../../src/spellcheck/spellcheck-plugin'
5
+
6
+ describe('spellcheck-plugin', () => {
7
+ it('createSpellcheckPlugin returns a Plugin', () => {
8
+ const plugin = createSpellcheckPlugin()
9
+ expect(plugin).toBeDefined()
10
+ expect(typeof plugin.spec.state).toBe('object')
11
+ })
12
+
13
+ it('plugin initializes with empty DecorationSet', () => {
14
+ const plugin = createSpellcheckPlugin()
15
+ const state = EditorState.create({
16
+ schema,
17
+ plugins: [plugin],
18
+ })
19
+ // Access state via plugin key (correct way without a live EditorView)
20
+ const pluginState = plugin.getState(state)
21
+ expect(pluginState).toBeDefined()
22
+ })
23
+
24
+ it('setSpellcheckLanguage does not throw', () => {
25
+ expect(() => setSpellcheckLanguage('hi')).not.toThrow()
26
+ expect(() => setSpellcheckLanguage('en')).not.toThrow()
27
+ })
28
+
29
+ it('plugin is registered in buildPlugins', async () => {
30
+ const { buildPlugins } = await import('../../src/editor/plugins')
31
+ const plugins = buildPlugins()
32
+ // Should have more than 4 plugins (history, keymaps x2, languageClass, ime, autocomplete, spellcheck)
33
+ expect(plugins.length).toBeGreaterThan(4)
34
+ })
35
+
36
+ it('tokenizeWords splits plain text into tokens', () => {
37
+ const tokens = tokenizeWords('hello world foo')
38
+ expect(tokens).toHaveLength(3)
39
+ expect(tokens[0]).toMatchObject({ word: 'hello', start: 0, end: 5 })
40
+ expect(tokens[1]).toMatchObject({ word: 'world', start: 6, end: 11 })
41
+ expect(tokens[2]).toMatchObject({ word: 'foo', start: 12, end: 15 })
42
+ })
43
+
44
+ it('tokenizeWords handles punctuation and numbers', () => {
45
+ const tokens = tokenizeWords('Hello, world! 123')
46
+ // Should only capture words, not numbers or punctuation
47
+ const words = tokens.map(t => t.word)
48
+ expect(words).toContain('Hello')
49
+ expect(words).toContain('world')
50
+ expect(words).not.toContain('123')
51
+ })
52
+
53
+ it('tokenizeWords returns empty array for empty string', () => {
54
+ expect(tokenizeWords('')).toHaveLength(0)
55
+ })
56
+ })
@@ -0,0 +1,315 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { CLASSIC_STYLE, INDUSTRY_COURIER_STYLE, TRADITIONAL_STYLE, BUILTIN_STYLES } from '../src/styles/builtin-styles'
3
+ import { resolveStyle, allStyles } from '../src/styles/registry'
4
+ import { styleToCssVars, applyStyleVars } from '../src/styles/css-adapter'
5
+ import { styleToDocxParagraphStyles } from '../src/styles/docx-adapter'
6
+ import { styleToPrintCss } from '../src/styles/print-adapter'
7
+ import { validateStyleJson } from '../src/styles/validate'
8
+ import { DEFAULT_COVER_LAYOUT } from '../src/styles/types'
9
+ import type { ScreenplayStyleDefinition } from '../src/styles/types'
10
+
11
+ const CONTENT_WIDTH_TWIPS_LETTER = 9360 // 12240 - 1440 - 1440, i.e. classic's 1in margins
12
+
13
+ describe('builtin-styles', () => {
14
+ it('Traditional uses 10pt body / 12pt headings (regression: was 16pt/18pt, too large)', () => {
15
+ const e = TRADITIONAL_STYLE.elements
16
+ expect(e.sceneHeading.fontSizePt).toBe(12)
17
+ expect(e.section.fontSizePt).toBe(12)
18
+ for (const key of ['action', 'character', 'parenthetical', 'dialogue', 'transition', 'centered', 'lyrics'] as const) {
19
+ expect(e[key].fontSizePt).toBe(10)
20
+ }
21
+ })
22
+
23
+ it('Traditional note/logline match the 10pt body size (regression: note was 13pt, logline was 16pt)', () => {
24
+ const e = TRADITIONAL_STYLE.elements
25
+ expect(e.note.fontSizePt).toBe(10)
26
+ expect(e.logline.fontSizePt).toBe(10)
27
+ })
28
+
29
+ it('Traditional note text is gray, not the default black', () => {
30
+ expect(TRADITIONAL_STYLE.elements.note.color).toBe('666666')
31
+ })
32
+
33
+ it('Traditional gives lyrics a small left margin so they read as distinct from flush-left action', () => {
34
+ expect(TRADITIONAL_STYLE.elements.lyrics.marginLeftPct).toBeGreaterThan(0)
35
+ expect(TRADITIONAL_STYLE.elements.action.marginLeftPct ?? 0).toBe(0)
36
+ })
37
+ })
38
+
39
+ describe('registry', () => {
40
+ it('resolves an unknown or missing id to classic', () => {
41
+ expect(resolveStyle('nonexistent-id', [])).toBe(CLASSIC_STYLE)
42
+ expect(resolveStyle(undefined, [])).toBe(CLASSIC_STYLE)
43
+ })
44
+
45
+ it('resolves a known builtin id', () => {
46
+ expect(resolveStyle('industry-courier', [])).toBe(INDUSTRY_COURIER_STYLE)
47
+ })
48
+
49
+ it('resolves a custom style by id, falling back to classic if not supplied', () => {
50
+ const custom: ScreenplayStyleDefinition = { ...CLASSIC_STYLE, id: 'my-custom', name: 'My Custom' }
51
+ expect(resolveStyle('my-custom', [custom])).toBe(custom)
52
+ expect(resolveStyle('my-custom', [])).toBe(CLASSIC_STYLE)
53
+ })
54
+
55
+ it('allStyles lists builtins before customs', () => {
56
+ const custom: ScreenplayStyleDefinition = { ...CLASSIC_STYLE, id: 'my-custom', name: 'My Custom' }
57
+ const list = allStyles([custom])
58
+ expect(list).toEqual([...BUILTIN_STYLES, custom])
59
+ })
60
+ })
61
+
62
+ describe('css-adapter', () => {
63
+ it('produces expected CSS variable values for classic', () => {
64
+ const vars = styleToCssVars(CLASSIC_STYLE)
65
+ expect(vars['--cs-body-font-family']).toBe('Noto Sans')
66
+ expect(vars['--cs-character-color']).toBe('#9c4221'.toUpperCase().replace('#', '#')) // sanity: literal below is canonical
67
+ expect(vars['--cs-character-color']).toBe('#9C4221')
68
+ expect(vars['--cs-scene-heading-color']).toBe('#0A5C6A')
69
+ expect(vars['--cs-scene-heading-font-weight']).toBe('bold')
70
+ expect(vars['--cs-action-font-weight']).toBe('normal')
71
+ // null (not the string 'inherit'!) means "no override" — applyStyleVars clears the
72
+ // property with removeProperty so the CSS var()'s own `inherit` fallback takes over.
73
+ // Setting a custom property's literal value to 'inherit' would instead make the
74
+ // property itself inherit from its own parent, breaking inheritance for everything below it.
75
+ expect(vars['--cs-action-color']).toBeNull()
76
+ expect(vars['--cs-dialogue-margin-left']).toBe('17%')
77
+ expect(vars['--cs-transition-text-align']).toBe('right')
78
+ expect(vars['--cs-centered-text-align']).toBe('center')
79
+ expect(vars['--cs-action-text-align']).toBe('left')
80
+ expect(vars['--cs-lyrics-font-style']).toBe('italic')
81
+ expect(vars['--cs-scene-heading-margin-top']).toBe('13pt')
82
+ })
83
+
84
+ it('carries Traditional\'s lyrics left margin through to the CSS var, unlike flush-left action', () => {
85
+ const vars = styleToCssVars(TRADITIONAL_STYLE)
86
+ expect(vars['--cs-lyrics-margin-left']).toBe('6%')
87
+ expect(vars['--cs-action-margin-left']).toBe('0%')
88
+ })
89
+
90
+ it('never emits margin-bottom / spaceAfterPt-derived vars (editor has no space-after concept)', () => {
91
+ const vars = styleToCssVars(CLASSIC_STYLE)
92
+ expect(Object.keys(vars).some(k => k.includes('margin-bottom'))).toBe(false)
93
+ })
94
+
95
+ it('applyStyleVars clears a previous style\'s color instead of setting the literal "inherit"', () => {
96
+ const el = document.createElement('div')
97
+ applyStyleVars(el, CLASSIC_STYLE) // has an action color? no — but character does
98
+ applyStyleVars(el, { ...CLASSIC_STYLE, elements: { ...CLASSIC_STYLE.elements, character: { ...CLASSIC_STYLE.elements.character, color: 'FF0000' } } })
99
+ expect(el.style.getPropertyValue('--cs-character-color')).toBe('#FF0000')
100
+
101
+ // Switching to a style with no color for this element must clear the property
102
+ // (not set it to the string 'inherit', which would break inheritance for descendants).
103
+ applyStyleVars(el, INDUSTRY_COURIER_STYLE)
104
+ expect(el.style.getPropertyValue('--cs-character-color')).toBe('')
105
+ expect(el.style.cssText).not.toContain('inherit')
106
+ })
107
+ })
108
+
109
+ describe('docx-adapter', () => {
110
+ it('keeps the CineXxx style ids/names stable (docx-importer.ts depends on this)', () => {
111
+ const styles = styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
112
+ const ids = styles.map(s => s.id)
113
+ expect(ids).toEqual([
114
+ 'CineSceneHeading', 'CineAction', 'CineCharacter', 'CineParenthetical', 'CineDialogue',
115
+ 'CineTransition', 'CineCentered', 'CineLyrics', 'CineSection', 'CineNote', 'CineComment',
116
+ 'CineTitle', 'CineAuthor', 'CineLogline',
117
+ ])
118
+ for (const s of styles) expect(s.name).toBe(s.id)
119
+ })
120
+
121
+ it('defines the cover-page styles the exporter references (else Word falls back to Normal)', () => {
122
+ const byId = Object.fromEntries(
123
+ styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER).map(s => [s.id, s])
124
+ )
125
+ expect(byId['CineTitle']!.run!.size).toBe(48) // 24pt * 2
126
+ expect(byId['CineTitle']!.run!.bold).toBe(true)
127
+ expect(byId['CineTitle']!.run!.color).toBe('0A5C6A')
128
+ expect(byId['CineTitle']!.paragraph!.alignment).toBe('center')
129
+ expect(byId['CineAuthor']!.run!.size).toBe(32) // 16pt * 2
130
+ expect(byId['CineAuthor']!.paragraph!.alignment).toBe('center')
131
+ expect(byId['CineLogline']!.run!.italics).toBe(true)
132
+ expect(byId['CineLogline']!.paragraph!.alignment).toBe('center')
133
+ })
134
+
135
+ it('converts classic pt/percent values to docx half-points/twips correctly', () => {
136
+ const styles = styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
137
+ const byId = Object.fromEntries(styles.map(s => [s.id, s]))
138
+
139
+ expect(byId['CineSceneHeading']!.run!.size).toBe(26) // 13pt * 2
140
+ expect(byId['CineSceneHeading']!.run!.bold).toBe(true)
141
+ expect(byId['CineSceneHeading']!.run!.color).toBe('0A5C6A')
142
+ expect(byId['CineSceneHeading']!.paragraph!.indent).toEqual({ left: 936, right: 749 }) // 10%/8% of 9360
143
+ expect(byId['CineSceneHeading']!.paragraph!.spacing).toEqual({ before: 260, after: 144 })
144
+
145
+ expect(byId['CineCharacter']!.paragraph!.indent).toEqual({ left: 1591, right: undefined })
146
+ expect(byId['CineCharacter']!.paragraph!.spacing).toEqual({ before: 130, after: 0 })
147
+
148
+ expect(byId['CineDialogue']!.run!.size).toBe(24) // 12pt * 2
149
+ expect(byId['CineDialogue']!.paragraph!.indent).toEqual({ left: 1591, right: 1591 })
150
+ })
151
+
152
+ it('produces no crash and sensible output for a style with no colors set (industry-courier)', () => {
153
+ const styles = styleToDocxParagraphStyles(INDUSTRY_COURIER_STYLE, CONTENT_WIDTH_TWIPS_LETTER)
154
+ for (const s of styles) expect(s.run!.color).toBeUndefined()
155
+ })
156
+
157
+ it('keeps scene headings/character cues/parentheticals with what follows them', () => {
158
+ const byId = Object.fromEntries(
159
+ styleToDocxParagraphStyles(CLASSIC_STYLE, CONTENT_WIDTH_TWIPS_LETTER).map(s => [s.id, s])
160
+ )
161
+ for (const id of ['CineSceneHeading', 'CineCharacter', 'CineParenthetical', 'CineSection']) {
162
+ expect(byId[id]!.paragraph!.keepNext).toBe(true)
163
+ expect(byId[id]!.paragraph!.keepLines).toBe(true)
164
+ }
165
+ // Action/dialogue can legitimately outrun a page — they must stay breakable,
166
+ // with widow control instead of keepNext/keepLines.
167
+ for (const id of ['CineAction', 'CineDialogue']) {
168
+ expect(byId[id]!.paragraph!.keepNext).toBeUndefined()
169
+ expect(byId[id]!.paragraph!.keepLines).toBeUndefined()
170
+ expect(byId[id]!.paragraph!.widowControl).toBe(true)
171
+ }
172
+ })
173
+
174
+ it('scales indentation with a different content width (A4) instead of reusing Letter twips', () => {
175
+ const letterStyles = styleToDocxParagraphStyles(CLASSIC_STYLE, 9360)
176
+ const a4ContentWidth = 11906 - 1440 - 1440 // A4 width minus 1in margins
177
+ const a4Styles = styleToDocxParagraphStyles(CLASSIC_STYLE, a4ContentWidth)
178
+ const letterAction = letterStyles.find(s => s.id === 'CineAction')!
179
+ const a4Action = a4Styles.find(s => s.id === 'CineAction')!
180
+ expect(a4Action.paragraph!.indent!.left).not.toBe(letterAction.paragraph!.indent!.left)
181
+ })
182
+ })
183
+
184
+ describe('print-adapter', () => {
185
+ it('embeds the resolved page size, margins and body font', () => {
186
+ const css = styleToPrintCss(CLASSIC_STYLE)
187
+ expect(css).toContain('margin: 1in 1in 1in 1in;')
188
+ expect(css).toContain('size: letter;')
189
+ expect(styleToPrintCss(CLASSIC_STYLE, 'a4')).toContain('size: A4;')
190
+ expect(css).toMatch(/font-family: 'Noto Sans', 'Noto Sans Tamil'/)
191
+ })
192
+
193
+ it('puts Noto Sans, the ISO 15919-capable Latin fallback, before the Indic families', () => {
194
+ const css = styleToPrintCss(INDUSTRY_COURIER_STYLE)
195
+ expect(css).toMatch(/font-family: 'Courier Prime', 'Noto Sans', 'Noto Sans Tamil'/)
196
+ })
197
+
198
+ it('lays the preview out at the printable width, so on-screen line breaks match the printed ones', () => {
199
+ // The paginator measures the live window: a preview wider than the paper would
200
+ // wrap later on screen than on paper and overflow every page it filled.
201
+ expect(styleToPrintCss(CLASSIC_STYLE)).toMatch(/#print-flow, #print-pages, \.print-cover-page \{[\s\S]*?width: 6\.5in;/)
202
+ expect(styleToPrintCss(CLASSIC_STYLE, 'a4')).toMatch(/#print-flow, #print-pages, \.print-cover-page \{[\s\S]*?width: 6\.27in;/)
203
+ })
204
+
205
+ it('puts Traditional-style Noto Serif and its per-script overrides first, with the Indic Noto Sans safety net after', () => {
206
+ const css = styleToPrintCss(TRADITIONAL_STYLE)
207
+ expect(css).toMatch(/font-family: 'Noto Serif', 'Noto Serif Devanagari', 'Noto Serif Tamil'[\s\S]*?'Noto Sans Tamil'/)
208
+ })
209
+
210
+ it('embeds per-element font-size/color/margins', () => {
211
+ const css = styleToPrintCss(CLASSIC_STYLE)
212
+ expect(css).toContain('.print-character {')
213
+ expect(css).toContain('color: #9C4221;')
214
+ expect(css).toMatch(/\.print-dialogue \{[\s\S]*?font-size: 12pt;/)
215
+ })
216
+
217
+ it('underlines .print-character when the style says so (regression: this rule used to be hand-built and dropped underline/italic)', () => {
218
+ const css = styleToPrintCss(TRADITIONAL_STYLE)
219
+ expect(css).toMatch(/\.print-character \{[\s\S]*?text-decoration: underline;/)
220
+ })
221
+
222
+ it('does not throw for a style with no colors set', () => {
223
+ expect(() => styleToPrintCss(INDUSTRY_COURIER_STYLE)).not.toThrow()
224
+ })
225
+
226
+ it('emits both cover layouts, so a style can pick either', () => {
227
+ const css = styleToPrintCss(CLASSIC_STYLE)
228
+ expect(css).toMatch(/\.print-cover-centered \{[\s\S]*?text-align: center;/)
229
+ expect(css).toMatch(/\.print-cover-corners \{[\s\S]*?justify-content: space-between;/)
230
+ expect(css).toMatch(/\.print-cover-corners \.print-cover-bottom \{[\s\S]*?align-self: flex-end;/)
231
+ })
232
+
233
+ it('forbids a page break right after a scene heading or character cue', () => {
234
+ const css = styleToPrintCss(CLASSIC_STYLE)
235
+ for (const sel of ['.print-scene-heading', '.print-character', '.print-parenthetical', '.print-section']) {
236
+ const rule = css.match(new RegExp(`\\${sel} \\{[\\s\\S]*?\\}`))![0]
237
+ expect(rule).toContain('break-after: avoid;')
238
+ expect(rule).toContain('page-break-after: avoid;') // legacy spelling for older print engines
239
+ expect(rule).toContain('break-inside: avoid;')
240
+ }
241
+ })
242
+
243
+ it('gives breakable elements orphan/widow control instead of an unbreakable box', () => {
244
+ const css = styleToPrintCss(CLASSIC_STYLE)
245
+ for (const sel of ['.print-action', '.print-dialogue']) {
246
+ const rule = css.match(new RegExp(`\\${sel} \\{[\\s\\S]*?\\}`))![0]
247
+ expect(rule).toContain('orphans: 2;')
248
+ expect(rule).toContain('widows: 2;')
249
+ expect(rule).not.toContain('break-after: avoid;')
250
+ }
251
+ })
252
+ })
253
+
254
+ describe('built-in cover layouts', () => {
255
+ it('gives Traditional the corner cover and leaves the others centered', () => {
256
+ expect(TRADITIONAL_STYLE.coverLayout).toBe('corners')
257
+ expect(CLASSIC_STYLE.coverLayout ?? DEFAULT_COVER_LAYOUT).toBe('centered')
258
+ expect(INDUSTRY_COURIER_STYLE.coverLayout ?? DEFAULT_COVER_LAYOUT).toBe('centered')
259
+ })
260
+
261
+ it('does not underline the title', () => {
262
+ for (const style of [CLASSIC_STYLE, INDUSTRY_COURIER_STYLE, TRADITIONAL_STYLE]) {
263
+ expect(style.elements.title.underline).toBeUndefined()
264
+ }
265
+ })
266
+ })
267
+
268
+ describe('validate', () => {
269
+ it('accepts a valid style definition', () => {
270
+ const result = validateStyleJson(CLASSIC_STYLE)
271
+ expect(result.ok).toBe(true)
272
+ })
273
+
274
+ it('rejects non-objects', () => {
275
+ expect(validateStyleJson(null).ok).toBe(false)
276
+ expect(validateStyleJson('nope').ok).toBe(false)
277
+ })
278
+
279
+ it('collects multiple errors for a malformed style', () => {
280
+ const bad = {
281
+ id: '',
282
+ name: 'Bad',
283
+ // fontFamily missing
284
+ page: { marginTopIn: 1, marginBottomIn: -1, marginLeftIn: 1 }, // missing marginRightIn, negative marginBottomIn
285
+ elements: {
286
+ sceneHeading: { fontSizePt: 'big' },
287
+ // all other elements missing
288
+ },
289
+ }
290
+ const result = validateStyleJson(bad)
291
+ expect(result.ok).toBe(false)
292
+ if (!result.ok) {
293
+ expect(result.errors.some(e => e.startsWith('id:'))).toBe(true)
294
+ expect(result.errors.some(e => e.startsWith('fontFamily:'))).toBe(true)
295
+ expect(result.errors.some(e => e.includes('marginRightIn'))).toBe(true)
296
+ expect(result.errors.some(e => e.includes('marginBottomIn'))).toBe(true)
297
+ expect(result.errors.some(e => e.includes('elements.sceneHeading.fontSizePt'))).toBe(true)
298
+ expect(result.errors.some(e => e.includes('elements.action: missing'))).toBe(true)
299
+ }
300
+ })
301
+
302
+ it('rejects a bad color / align / margin value', () => {
303
+ const style = JSON.parse(JSON.stringify(CLASSIC_STYLE))
304
+ style.elements.character.color = '#9C4221' // leading '#' not allowed
305
+ style.elements.transition.align = 'diagonal'
306
+ style.elements.action.marginLeftPct = 150
307
+ const result = validateStyleJson(style)
308
+ expect(result.ok).toBe(false)
309
+ if (!result.ok) {
310
+ expect(result.errors.some(e => e.includes('elements.character.color'))).toBe(true)
311
+ expect(result.errors.some(e => e.includes('elements.transition.align'))).toBe(true)
312
+ expect(result.errors.some(e => e.includes('elements.action.marginLeftPct'))).toBe(true)
313
+ }
314
+ })
315
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getTemplate, TEMPLATES } from '../../src/templates/templates'
3
+
4
+ describe('templates', () => {
5
+ it('getTemplate returns a string for English', () => {
6
+ const t = getTemplate('en')
7
+ expect(typeof t).toBe('string')
8
+ expect(t.length).toBeGreaterThan(0)
9
+ })
10
+
11
+ it('English template contains required Sutra elements', () => {
12
+ const t = getTemplate('en')
13
+ expect(t).toContain('---') // frontmatter
14
+ expect(t).toContain('lang: en') // language declaration
15
+ expect(t).toContain('## ') // scene heading
16
+ expect(t).toContain('@') // character cue
17
+ })
18
+
19
+ it('returns Hindi template with Devanagari content', () => {
20
+ const t = getTemplate('hi')
21
+ expect(t).toContain('lang: hi')
22
+ // Should contain Devanagari characters
23
+ expect(/[ऀ-ॿ]/.test(t)).toBe(true)
24
+ })
25
+
26
+ it('returns Tamil template with Tamil content', () => {
27
+ const t = getTemplate('ta')
28
+ expect(t).toContain('lang: ta')
29
+ expect(/[஀-௿]/.test(t)).toBe(true)
30
+ })
31
+
32
+ it('returns Telugu template with Telugu content', () => {
33
+ const t = getTemplate('te')
34
+ expect(t).toContain('lang: te')
35
+ expect(/[ఀ-౿]/.test(t)).toBe(true)
36
+ })
37
+
38
+ it('returns Kannada template with Kannada content', () => {
39
+ const t = getTemplate('kn')
40
+ expect(t).toContain('lang: kn')
41
+ expect(/[ಀ-೿]/.test(t)).toBe(true)
42
+ })
43
+
44
+ it('returns Malayalam template with Malayalam content', () => {
45
+ const t = getTemplate('ml')
46
+ expect(t).toContain('lang: ml')
47
+ expect(/[ഀ-ൿ]/.test(t)).toBe(true)
48
+ })
49
+
50
+ it('returns Bengali template with Bengali content', () => {
51
+ const t = getTemplate('bn')
52
+ expect(t).toContain('lang: bn')
53
+ expect(/[ঀ-৿]/.test(t)).toBe(true)
54
+ })
55
+
56
+ it('returns Gujarati template with Gujarati content', () => {
57
+ const t = getTemplate('gu')
58
+ expect(t).toContain('lang: gu')
59
+ expect(/[઀-૿]/.test(t)).toBe(true)
60
+ })
61
+
62
+ it('falls back to English for unknown language code', () => {
63
+ const t = getTemplate('xx')
64
+ expect(t).toContain('lang: en')
65
+ })
66
+
67
+ it('all template languages are covered', () => {
68
+ const langs = ['en', 'hi', 'ta', 'te', 'kn', 'ml', 'bn', 'gu', 'mr', 'pa', 'or', 'si']
69
+ for (const lang of langs) {
70
+ expect(TEMPLATES[lang]).toBeDefined()
71
+ expect(TEMPLATES[lang].length).toBeGreaterThan(50)
72
+ }
73
+ })
74
+
75
+ it('every template contains frontmatter, scene heading, character cue', () => {
76
+ for (const [lang, tmpl] of Object.entries(TEMPLATES)) {
77
+ expect(tmpl, `${lang}: missing frontmatter`).toContain('---')
78
+ expect(tmpl, `${lang}: missing scene heading`).toContain('## ')
79
+ expect(tmpl, `${lang}: missing character cue`).toContain('@')
80
+ expect(tmpl.length, `${lang}: template too short`).toBeGreaterThan(50)
81
+ }
82
+ })
83
+ })