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,225 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { syntaxTree } from '@codemirror/language';
3
+
4
+ //////////////////////////////
5
+ // CodeMirror API mocks
6
+ //
7
+ // link-wrap.ts uses ViewPlugin, Decoration, RangeSetBuilder from
8
+ // @codemirror/view and @codemirror/state, and syntaxTree from
9
+ // @codemirror/language. The mocks return the minimal shapes needed
10
+ // for the module to load and for the ViewPlugin.define() call to
11
+ // complete without throwing. Testing the full decoration pass
12
+ // requires a real CodeMirror document tree, which is out of scope
13
+ // for a unit test — the structural tests below verify that the
14
+ // plugin is correctly defined and wired.
15
+ //////////////////////////////
16
+
17
+ vi.mock('@codemirror/language', () => ({
18
+ syntaxTree: vi.fn(() => ({
19
+ /**
20
+ * Minimal syntax tree stub — calls enter once with a non-Link node
21
+ * so buildDecorations completes without adding any decorations.
22
+ * @param {{ enter: (node: { name: string, from: number, to: number }) => void }} opts - Iteration options
23
+ * @return {void}
24
+ */
25
+ iterate(opts: {
26
+ enter: (node: { name: string; from: number; to: number }) => void;
27
+ }) {
28
+ // Deliberately visit a non-Link node to exercise the filtering branch
29
+ opts.enter({ name: 'Document', from: 0, to: 10 });
30
+ },
31
+ })),
32
+ }));
33
+
34
+ vi.mock('@codemirror/state', () => {
35
+ /**
36
+ * Minimal RangeSetBuilder stub that satisfies the builder.finish() call.
37
+ * No decorations are added in unit tests (the syntax tree is empty),
38
+ * so finish() just needs to return a stable value.
39
+ */
40
+ class RangeSetBuilder {
41
+ /**
42
+ * Adds a range — no-op in the stub.
43
+ * @param {number} _from - Range start
44
+ * @param {number} _to - Range end
45
+ * @param {unknown} _value - Decoration value
46
+ * @return {void}
47
+ */
48
+ add(_from: number, _to: number, _value: unknown): void {}
49
+
50
+ /**
51
+ * Finishes the builder and returns the collected decoration set.
52
+ * @return {unknown[]} Empty decoration set stub
53
+ */
54
+ finish(): unknown[] {
55
+ return [];
56
+ }
57
+ }
58
+ return { RangeSetBuilder };
59
+ });
60
+
61
+ vi.mock('@codemirror/view', () => {
62
+ /** Decoration stub that provides a mark() factory. */
63
+ const Decoration = {
64
+ /**
65
+ * Creates a mark decoration with the given options.
66
+ * @param {{ class: string }} opts - Decoration options
67
+ * @return {{ class: string }} A minimal mark decoration stub
68
+ */
69
+ mark(opts: { class: string }) {
70
+ return opts;
71
+ },
72
+ };
73
+
74
+ /**
75
+ * ViewPlugin stub that records the define() call parameters
76
+ * and returns a stable fake extension object.
77
+ */
78
+ const ViewPlugin = {
79
+ _lastFactory: null as unknown,
80
+ _lastConfig: null as unknown,
81
+
82
+ /**
83
+ * Captures the factory and config and returns a fake plugin extension.
84
+ * @param {(view: unknown) => unknown} factory - The plugin instance factory
85
+ * @param {{ decorations: (v: unknown) => unknown }} config - Plugin configuration
86
+ * @return {{ isViewPlugin: true, factory: Function, config: object }} Fake extension
87
+ */
88
+ define(
89
+ factory: (view: unknown) => unknown,
90
+ config: { decorations: (v: unknown) => unknown },
91
+ ) {
92
+ ViewPlugin._lastFactory = factory;
93
+ ViewPlugin._lastConfig = config;
94
+ return { isViewPlugin: true, factory, config };
95
+ },
96
+ };
97
+
98
+ return { ViewPlugin, Decoration };
99
+ });
100
+
101
+ import { linkWrapPlugin } from '../../../../src/client/js/editor/link-wrap';
102
+
103
+ //////////////////////////////
104
+ // linkWrapPlugin structural tests
105
+ //////////////////////////////
106
+
107
+ describe('linkWrapPlugin', () => {
108
+ it('is exported and truthy', () => {
109
+ expect(linkWrapPlugin).toBeTruthy();
110
+ });
111
+
112
+ it('is the result of ViewPlugin.define()', () => {
113
+ // The mock returns an object with isViewPlugin: true for anything
114
+ // produced by ViewPlugin.define()
115
+ expect((linkWrapPlugin as any).isViewPlugin).toBe(true);
116
+ });
117
+
118
+ it('exposes a decorations accessor via the config', () => {
119
+ // ViewPlugin.define() must receive a config with a decorations getter
120
+ const config = (linkWrapPlugin as any).config as {
121
+ decorations: (v: unknown) => unknown;
122
+ };
123
+ expect(typeof config.decorations).toBe('function');
124
+ });
125
+
126
+ it('decorations accessor reads the decorations property from the plugin instance', () => {
127
+ const config = (linkWrapPlugin as any).config as {
128
+ decorations: (v: unknown) => unknown;
129
+ };
130
+ const fakeInstance = { decorations: ['decoration-sentinel'] };
131
+ expect(config.decorations(fakeInstance)).toBe(fakeInstance.decorations);
132
+ });
133
+
134
+ it('plugin factory produces an object with a decorations property', () => {
135
+ const factory = (linkWrapPlugin as any).factory as (view: unknown) => {
136
+ decorations: unknown;
137
+ update: (update: {
138
+ docChanged: boolean;
139
+ viewportChanged: boolean;
140
+ state: unknown;
141
+ }) => void;
142
+ };
143
+ // Provide a minimal EditorView stub with a state that has a doc
144
+ const fakeView = {
145
+ state: {
146
+ doc: { length: 0 },
147
+ },
148
+ };
149
+ const instance = factory(fakeView);
150
+ expect(instance).toHaveProperty('decorations');
151
+ });
152
+
153
+ it('plugin update method re-builds decorations when doc changes', () => {
154
+ const factory = (linkWrapPlugin as any).factory as (view: unknown) => {
155
+ decorations: unknown;
156
+ update: (update: {
157
+ docChanged: boolean;
158
+ viewportChanged: boolean;
159
+ state: unknown;
160
+ }) => void;
161
+ };
162
+ const fakeView = { state: { doc: { length: 0 } } };
163
+ const instance = factory(fakeView);
164
+ const before = instance.decorations;
165
+
166
+ instance.update({
167
+ docChanged: true,
168
+ viewportChanged: false,
169
+ state: fakeView.state,
170
+ });
171
+
172
+ // The decorations property should have been reassigned
173
+ expect(instance).toHaveProperty('decorations');
174
+ // The value comes from buildDecorations (which calls builder.finish() → [])
175
+ // — just verify it is defined after update
176
+ expect(instance.decorations).toBeDefined();
177
+ // Silence the "unused variable" warning for before
178
+ void before;
179
+ });
180
+
181
+ it('plugin update method re-builds decorations when viewport changes', () => {
182
+ const factory = (linkWrapPlugin as any).factory as (view: unknown) => {
183
+ decorations: unknown;
184
+ update: (update: {
185
+ docChanged: boolean;
186
+ viewportChanged: boolean;
187
+ state: unknown;
188
+ }) => void;
189
+ };
190
+ const fakeView = { state: { doc: { length: 0 } } };
191
+ const instance = factory(fakeView);
192
+
193
+ instance.update({
194
+ docChanged: false,
195
+ viewportChanged: true,
196
+ state: fakeView.state,
197
+ });
198
+ expect(instance.decorations).toBeDefined();
199
+ });
200
+
201
+ it('plugin update method does not rebuild when nothing changed', () => {
202
+ const factory = (linkWrapPlugin as any).factory as (view: unknown) => {
203
+ decorations: unknown;
204
+ update: (update: {
205
+ docChanged: boolean;
206
+ viewportChanged: boolean;
207
+ state: unknown;
208
+ }) => void;
209
+ };
210
+
211
+ const fakeView = { state: { doc: { length: 0 } } };
212
+ const instance = factory(fakeView);
213
+ // Record call count after construction (buildDecorations is called once in the factory)
214
+ const callsBefore = vi.mocked(syntaxTree).mock.calls.length;
215
+
216
+ instance.update({
217
+ docChanged: false,
218
+ viewportChanged: false,
219
+ state: fakeView.state,
220
+ });
221
+
222
+ // syntaxTree should not have been called again for the no-change update
223
+ expect(vi.mocked(syntaxTree).mock.calls.length).toBe(callsBefore);
224
+ });
225
+ });
@@ -0,0 +1,370 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ //////////////////////////////
4
+ // CodeMirror API mocks
5
+ //
6
+ // markdown-shortcuts.ts depends on @codemirror/view, @codemirror/state,
7
+ // and @codemirror/language. The mocks provide minimal implementations
8
+ // that allow the module to load and for the key handler functions to
9
+ // be exercised with controlled state. The EditorView.dispatch mock
10
+ // captures dispatched transactions so assertions can inspect them.
11
+ //////////////////////////////
12
+
13
+ vi.mock('@codemirror/language', () => ({
14
+ syntaxTree: vi.fn(() => ({
15
+ /**
16
+ * Stub syntax tree that supports targeted iteration and returns
17
+ * a Link or StrongEmphasis/Emphasis node based on a global flag.
18
+ * @param {{ from: number, to: number, enter: (node: { name: string, from: number, to: number }) => void }} opts - Iteration options
19
+ * @return {void}
20
+ */
21
+ iterate(opts: {
22
+ from?: number;
23
+ to?: number;
24
+ enter: (node: { name: string; from: number; to: number }) => void;
25
+ }) {
26
+ // Do not visit any nodes by default — tests override syntaxTree as needed
27
+ },
28
+ })),
29
+ }));
30
+
31
+ vi.mock('@codemirror/state', () => {
32
+ /**
33
+ * Minimal EditorSelection implementation supporting cursor and range creation.
34
+ * Matches the shape used by the command handlers.
35
+ */
36
+ const EditorSelection = {
37
+ /**
38
+ * Creates a cursor (collapsed) selection at the given position.
39
+ * @param {number} pos - The cursor position
40
+ * @return {{ anchor: number, head: number, empty: boolean }} Cursor selection
41
+ */
42
+ cursor(pos: number) {
43
+ return { anchor: pos, head: pos, empty: true };
44
+ },
45
+
46
+ /**
47
+ * Creates a ranged selection between from and to.
48
+ * @param {number} from - Selection start
49
+ * @param {number} to - Selection end
50
+ * @return {{ anchor: number, head: number, empty: boolean }} Range selection
51
+ */
52
+ range(from: number, to: number) {
53
+ return { anchor: from, head: to, empty: from === to };
54
+ },
55
+ };
56
+
57
+ return { EditorSelection };
58
+ });
59
+
60
+ vi.mock('@codemirror/view', () => {
61
+ /**
62
+ * Stub EditorView class with a controllable dispatch spy.
63
+ * Each test constructs a fresh instance so dispatch calls are isolated.
64
+ */
65
+ class EditorView {
66
+ state: {
67
+ selection: { main: { from: number; to: number; empty: boolean } };
68
+ doc: { length: number };
69
+ sliceDoc: (from: number, to: number) => string;
70
+ changeByRange: (
71
+ fn: (range: { from: number; to: number; empty: boolean }) => {
72
+ range: unknown;
73
+ changes: unknown;
74
+ },
75
+ ) => { changes: unknown; selection: unknown };
76
+ };
77
+
78
+ dispatch = vi.fn();
79
+
80
+ /**
81
+ * Creates a minimal EditorView stub with controllable selection and doc content.
82
+ * @param {{ from: number, to: number, empty: boolean }} selection - The main selection
83
+ * @param {string} docContent - The full document content string
84
+ */
85
+ constructor(
86
+ selection: { from: number; to: number; empty: boolean } = {
87
+ from: 0,
88
+ to: 0,
89
+ empty: true,
90
+ },
91
+ docContent = '',
92
+ ) {
93
+ this.state = {
94
+ selection: { main: selection },
95
+ doc: { length: docContent.length },
96
+ sliceDoc: (from: number, to: number) => docContent.slice(from, to),
97
+ changeByRange: (fn) => {
98
+ const result = fn(selection);
99
+ return { changes: result.changes, selection: result.range };
100
+ },
101
+ };
102
+ }
103
+ }
104
+
105
+ /** Minimal domEventHandlers stub — captures the handler object and returns a fake extension. */
106
+ const domEventHandlersResults: unknown[] = [];
107
+ EditorView.domEventHandlers = vi.fn((handlers: unknown) => {
108
+ domEventHandlersResults.push(handlers);
109
+ return { isDomEventHandler: true, handlers };
110
+ });
111
+
112
+ /** Minimal inputHandler.of stub — returns a fake extension. */
113
+ EditorView.inputHandler = {
114
+ of: vi.fn((fn: unknown) => ({ isInputHandler: true, fn })),
115
+ };
116
+
117
+ return { EditorView };
118
+ });
119
+
120
+ vi.mock('../utils/url-utils', async () => {
121
+ const actual = await import('../../../../src/client/js/utils/url-utils');
122
+ return { isURL: actual.isURL };
123
+ });
124
+
125
+ import { syntaxTree } from '@codemirror/language';
126
+ import { EditorSelection } from '@codemirror/state';
127
+ import { EditorView } from '@codemirror/view';
128
+ import {
129
+ markdownShortcutsKeymap,
130
+ markdownShortcutsExtensions,
131
+ } from '../../../../src/client/js/editor/markdown-shortcuts';
132
+
133
+ //////////////////////////////
134
+ // markdownShortcutsKeymap — structure
135
+ //////////////////////////////
136
+
137
+ describe('markdownShortcutsKeymap', () => {
138
+ it('is an array', () => {
139
+ expect(Array.isArray(markdownShortcutsKeymap)).toBe(true);
140
+ });
141
+
142
+ it('contains exactly 3 key bindings', () => {
143
+ expect(markdownShortcutsKeymap).toHaveLength(3);
144
+ });
145
+
146
+ it('contains a Mod-b binding for bold', () => {
147
+ const binding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-b');
148
+ expect(binding).toBeDefined();
149
+ expect(typeof binding?.run).toBe('function');
150
+ });
151
+
152
+ it('contains a Mod-i binding for italic', () => {
153
+ const binding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-i');
154
+ expect(binding).toBeDefined();
155
+ expect(typeof binding?.run).toBe('function');
156
+ });
157
+
158
+ it('contains a Mod-k binding for link insertion', () => {
159
+ const binding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-k');
160
+ expect(binding).toBeDefined();
161
+ expect(typeof binding?.run).toBe('function');
162
+ });
163
+ });
164
+
165
+ //////////////////////////////
166
+ // markdownShortcutsExtensions — structure
167
+ //////////////////////////////
168
+
169
+ describe('markdownShortcutsExtensions', () => {
170
+ it('is an array', () => {
171
+ expect(Array.isArray(markdownShortcutsExtensions)).toBe(true);
172
+ });
173
+
174
+ it('contains exactly 2 extensions (smart paste + bracket wrap)', () => {
175
+ expect(markdownShortcutsExtensions).toHaveLength(2);
176
+ });
177
+
178
+ it('all items are truthy (valid extension objects)', () => {
179
+ for (const ext of markdownShortcutsExtensions) {
180
+ expect(ext).toBeTruthy();
181
+ }
182
+ });
183
+ });
184
+
185
+ //////////////////////////////
186
+ // Mod-b (bold) handler — toggleMarker('**', 'StrongEmphasis')
187
+ //////////////////////////////
188
+
189
+ describe('Mod-b handler — wrapping', () => {
190
+ it('wraps a selection in ** markers', () => {
191
+ // No wrapping node found — falls through to wrap-selection branch
192
+ vi.mocked(syntaxTree).mockReturnValue({
193
+ iterate: vi.fn(),
194
+ } as any);
195
+
196
+ const boldBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-b')!;
197
+ // Selection covering "World" in "Hello World" (from=6, to=11)
198
+ const view = new (EditorView as any)(
199
+ { from: 6, to: 11, empty: false },
200
+ 'Hello World',
201
+ );
202
+ const result = boldBinding.run(view as any);
203
+ expect(result).toBe(true);
204
+ expect(view.dispatch).toHaveBeenCalled();
205
+ const transaction = view.dispatch.mock.calls[0][0];
206
+ // changes should include inserting ** at both ends
207
+ expect(transaction.changes).toBeDefined();
208
+ });
209
+
210
+ it('inserts empty ** markers when selection is empty', () => {
211
+ vi.mocked(syntaxTree).mockReturnValue({
212
+ iterate: vi.fn(),
213
+ } as any);
214
+
215
+ const boldBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-b')!;
216
+ const view = new (EditorView as any)(
217
+ { from: 5, to: 5, empty: true },
218
+ 'Hello World',
219
+ );
220
+ const result = boldBinding.run(view as any);
221
+ expect(result).toBe(true);
222
+ expect(view.dispatch).toHaveBeenCalled();
223
+ });
224
+
225
+ it('unwraps when cursor is inside a StrongEmphasis node', () => {
226
+ // Simulate the syntax tree finding a StrongEmphasis node at the cursor position
227
+ vi.mocked(syntaxTree).mockReturnValue({
228
+ iterate: vi.fn(
229
+ (opts: {
230
+ enter: (n: { name: string; from: number; to: number }) => void;
231
+ }) => {
232
+ opts.enter({ name: 'StrongEmphasis', from: 0, to: 13 });
233
+ },
234
+ ),
235
+ } as any);
236
+
237
+ const boldBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-b')!;
238
+ const view = new (EditorView as any)(
239
+ { from: 5, to: 5, empty: true },
240
+ '**Hello World**',
241
+ );
242
+ const result = boldBinding.run(view as any);
243
+ expect(result).toBe(true);
244
+ expect(view.dispatch).toHaveBeenCalled();
245
+ const transaction = view.dispatch.mock.calls[0][0];
246
+ // Unwrap produces changes that remove the markers
247
+ expect(transaction.changes).toBeDefined();
248
+ });
249
+ });
250
+
251
+ //////////////////////////////
252
+ // Mod-i (italic) handler — toggleMarker('_', 'Emphasis')
253
+ //////////////////////////////
254
+
255
+ describe('Mod-i handler — wrapping', () => {
256
+ it('wraps a selection in _ markers', () => {
257
+ vi.mocked(syntaxTree).mockReturnValue({ iterate: vi.fn() } as any);
258
+
259
+ const italicBinding = markdownShortcutsKeymap.find(
260
+ (b) => b.key === 'Mod-i',
261
+ )!;
262
+ const view = new (EditorView as any)(
263
+ { from: 0, to: 5, empty: false },
264
+ 'Hello World',
265
+ );
266
+ const result = italicBinding.run(view as any);
267
+ expect(result).toBe(true);
268
+ expect(view.dispatch).toHaveBeenCalled();
269
+ });
270
+
271
+ it('inserts empty _ markers when no selection', () => {
272
+ vi.mocked(syntaxTree).mockReturnValue({ iterate: vi.fn() } as any);
273
+
274
+ const italicBinding = markdownShortcutsKeymap.find(
275
+ (b) => b.key === 'Mod-i',
276
+ )!;
277
+ const view = new (EditorView as any)(
278
+ { from: 3, to: 3, empty: true },
279
+ 'Hello',
280
+ );
281
+ const result = italicBinding.run(view as any);
282
+ expect(result).toBe(true);
283
+ expect(view.dispatch).toHaveBeenCalled();
284
+ });
285
+ });
286
+
287
+ //////////////////////////////
288
+ // Mod-k (link) handler — insertLink
289
+ //////////////////////////////
290
+
291
+ describe('Mod-k handler — link insertion', () => {
292
+ it('inserts []() with no selection and places cursor inside []', () => {
293
+ // No Link node at cursor
294
+ vi.mocked(syntaxTree).mockReturnValue({ iterate: vi.fn() } as any);
295
+
296
+ const linkBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-k')!;
297
+ const view = new (EditorView as any)({ from: 0, to: 0, empty: true }, '');
298
+ const result = linkBinding.run(view as any);
299
+ expect(result).toBe(true);
300
+ expect(view.dispatch).toHaveBeenCalled();
301
+ const tx = view.dispatch.mock.calls[0][0];
302
+ // The change should insert []()
303
+ const changes = Array.isArray(tx.changes) ? tx.changes : [tx.changes];
304
+ const insertCall = changes.find(
305
+ (c: { insert?: string }) => c.insert === '[]()',
306
+ );
307
+ expect(insertCall).toBeDefined();
308
+ });
309
+
310
+ it('wraps selected text as [text]() with cursor in ()', () => {
311
+ vi.mocked(syntaxTree).mockReturnValue({ iterate: vi.fn() } as any);
312
+
313
+ const linkBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-k')!;
314
+ const view = new (EditorView as any)(
315
+ { from: 0, to: 5, empty: false },
316
+ 'Hello World',
317
+ );
318
+ const result = linkBinding.run(view as any);
319
+ expect(result).toBe(true);
320
+ expect(view.dispatch).toHaveBeenCalled();
321
+ const tx = view.dispatch.mock.calls[0][0];
322
+ const changes = Array.isArray(tx.changes) ? tx.changes : [tx.changes];
323
+ // The replacement should be [Hello]()
324
+ const replaceCall = changes.find(
325
+ (c: { insert?: string }) => c.insert === '[Hello]()',
326
+ );
327
+ expect(replaceCall).toBeDefined();
328
+ });
329
+
330
+ it('returns false when cursor is already inside a Link node', () => {
331
+ // Simulate a Link node containing the cursor
332
+ vi.mocked(syntaxTree).mockReturnValue({
333
+ iterate: vi.fn(
334
+ (opts: {
335
+ enter: (n: { name: string; from: number; to: number }) => void;
336
+ }) => {
337
+ opts.enter({ name: 'Link', from: 0, to: 20 });
338
+ },
339
+ ),
340
+ } as any);
341
+
342
+ const linkBinding = markdownShortcutsKeymap.find((b) => b.key === 'Mod-k')!;
343
+ const view = new (EditorView as any)(
344
+ { from: 5, to: 5, empty: true },
345
+ '[existing](http://example.com)',
346
+ );
347
+ const result = linkBinding.run(view as any);
348
+ expect(result).toBe(false);
349
+ expect(view.dispatch).not.toHaveBeenCalled();
350
+ });
351
+ });
352
+
353
+ //////////////////////////////
354
+ // EditorSelection usage in handlers
355
+ //////////////////////////////
356
+
357
+ describe('EditorSelection integration', () => {
358
+ it('cursor() returns an empty selection at the given position', () => {
359
+ const sel = EditorSelection.cursor(7);
360
+ expect(sel.anchor).toBe(7);
361
+ expect(sel.empty).toBe(true);
362
+ });
363
+
364
+ it('range() returns a ranged selection', () => {
365
+ const sel = EditorSelection.range(3, 10);
366
+ expect(sel.anchor).toBe(3);
367
+ expect(sel.head).toBe(10);
368
+ expect(sel.empty).toBe(false);
369
+ });
370
+ });