nebula-cms 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 (241) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.mcp.json +12 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.cjs +22 -0
  6. package/AGENTS.md +183 -0
  7. package/LICENSE +201 -0
  8. package/README.md +128 -0
  9. package/package.json +74 -0
  10. package/playground/.claude/settings.local.json +5 -0
  11. package/playground/astro.config.mjs +7 -0
  12. package/playground/node_modules/.bin/astro +21 -0
  13. package/playground/node_modules/.bin/rollup +21 -0
  14. package/playground/node_modules/.bin/tsc +21 -0
  15. package/playground/node_modules/.bin/tsserver +21 -0
  16. package/playground/node_modules/.bin/vite +21 -0
  17. package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
  18. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
  19. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
  20. package/playground/node_modules/.vite/deps/_metadata.json +184 -0
  21. package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
  22. package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
  23. package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
  24. package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
  25. package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
  26. package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
  27. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
  28. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
  29. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
  30. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
  31. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
  32. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
  33. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
  34. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
  35. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
  36. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
  37. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
  38. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
  39. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
  40. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
  41. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
  42. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
  43. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
  44. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
  45. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
  46. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
  47. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
  48. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
  49. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
  50. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
  51. package/playground/node_modules/.vite/deps/package.json +3 -0
  52. package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
  53. package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
  54. package/playground/node_modules/.vite/deps/svelte.js +55 -0
  55. package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
  56. package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
  57. package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
  58. package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
  59. package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
  60. package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
  61. package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
  62. package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
  63. package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
  64. package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
  65. package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
  66. package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
  67. package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
  68. package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
  69. package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
  70. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
  71. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
  72. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
  73. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
  74. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
  75. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
  76. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
  77. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
  78. package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
  79. package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
  80. package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
  81. package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
  82. package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
  83. package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
  84. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
  85. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
  86. package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
  87. package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
  88. package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
  89. package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
  90. package/playground/package.json +16 -0
  91. package/playground/pnpm-lock.yaml +3167 -0
  92. package/playground/src/content/authors/jane-doe.json +8 -0
  93. package/playground/src/content/config/build.toml +2 -0
  94. package/playground/src/content/courses/web-fundamentals.json +29 -0
  95. package/playground/src/content/docs/advanced.mdx +6 -0
  96. package/playground/src/content/docs/intro.md +6 -0
  97. package/playground/src/content/guides/getting-started.mdx +6 -0
  98. package/playground/src/content/posts/hello-world.md +7 -0
  99. package/playground/src/content/products/t-shirt.json +16 -0
  100. package/playground/src/content/recipes/pancakes.mdoc +8 -0
  101. package/playground/src/content/settings/site.yml +2 -0
  102. package/playground/src/content.config.ts +198 -0
  103. package/playground/src/env.d.ts +1 -0
  104. package/playground/src/pages/index.astro +11 -0
  105. package/playground/src/pages/nebula.astro +14 -0
  106. package/pnpm-workspace.yaml +2 -0
  107. package/scripts/subset-icons.mjs +178 -0
  108. package/src/astro/index.ts +295 -0
  109. package/src/client/Admin.svelte +283 -0
  110. package/src/client/components/BackendPicker.svelte +291 -0
  111. package/src/client/components/DraftChip.svelte +46 -0
  112. package/src/client/components/MetadataForm.svelte +56 -0
  113. package/src/client/components/ThemeToggle.svelte +18 -0
  114. package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
  115. package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
  116. package/src/client/components/editor/EditorPane.svelte +227 -0
  117. package/src/client/components/editor/EditorTabs.svelte +81 -0
  118. package/src/client/components/editor/EditorToolbar.svelte +131 -0
  119. package/src/client/components/editor/FormatSelector.svelte +66 -0
  120. package/src/client/components/editor/Toolbar.svelte +17 -0
  121. package/src/client/components/fields/ArrayField.svelte +339 -0
  122. package/src/client/components/fields/ArrayItem.svelte +325 -0
  123. package/src/client/components/fields/BooleanField.svelte +114 -0
  124. package/src/client/components/fields/DateField.svelte +82 -0
  125. package/src/client/components/fields/EnumField.svelte +74 -0
  126. package/src/client/components/fields/FieldWrapper.svelte +96 -0
  127. package/src/client/components/fields/NumberField.svelte +99 -0
  128. package/src/client/components/fields/ObjectField.svelte +121 -0
  129. package/src/client/components/fields/SchemaField.svelte +107 -0
  130. package/src/client/components/fields/StringField.svelte +104 -0
  131. package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
  132. package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
  133. package/src/client/css/a11y.css +14 -0
  134. package/src/client/css/btn.css +113 -0
  135. package/src/client/css/dialog.css +29 -0
  136. package/src/client/css/field-input.css +39 -0
  137. package/src/client/css/reset.css +59 -0
  138. package/src/client/css/theme.css +77 -0
  139. package/src/client/index.ts +1 -0
  140. package/src/client/js/drafts/merge.svelte.ts +121 -0
  141. package/src/client/js/drafts/ops.svelte.ts +227 -0
  142. package/src/client/js/drafts/storage.ts +108 -0
  143. package/src/client/js/drafts/workers/diff.ts +40 -0
  144. package/src/client/js/editor/editor.svelte.ts +343 -0
  145. package/src/client/js/editor/languages.ts +98 -0
  146. package/src/client/js/editor/link-wrap.ts +45 -0
  147. package/src/client/js/editor/markdown-shortcuts.ts +261 -0
  148. package/src/client/js/handlers/admin.ts +246 -0
  149. package/src/client/js/state/dialogs.svelte.ts +35 -0
  150. package/src/client/js/state/router.svelte.ts +156 -0
  151. package/src/client/js/state/schema.svelte.ts +140 -0
  152. package/src/client/js/state/state.svelte.ts +334 -0
  153. package/src/client/js/state/theme.svelte.ts +173 -0
  154. package/src/client/js/storage/adapter.ts +102 -0
  155. package/src/client/js/storage/client.ts +150 -0
  156. package/src/client/js/storage/db.ts +36 -0
  157. package/src/client/js/storage/fsa.ts +110 -0
  158. package/src/client/js/storage/github.ts +297 -0
  159. package/src/client/js/storage/storage.ts +83 -0
  160. package/src/client/js/storage/workers/frontmatter.ts +320 -0
  161. package/src/client/js/storage/workers/storage.ts +177 -0
  162. package/src/client/js/storage/workers/toml-parser.ts +106 -0
  163. package/src/client/js/storage/workers/yaml-parser.ts +132 -0
  164. package/src/client/js/utils/file-types.ts +192 -0
  165. package/src/client/js/utils/format.ts +16 -0
  166. package/src/client/js/utils/frontmatter.ts +38 -0
  167. package/src/client/js/utils/schema-utils.ts +295 -0
  168. package/src/client/js/utils/slug.ts +18 -0
  169. package/src/client/js/utils/sort.ts +84 -0
  170. package/src/client/js/utils/stable-stringify.ts +27 -0
  171. package/src/client/js/utils/url-utils.ts +38 -0
  172. package/src/types.ts +25 -0
  173. package/src/virtual.d.ts +22 -0
  174. package/svelte.config.js +4 -0
  175. package/tests/astro/build.test.ts +63 -0
  176. package/tests/astro/index.test.ts +689 -0
  177. package/tests/client/components/Admin.test.ts +446 -0
  178. package/tests/client/components/BackendPicker.test.ts +239 -0
  179. package/tests/client/components/DraftChip.test.ts +53 -0
  180. package/tests/client/components/MetadataForm.test.ts +164 -0
  181. package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
  182. package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
  183. package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
  184. package/tests/client/components/editor/EditorPane.test.ts +100 -0
  185. package/tests/client/components/editor/EditorTabs.test.ts +253 -0
  186. package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
  187. package/tests/client/components/editor/fixtures.ts +31 -0
  188. package/tests/client/components/fields/ArrayField.test.ts +197 -0
  189. package/tests/client/components/fields/BooleanField.test.ts +206 -0
  190. package/tests/client/components/fields/DateField.test.ts +210 -0
  191. package/tests/client/components/fields/EnumField.test.ts +246 -0
  192. package/tests/client/components/fields/NumberField.test.ts +240 -0
  193. package/tests/client/components/fields/ObjectField.test.ts +157 -0
  194. package/tests/client/components/fields/SchemaField.test.ts +190 -0
  195. package/tests/client/components/fields/StringField.test.ts +223 -0
  196. package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
  197. package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
  198. package/tests/client/components/sidebar/sort-mock.ts +23 -0
  199. package/tests/client/js/drafts/fixtures.ts +22 -0
  200. package/tests/client/js/drafts/merge.test.ts +282 -0
  201. package/tests/client/js/drafts/ops.test.ts +658 -0
  202. package/tests/client/js/drafts/storage.test.ts +200 -0
  203. package/tests/client/js/drafts/workers/diff.test.ts +165 -0
  204. package/tests/client/js/editor/editor.test.ts +616 -0
  205. package/tests/client/js/editor/link-wrap.test.ts +225 -0
  206. package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
  207. package/tests/client/js/handlers/admin.test.ts +467 -0
  208. package/tests/client/js/state/router.test.ts +619 -0
  209. package/tests/client/js/state/schema.test.ts +266 -0
  210. package/tests/client/js/state/state.test.ts +328 -0
  211. package/tests/client/js/storage/adapter.test.ts +115 -0
  212. package/tests/client/js/storage/client.test.ts +250 -0
  213. package/tests/client/js/storage/db.test.ts +59 -0
  214. package/tests/client/js/storage/fsa.test.ts +284 -0
  215. package/tests/client/js/storage/github.test.ts +349 -0
  216. package/tests/client/js/storage/mock-port.ts +95 -0
  217. package/tests/client/js/storage/storage.test.ts +77 -0
  218. package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
  219. package/tests/client/js/storage/workers/storage.test.ts +299 -0
  220. package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
  221. package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
  222. package/tests/client/js/utils/file-types.test.ts +268 -0
  223. package/tests/client/js/utils/frontmatter.test.ts +87 -0
  224. package/tests/client/js/utils/schema-utils.test.ts +318 -0
  225. package/tests/client/js/utils/slug.test.ts +58 -0
  226. package/tests/client/js/utils/sort.test.ts +276 -0
  227. package/tests/client/js/utils/stable-stringify.test.ts +68 -0
  228. package/tests/client/js/utils/url-utils.test.ts +70 -0
  229. package/tests/e2e/backend-connection.test.ts +301 -0
  230. package/tests/e2e/draft-lifecycle.test.ts +388 -0
  231. package/tests/e2e/editing.test.ts +355 -0
  232. package/tests/e2e/github-adapter.test.ts +330 -0
  233. package/tests/e2e/helpers/mock-adapter.ts +166 -0
  234. package/tests/e2e/helpers/test-app.ts +155 -0
  235. package/tests/e2e/navigation.test.ts +358 -0
  236. package/tests/e2e/publishing.test.ts +345 -0
  237. package/tests/e2e/unsaved-changes.test.ts +317 -0
  238. package/tests/setup.ts +2 -0
  239. package/tests/stubs/codemirror.ts +197 -0
  240. package/tsconfig.json +19 -0
  241. package/vitest.config.ts +178 -0
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ getExtensionsForSchema,
4
+ hasBodyEditor,
5
+ getFileCategory,
6
+ getDataFormat,
7
+ stripExtension,
8
+ getDefaultExtension,
9
+ } from '../../../../src/client/js/utils/file-types';
10
+
11
+ //////////////////////////////
12
+ // getExtensionsForSchema
13
+ //////////////////////////////
14
+
15
+ describe('getExtensionsForSchema', () => {
16
+ it('returns all extensions for a single type identifier', () => {
17
+ expect(getExtensionsForSchema({ files: ['md'] })).toEqual([
18
+ '.md',
19
+ '.markdown',
20
+ ]);
21
+ });
22
+
23
+ it('returns extensions for multiple type identifiers combined', () => {
24
+ expect(getExtensionsForSchema({ files: ['md', 'json'] })).toEqual([
25
+ '.md',
26
+ '.markdown',
27
+ '.json',
28
+ ]);
29
+ });
30
+
31
+ it('returns extensions for all frontmatter types', () => {
32
+ expect(getExtensionsForSchema({ files: ['md', 'mdx', 'markdoc'] })).toEqual(
33
+ ['.md', '.markdown', '.mdx', '.mdoc', '.markdoc'],
34
+ );
35
+ });
36
+
37
+ it('returns extensions for all data types', () => {
38
+ expect(getExtensionsForSchema({ files: ['json', 'yaml', 'toml'] })).toEqual(
39
+ ['.json', '.yml', '.yaml', '.toml'],
40
+ );
41
+ });
42
+
43
+ it('returns an empty array when files is empty', () => {
44
+ expect(getExtensionsForSchema({ files: [] })).toEqual([]);
45
+ });
46
+
47
+ it('skips unknown type identifiers without throwing', () => {
48
+ expect(getExtensionsForSchema({ files: ['md', 'unknown-type'] })).toEqual([
49
+ '.md',
50
+ '.markdown',
51
+ ]);
52
+ });
53
+
54
+ it('returns an empty array when files property is missing', () => {
55
+ expect(getExtensionsForSchema({})).toEqual([]);
56
+ });
57
+ });
58
+
59
+ //////////////////////////////
60
+ // hasBodyEditor
61
+ //////////////////////////////
62
+
63
+ describe('hasBodyEditor', () => {
64
+ it('returns true for .md files', () => {
65
+ expect(hasBodyEditor('post.md')).toBe(true);
66
+ });
67
+
68
+ it('returns true for .markdown files', () => {
69
+ expect(hasBodyEditor('post.markdown')).toBe(true);
70
+ });
71
+
72
+ it('returns true for .mdx files', () => {
73
+ expect(hasBodyEditor('component.mdx')).toBe(true);
74
+ });
75
+
76
+ it('returns true for .mdoc files', () => {
77
+ expect(hasBodyEditor('doc.mdoc')).toBe(true);
78
+ });
79
+
80
+ it('returns true for .markdoc files', () => {
81
+ expect(hasBodyEditor('doc.markdoc')).toBe(true);
82
+ });
83
+
84
+ it('returns false for .json files', () => {
85
+ expect(hasBodyEditor('data.json')).toBe(false);
86
+ });
87
+
88
+ it('returns false for .yml files', () => {
89
+ expect(hasBodyEditor('config.yml')).toBe(false);
90
+ });
91
+
92
+ it('returns false for .yaml files', () => {
93
+ expect(hasBodyEditor('config.yaml')).toBe(false);
94
+ });
95
+
96
+ it('returns false for .toml files', () => {
97
+ expect(hasBodyEditor('config.toml')).toBe(false);
98
+ });
99
+
100
+ it('returns false for unrecognised extensions', () => {
101
+ expect(hasBodyEditor('file.txt')).toBe(false);
102
+ });
103
+ });
104
+
105
+ //////////////////////////////
106
+ // getFileCategory
107
+ //////////////////////////////
108
+
109
+ describe('getFileCategory', () => {
110
+ it('returns frontmatter for .md files', () => {
111
+ expect(getFileCategory('post.md')).toBe('frontmatter');
112
+ });
113
+
114
+ it('returns frontmatter for .markdown files', () => {
115
+ expect(getFileCategory('post.markdown')).toBe('frontmatter');
116
+ });
117
+
118
+ it('returns frontmatter for .mdx files', () => {
119
+ expect(getFileCategory('component.mdx')).toBe('frontmatter');
120
+ });
121
+
122
+ it('returns frontmatter for .mdoc files', () => {
123
+ expect(getFileCategory('doc.mdoc')).toBe('frontmatter');
124
+ });
125
+
126
+ it('returns frontmatter for .markdoc files', () => {
127
+ expect(getFileCategory('doc.markdoc')).toBe('frontmatter');
128
+ });
129
+
130
+ it('returns data for .json files', () => {
131
+ expect(getFileCategory('data.json')).toBe('data');
132
+ });
133
+
134
+ it('returns data for .yml files', () => {
135
+ expect(getFileCategory('config.yml')).toBe('data');
136
+ });
137
+
138
+ it('returns data for .yaml files', () => {
139
+ expect(getFileCategory('config.yaml')).toBe('data');
140
+ });
141
+
142
+ it('returns data for .toml files', () => {
143
+ expect(getFileCategory('config.toml')).toBe('data');
144
+ });
145
+
146
+ it('returns null for unrecognised extensions', () => {
147
+ expect(getFileCategory('file.txt')).toBe(null);
148
+ });
149
+ });
150
+
151
+ //////////////////////////////
152
+ // getDataFormat
153
+ //////////////////////////////
154
+
155
+ describe('getDataFormat', () => {
156
+ it('returns json for .json files', () => {
157
+ expect(getDataFormat('data.json')).toBe('json');
158
+ });
159
+
160
+ it('returns yaml for .yml files', () => {
161
+ expect(getDataFormat('config.yml')).toBe('yaml');
162
+ });
163
+
164
+ it('returns yaml for .yaml files', () => {
165
+ expect(getDataFormat('config.yaml')).toBe('yaml');
166
+ });
167
+
168
+ it('returns toml for .toml files', () => {
169
+ expect(getDataFormat('config.toml')).toBe('toml');
170
+ });
171
+
172
+ it('returns null for frontmatter files', () => {
173
+ expect(getDataFormat('post.md')).toBe(null);
174
+ });
175
+
176
+ it('returns null for .mdx files', () => {
177
+ expect(getDataFormat('component.mdx')).toBe(null);
178
+ });
179
+
180
+ it('returns null for unrecognised extensions', () => {
181
+ expect(getDataFormat('file.txt')).toBe(null);
182
+ });
183
+ });
184
+
185
+ //////////////////////////////
186
+ // stripExtension
187
+ //////////////////////////////
188
+
189
+ describe('stripExtension', () => {
190
+ it('strips .md extension', () => {
191
+ expect(stripExtension('my-post.md')).toBe('my-post');
192
+ });
193
+
194
+ it('strips .markdown extension', () => {
195
+ expect(stripExtension('my-post.markdown')).toBe('my-post');
196
+ });
197
+
198
+ it('strips .mdx extension', () => {
199
+ expect(stripExtension('component.mdx')).toBe('component');
200
+ });
201
+
202
+ it('strips .mdoc extension', () => {
203
+ expect(stripExtension('doc.mdoc')).toBe('doc');
204
+ });
205
+
206
+ it('strips .markdoc extension', () => {
207
+ expect(stripExtension('doc.markdoc')).toBe('doc');
208
+ });
209
+
210
+ it('strips .json extension', () => {
211
+ expect(stripExtension('data.json')).toBe('data');
212
+ });
213
+
214
+ it('strips .yml extension', () => {
215
+ expect(stripExtension('config.yml')).toBe('config');
216
+ });
217
+
218
+ it('strips .yaml extension', () => {
219
+ expect(stripExtension('config.yaml')).toBe('config');
220
+ });
221
+
222
+ it('strips .toml extension', () => {
223
+ expect(stripExtension('config.toml')).toBe('config');
224
+ });
225
+
226
+ it('returns filename unchanged for unrecognised extensions', () => {
227
+ expect(stripExtension('file.txt')).toBe('file.txt');
228
+ });
229
+
230
+ it('only strips the last known extension in a double-extension filename', () => {
231
+ // Filenames with multiple dots: strip only the recognised trailing extension
232
+ expect(stripExtension('my.post.md')).toBe('my.post');
233
+ });
234
+ });
235
+
236
+ //////////////////////////////
237
+ // getDefaultExtension
238
+ //////////////////////////////
239
+
240
+ describe('getDefaultExtension', () => {
241
+ it('returns .md as the default extension for md', () => {
242
+ expect(getDefaultExtension('md')).toBe('.md');
243
+ });
244
+
245
+ it('returns .mdx as the default extension for mdx', () => {
246
+ expect(getDefaultExtension('mdx')).toBe('.mdx');
247
+ });
248
+
249
+ it('returns .mdoc as the default extension for markdoc', () => {
250
+ expect(getDefaultExtension('markdoc')).toBe('.mdoc');
251
+ });
252
+
253
+ it('returns .json as the default extension for json', () => {
254
+ expect(getDefaultExtension('json')).toBe('.json');
255
+ });
256
+
257
+ it('returns .yml as the default extension for yaml', () => {
258
+ expect(getDefaultExtension('yaml')).toBe('.yml');
259
+ });
260
+
261
+ it('returns .toml as the default extension for toml', () => {
262
+ expect(getDefaultExtension('toml')).toBe('.toml');
263
+ });
264
+
265
+ it('returns null for unknown type identifiers', () => {
266
+ expect(getDefaultExtension('unknown')).toBe(null);
267
+ });
268
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { splitFrontmatter } from '../../../../src/client/js/utils/frontmatter';
3
+
4
+ describe('splitFrontmatter', () => {
5
+ it('splits valid YAML frontmatter and body', () => {
6
+ const content = '---\ntitle: Hello\n---\nBody text here.';
7
+ const result = splitFrontmatter(content);
8
+ // slice(4, closeIndex) stops before the \n that precedes ---
9
+ expect(result.rawFrontmatter).toBe('title: Hello');
10
+ expect(result.body).toBe('Body text here.');
11
+ });
12
+
13
+ it('returns an empty body when there is only frontmatter', () => {
14
+ const content = '---\ntitle: No Body\n---\n';
15
+ const result = splitFrontmatter(content);
16
+ expect(result.rawFrontmatter).toBe('title: No Body');
17
+ expect(result.body).toBe('');
18
+ });
19
+
20
+ it('handles frontmatter with no trailing newline (ends with ---)', () => {
21
+ // endsWith('\n---') branch: slice(4, length - 4) trims the leading newline of the delimiter
22
+ const content = '---\ntitle: EOF\n---';
23
+ const result = splitFrontmatter(content);
24
+ expect(result.rawFrontmatter).toBe('title: EOF');
25
+ expect(result.body).toBe('');
26
+ });
27
+
28
+ it('returns empty frontmatter and full content when no frontmatter delimiter is present', () => {
29
+ const content = 'Just plain body text.';
30
+ const result = splitFrontmatter(content);
31
+ expect(result.rawFrontmatter).toBe('');
32
+ expect(result.body).toBe('Just plain body text.');
33
+ });
34
+
35
+ it('returns empty frontmatter and full content for an empty string', () => {
36
+ const result = splitFrontmatter('');
37
+ expect(result.rawFrontmatter).toBe('');
38
+ expect(result.body).toBe('');
39
+ });
40
+
41
+ it('rejects a horizontal rule (----) at the start — not treated as frontmatter', () => {
42
+ const content = '----\ntitle: Nope\n---\nBody.';
43
+ const result = splitFrontmatter(content);
44
+ expect(result.rawFrontmatter).toBe('');
45
+ expect(result.body).toBe(content);
46
+ });
47
+
48
+ it('strips a leading BOM character before processing', () => {
49
+ const content = '\uFEFF---\ntitle: BOM\n---\nBody.';
50
+ const result = splitFrontmatter(content);
51
+ expect(result.rawFrontmatter).toBe('title: BOM');
52
+ expect(result.body).toBe('Body.');
53
+ });
54
+
55
+ it('normalises CRLF line endings before processing', () => {
56
+ const content = '---\r\ntitle: CRLF\r\n---\r\nBody.';
57
+ const result = splitFrontmatter(content);
58
+ expect(result.rawFrontmatter).toBe('title: CRLF');
59
+ expect(result.body).toBe('Body.');
60
+ });
61
+
62
+ it('returns empty frontmatter when the closing delimiter is missing', () => {
63
+ // No closing --- so the entire content is treated as body
64
+ const content = '---\ntitle: Unclosed';
65
+ const result = splitFrontmatter(content);
66
+ expect(result.rawFrontmatter).toBe('');
67
+ expect(result.body).toBe(content);
68
+ });
69
+
70
+ it('treats content starting with --- but missing a newline as a non-match', () => {
71
+ // "---" without an immediate newline is not a valid opening delimiter
72
+ const content = '--- title: Inline';
73
+ const result = splitFrontmatter(content);
74
+ expect(result.rawFrontmatter).toBe('');
75
+ expect(result.body).toBe(content);
76
+ });
77
+
78
+ it('handles multiline frontmatter with body correctly', () => {
79
+ const content =
80
+ '---\ntitle: Multi\nauthor: Jane\ntags:\n - a\n - b\n---\n# Heading\n\nParagraph.';
81
+ const result = splitFrontmatter(content);
82
+ expect(result.rawFrontmatter).toBe(
83
+ 'title: Multi\nauthor: Jane\ntags:\n - a\n - b',
84
+ );
85
+ expect(result.body).toBe('# Heading\n\nParagraph.');
86
+ });
87
+ });
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ resolveFieldType,
4
+ createDefaultValue,
5
+ extractTabs,
6
+ getFieldsForTab,
7
+ getByPath,
8
+ setByPath,
9
+ } from '../../../../src/client/js/utils/schema-utils';
10
+
11
+ //////////////////////////////
12
+ // resolveFieldType
13
+ //////////////////////////////
14
+
15
+ describe('resolveFieldType', () => {
16
+ it('resolves type: string to kind: string', () => {
17
+ expect(resolveFieldType({ type: 'string' })).toEqual({ kind: 'string' });
18
+ });
19
+
20
+ it('resolves type: number to kind: number', () => {
21
+ expect(resolveFieldType({ type: 'number' })).toEqual({ kind: 'number' });
22
+ });
23
+
24
+ it('resolves type: integer to kind: number', () => {
25
+ expect(resolveFieldType({ type: 'integer' })).toEqual({ kind: 'number' });
26
+ });
27
+
28
+ it('resolves type: boolean to kind: boolean', () => {
29
+ expect(resolveFieldType({ type: 'boolean' })).toEqual({ kind: 'boolean' });
30
+ });
31
+
32
+ it('resolves type: array to kind: array', () => {
33
+ expect(resolveFieldType({ type: 'array' })).toEqual({ kind: 'array' });
34
+ });
35
+
36
+ it('resolves type: object to kind: object', () => {
37
+ expect(resolveFieldType({ type: 'object' })).toEqual({ kind: 'object' });
38
+ });
39
+
40
+ it('resolves string with format: date-time to kind: date', () => {
41
+ expect(resolveFieldType({ type: 'string', format: 'date-time' })).toEqual({
42
+ kind: 'date',
43
+ });
44
+ });
45
+
46
+ it('resolves string with enum values to kind: enum with options', () => {
47
+ const result = resolveFieldType({ type: 'string', enum: ['a', 'b', 'c'] });
48
+ expect(result).toEqual({ kind: 'enum', options: ['a', 'b', 'c'] });
49
+ });
50
+
51
+ it('resolves unknown type to kind: unknown', () => {
52
+ expect(resolveFieldType({ type: 'bogus' })).toEqual({ kind: 'unknown' });
53
+ });
54
+
55
+ it('resolves schema with no type to kind: unknown', () => {
56
+ expect(resolveFieldType({})).toEqual({ kind: 'unknown' });
57
+ });
58
+
59
+ it('unwraps nullable anyOf and marks nullable: true', () => {
60
+ const schema = {
61
+ anyOf: [{ type: 'string' }, { type: 'null' }],
62
+ };
63
+ expect(resolveFieldType(schema)).toEqual({
64
+ kind: 'string',
65
+ nullable: true,
66
+ });
67
+ });
68
+
69
+ it('unwraps nullable anyOf for date-time and marks nullable: true', () => {
70
+ const schema = {
71
+ anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }],
72
+ };
73
+ expect(resolveFieldType(schema)).toEqual({ kind: 'date', nullable: true });
74
+ });
75
+
76
+ it('date-time format takes precedence over enum when both present', () => {
77
+ // format is checked before enum in the implementation
78
+ const result = resolveFieldType({
79
+ type: 'string',
80
+ format: 'date-time',
81
+ enum: ['x'],
82
+ });
83
+ expect(result.kind).toBe('date');
84
+ });
85
+ });
86
+
87
+ //////////////////////////////
88
+ // createDefaultValue
89
+ //////////////////////////////
90
+
91
+ describe('createDefaultValue', () => {
92
+ it('returns an explicit schema default when present', () => {
93
+ expect(createDefaultValue({ type: 'string', default: 'hello' })).toBe(
94
+ 'hello',
95
+ );
96
+ });
97
+
98
+ it('returns an explicit default of false (falsy default honoured)', () => {
99
+ expect(createDefaultValue({ type: 'boolean', default: false })).toBe(false);
100
+ });
101
+
102
+ it('returns empty string for type: string', () => {
103
+ expect(createDefaultValue({ type: 'string' })).toBe('');
104
+ });
105
+
106
+ it('returns 0 for type: number', () => {
107
+ expect(createDefaultValue({ type: 'number' })).toBe(0);
108
+ });
109
+
110
+ it('returns 0 for type: integer', () => {
111
+ expect(createDefaultValue({ type: 'integer' })).toBe(0);
112
+ });
113
+
114
+ it('returns false for type: boolean', () => {
115
+ expect(createDefaultValue({ type: 'boolean' })).toBe(false);
116
+ });
117
+
118
+ it('returns [] for type: array', () => {
119
+ expect(createDefaultValue({ type: 'array' })).toEqual([]);
120
+ });
121
+
122
+ it('returns {} for type: object with no properties', () => {
123
+ expect(createDefaultValue({ type: 'object' })).toEqual({});
124
+ });
125
+
126
+ it('recursively builds defaults for object properties', () => {
127
+ const schema = {
128
+ type: 'object',
129
+ properties: {
130
+ name: { type: 'string' },
131
+ count: { type: 'number' },
132
+ active: { type: 'boolean' },
133
+ },
134
+ };
135
+ expect(createDefaultValue(schema)).toEqual({
136
+ name: '',
137
+ count: 0,
138
+ active: false,
139
+ });
140
+ });
141
+
142
+ it('returns null for a nullable anyOf schema', () => {
143
+ const schema = { anyOf: [{ type: 'string' }, { type: 'null' }] };
144
+ expect(createDefaultValue(schema)).toBeNull();
145
+ });
146
+
147
+ it('returns null for an unrecognised type', () => {
148
+ expect(createDefaultValue({ type: 'bogus' })).toBeNull();
149
+ });
150
+
151
+ it('returns null for an empty schema', () => {
152
+ expect(createDefaultValue({})).toBeNull();
153
+ });
154
+ });
155
+
156
+ //////////////////////////////
157
+ // extractTabs
158
+ //////////////////////////////
159
+
160
+ describe('extractTabs', () => {
161
+ it('returns an empty array when the schema has no properties', () => {
162
+ expect(extractTabs({})).toEqual([]);
163
+ });
164
+
165
+ it('returns an empty array when no fields have a tab array', () => {
166
+ const schema = { properties: { title: { type: 'string' } } };
167
+ expect(extractTabs(schema)).toEqual([]);
168
+ });
169
+
170
+ it('returns sorted, deduplicated tab names', () => {
171
+ const schema = {
172
+ properties: {
173
+ title: { type: 'string', tab: ['Content'] },
174
+ slug: { type: 'string', tab: ['SEO', 'Content'] },
175
+ date: { type: 'string', tab: ['Metadata'] },
176
+ },
177
+ };
178
+ expect(extractTabs(schema)).toEqual(['Content', 'Metadata', 'SEO']);
179
+ });
180
+
181
+ it('deduplicates tab names appearing across multiple fields', () => {
182
+ const schema = {
183
+ properties: {
184
+ a: { tab: ['Shared'] },
185
+ b: { tab: ['Shared'] },
186
+ },
187
+ };
188
+ expect(extractTabs(schema)).toEqual(['Shared']);
189
+ });
190
+
191
+ it('ignores fields whose tab value is not an array', () => {
192
+ const schema = {
193
+ properties: {
194
+ a: { tab: 'not-an-array' },
195
+ b: { tab: ['Real'] },
196
+ },
197
+ };
198
+ expect(extractTabs(schema)).toEqual(['Real']);
199
+ });
200
+ });
201
+
202
+ //////////////////////////////
203
+ // getFieldsForTab
204
+ //////////////////////////////
205
+
206
+ describe('getFieldsForTab', () => {
207
+ const schema = {
208
+ properties: {
209
+ title: { type: 'string', tab: ['Content'] },
210
+ slug: { type: 'string', tab: ['SEO'] },
211
+ date: { type: 'string', tab: ['Metadata', 'Content'] },
212
+ $schema: { type: 'string' },
213
+ },
214
+ };
215
+
216
+ it('returns all field names (excluding $schema) when tab is null', () => {
217
+ const result = getFieldsForTab(schema, null);
218
+ expect(result).toContain('title');
219
+ expect(result).toContain('slug');
220
+ expect(result).toContain('date');
221
+ expect(result).not.toContain('$schema');
222
+ });
223
+
224
+ it('returns only fields belonging to the given tab', () => {
225
+ expect(getFieldsForTab(schema, 'Content')).toEqual(['title', 'date']);
226
+ });
227
+
228
+ it('returns the correct subset for SEO tab', () => {
229
+ expect(getFieldsForTab(schema, 'SEO')).toEqual(['slug']);
230
+ });
231
+
232
+ it('returns an empty array for a tab with no matching fields', () => {
233
+ expect(getFieldsForTab(schema, 'NonExistent')).toEqual([]);
234
+ });
235
+
236
+ it('returns an empty array when the schema has no properties', () => {
237
+ expect(getFieldsForTab({}, 'Content')).toEqual([]);
238
+ });
239
+ });
240
+
241
+ //////////////////////////////
242
+ // getByPath
243
+ //////////////////////////////
244
+
245
+ describe('getByPath', () => {
246
+ it('returns the root object for an empty path', () => {
247
+ const obj = { a: 1 };
248
+ expect(getByPath(obj, [])).toBe(obj);
249
+ });
250
+
251
+ it('reads a top-level property by key', () => {
252
+ expect(getByPath({ name: 'Alice' }, ['name'])).toBe('Alice');
253
+ });
254
+
255
+ it('traverses nested objects', () => {
256
+ const obj = { a: { b: { c: 42 } } };
257
+ expect(getByPath(obj, ['a', 'b', 'c'])).toBe(42);
258
+ });
259
+
260
+ it('reads array elements by numeric index', () => {
261
+ const obj = { items: ['x', 'y', 'z'] };
262
+ expect(getByPath(obj, ['items', 1])).toBe('y');
263
+ });
264
+
265
+ it('returns undefined when a segment is missing', () => {
266
+ expect(getByPath({ a: 1 }, ['b', 'c'])).toBeUndefined();
267
+ });
268
+
269
+ it('returns undefined when traversing through null', () => {
270
+ expect(getByPath({ a: null }, ['a', 'b'])).toBeUndefined();
271
+ });
272
+
273
+ it('returns undefined when traversing through undefined', () => {
274
+ expect(getByPath({ a: undefined }, ['a', 'b'])).toBeUndefined();
275
+ });
276
+ });
277
+
278
+ //////////////////////////////
279
+ // setByPath
280
+ //////////////////////////////
281
+
282
+ describe('setByPath', () => {
283
+ it('sets a top-level property', () => {
284
+ const obj: Record<string, unknown> = {};
285
+ setByPath(obj, ['name'], 'Alice');
286
+ expect(obj['name']).toBe('Alice');
287
+ });
288
+
289
+ it('sets a deeply nested property, creating intermediates', () => {
290
+ const obj: Record<string, unknown> = {};
291
+ setByPath(obj, ['a', 'b', 'c'], 99);
292
+ expect((obj['a'] as Record<string, unknown>)['b']).toEqual({ c: 99 });
293
+ });
294
+
295
+ it('overwrites an existing value', () => {
296
+ const obj = { score: 1 };
297
+ setByPath(obj, ['score'], 100);
298
+ expect(obj['score']).toBe(100);
299
+ });
300
+
301
+ it('sets a value at a numeric array index', () => {
302
+ const obj: Record<string, unknown> = { items: ['a', 'b', 'c'] };
303
+ setByPath(obj, ['items', 1], 'X');
304
+ expect((obj['items'] as string[])[1]).toBe('X');
305
+ });
306
+
307
+ it('does nothing for an empty path', () => {
308
+ const obj = { a: 1 };
309
+ setByPath(obj, [], 'should-not-apply');
310
+ expect(obj).toEqual({ a: 1 });
311
+ });
312
+
313
+ it('creates missing intermediate objects', () => {
314
+ const obj: Record<string, unknown> = {};
315
+ setByPath(obj, ['x', 'y'], 'deep');
316
+ expect(obj).toEqual({ x: { y: 'deep' } });
317
+ });
318
+ });