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.
- package/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- 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
|
+
});
|