@mh-gg/base-plugins 0.1.1-alpha.20260613T085325975Z

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 (61) hide show
  1. package/README.md +247 -0
  2. package/package.json +48 -0
  3. package/src/approvals/index.cjs +65 -0
  4. package/src/attachments/index.cjs +75 -0
  5. package/src/calendar/index.cjs +48 -0
  6. package/src/checklists/index.cjs +58 -0
  7. package/src/comments/index.cjs +15 -0
  8. package/src/comments/plugin.cjs +66 -0
  9. package/src/comments/reducer.cjs +81 -0
  10. package/src/comments/schemas.cjs +60 -0
  11. package/src/comments/state.cjs +17 -0
  12. package/src/comments/threads.cjs +44 -0
  13. package/src/comments/views.cjs +27 -0
  14. package/src/composer/capabilities.cjs +19 -0
  15. package/src/composer/compose.cjs +37 -0
  16. package/src/composer/index.cjs +15 -0
  17. package/src/composer/operations.cjs +42 -0
  18. package/src/composer/registry.cjs +155 -0
  19. package/src/composer/selection.cjs +39 -0
  20. package/src/composer/suite.cjs +32 -0
  21. package/src/crdt/client.mjs +207 -0
  22. package/src/crdt/index.cjs +258 -0
  23. package/src/embeds/index.cjs +90 -0
  24. package/src/files/index.cjs +133 -0
  25. package/src/index.cjs +19 -0
  26. package/src/labels/index.cjs +46 -0
  27. package/src/location-pins/index.cjs +142 -0
  28. package/src/markdown/documents/index.cjs +128 -0
  29. package/src/markdown/index.cjs +8 -0
  30. package/src/markdown/parser/index.cjs +127 -0
  31. package/src/markdown/providers/audio.cjs +77 -0
  32. package/src/markdown/providers/cloud.cjs +72 -0
  33. package/src/markdown/providers/developer.cjs +45 -0
  34. package/src/markdown/providers/direct.cjs +49 -0
  35. package/src/markdown/providers/games.cjs +26 -0
  36. package/src/markdown/providers/images.cjs +88 -0
  37. package/src/markdown/providers/index.cjs +97 -0
  38. package/src/markdown/providers/maps.cjs +24 -0
  39. package/src/markdown/providers/productivity.cjs +30 -0
  40. package/src/markdown/providers/res-inspired.cjs +11 -0
  41. package/src/markdown/providers/social.cjs +33 -0
  42. package/src/markdown/providers/video.cjs +139 -0
  43. package/src/markdown/resolve.cjs +87 -0
  44. package/src/media-rooms/index.cjs +244 -0
  45. package/src/presence/index.cjs +193 -0
  46. package/src/reactions/index.cjs +47 -0
  47. package/src/screen-share/index.cjs +84 -0
  48. package/src/shared/constants.cjs +87 -0
  49. package/src/shared/embed.cjs +82 -0
  50. package/src/shared/index.cjs +20 -0
  51. package/src/shared/roles.cjs +5 -0
  52. package/src/shared/scopes.cjs +15 -0
  53. package/src/shared/url.cjs +32 -0
  54. package/src/shared/validation.cjs +31 -0
  55. package/test/composable-plugins.test.cjs +170 -0
  56. package/test/crdt-plugin.test.cjs +168 -0
  57. package/test/embed-autodetect-providers.test.cjs +138 -0
  58. package/test/markdown-media-workflow-plugins.test.cjs +201 -0
  59. package/test/markdown-parser-edge-cases.test.cjs +86 -0
  60. package/test/plugin-structure.test.cjs +69 -0
  61. package/test/shared-plugin-edges.test.cjs +207 -0
@@ -0,0 +1,138 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ DEFAULT_MARKDOWN_PLUGINS,
6
+ EMBED_PROVIDER_KEYS,
7
+ RES_INSPIRED_EMBED_PROVIDERS,
8
+ autoDetectEmbeds,
9
+ extractUrlsFromText,
10
+ parseMarkdown,
11
+ pluginForDirective,
12
+ resolveEmbed
13
+ } = require("../src/index.cjs");
14
+
15
+ const providerCases = [
16
+ ["https://youtu.be/dQw4w9WgXcQ", "youtube", "video", "iframe"],
17
+ ["https://www.youtube.com/playlist?list=PL1234567890abcdef", "youtube", "video-playlist", "iframe"],
18
+ ["https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC", "spotify", "audio", "iframe"],
19
+ ["https://drive.google.com/file/d/abc123/view", "google-drive", "file", "iframe"],
20
+ ["https://docs.google.com/document/d/doc123/edit", "google-drive", "document", "iframe"],
21
+ ["https://photos.app.goo.gl/share123", "google-photos", "photo-album", "external-link"],
22
+ ["https://www.dropbox.com/s/example/file.pdf?dl=0", "dropbox", "document", "file-preview"],
23
+ ["https://vimeo.com/123456789", "vimeo", "video", "iframe"],
24
+ ["https://soundcloud.com/artist/track", "soundcloud", "audio", "iframe"],
25
+ ["https://artist.bandcamp.com/track/song", "bandcamp", "audio", "external-link"],
26
+ ["https://music.apple.com/us/album/demo/123", "apple-music", "audio", "external-link"],
27
+ ["https://www.twitch.tv/videos/123456789", "twitch", "video", "iframe"],
28
+ ["https://clips.twitch.tv/FineClipSlug", "twitch", "clip", "iframe"],
29
+ ["https://www.dailymotion.com/video/x7tgcz", "dailymotion", "video", "iframe"],
30
+ ["https://dai.ly/x7tgcz", "dailymotion", "video", "iframe"],
31
+ ["https://streamable.com/abcd12", "streamable", "video", "iframe"],
32
+ ["https://streamja.com/abcde", "streamja", "video", "iframe"],
33
+ ["https://coub.com/view/abc123", "coub", "video-loop", "iframe"],
34
+ ["https://i.imgur.com/abc1234.png", "imgur", "image", "image"],
35
+ ["https://i.imgur.com/abc1234.gifv", "imgur", "video", "video"],
36
+ ["https://imgur.com/a/album123", "imgur", "gallery", "external-link"],
37
+ ["https://giphy.com/gifs/funny-cat-abc123", "giphy", "gif", "image"],
38
+ ["https://tenor.com/view/cat-wave-123456", "tenor", "gif", "external-link"],
39
+ ["https://redgifs.com/watch/sampleclip", "redgifs", "video", "external-link"],
40
+ ["https://gfycat.com/serpentineoldfrog", "gfycat", "video", "external-link"],
41
+ ["https://gyazo.com/abc123", "gyazo", "image", "image"],
42
+ ["https://www.flickr.com/photos/user/123456789", "flickr", "photo", "external-link"],
43
+ ["https://500px.com/photo/123456/demo", "500px", "photo", "external-link"],
44
+ ["https://www.deviantart.com/user/art/title-123456", "deviantart", "artwork", "external-link"],
45
+ ["https://example.photobucket.com/albums/item.jpg", "photobucket", "photo", "external-link"],
46
+ ["https://www.pixiv.net/en/artworks/123456", "pixiv", "artwork", "external-link"],
47
+ ["https://staff.tumblr.com/post/123/demo", "tumblr", "post", "external-link"],
48
+ ["https://pbs.twimg.com/media/example.jpg", "twimg", "image", "image"],
49
+ ["https://www.instagram.com/p/ABCDEF/", "instagram", "social-post", "external-link"],
50
+ ["https://www.facebook.com/watch/?v=123456789", "facebook-video", "video", "external-link"],
51
+ ["https://www.tiktok.com/@user/video/1234567890", "tiktok", "video", "external-link"],
52
+ ["https://x.com/matterhorn/status/1234567890", "twitter", "social-post", "external-link"],
53
+ ["https://bsky.app/profile/example.com/post/3abc", "bluesky", "social-post", "external-link"],
54
+ ["https://i.redd.it/example.png", "reddit", "image", "image"],
55
+ ["https://www.reddit.com/gallery/abc123", "reddit", "gallery", "external-link"],
56
+ ["https://imgflip.com/i/abc123", "imgflip", "meme", "external-link"],
57
+ ["https://codepen.io/user/pen/abc123", "codepen", "code-demo", "iframe"],
58
+ ["https://jsfiddle.net/user/abc123/", "jsfiddle", "code-demo", "iframe"],
59
+ ["https://github.com/matterhorn/example", "github", "repository", "external-link"],
60
+ ["https://gist.github.com/user/abcdef", "gist", "code-snippet", "external-link"],
61
+ ["https://pastebin.com/abc123", "pastebin", "code-snippet", "iframe"],
62
+ ["https://hastebin.com/share/abc123", "hastebin", "code-snippet", "external-link"],
63
+ ["https://en.wikipedia.org/wiki/Markdown", "wikipedia", "article", "external-link"],
64
+ ["https://www.google.com/maps/place/New+York", "google-maps", "map", "external-link"],
65
+ ["https://www.openstreetmap.org/relation/175905", "openstreetmap", "map", "external-link"],
66
+ ["https://ridewithgps.com/routes/123456", "ridewithgps", "route", "external-link"],
67
+ ["https://1drv.ms/f/s!abc123", "onedrive", "file", "external-link"],
68
+ ["https://www.figma.com/file/abc123/demo", "figma", "design", "external-link"],
69
+ ["https://demo.notion.site/Launch-Plan-abc123", "notion", "document", "external-link"],
70
+ ["https://www.loom.com/share/abc123", "loom", "video", "external-link"],
71
+ ["https://www.canva.com/design/abc123/view", "canva", "design", "external-link"],
72
+ ["https://miro.com/app/board/abc123/", "miro", "whiteboard", "external-link"],
73
+ ["https://excalidraw.com/#json=abc123", "excalidraw", "whiteboard", "external-link"],
74
+ ["https://store.steampowered.com/app/730/CounterStrike_2/", "steam", "game", "external-link"],
75
+ ["https://xboxdvr.com/gamer/user/video/123", "xboxdvr", "game-clip", "external-link"],
76
+ ["https://xkcd.com/303/", "xkcd", "comic", "external-link"],
77
+ ["https://strawpoll.com/polls/abc123", "strawpoll", "poll", "external-link"],
78
+ ["https://video.joinpeertube.org/w/abc123", "peertube", "video", "external-link"],
79
+ ["https://clyp.it/abc123", "clyp", "audio", "external-link"],
80
+ ["https://getyarn.io/yarn-clip/abc123", "getyarn", "video-clip", "external-link"],
81
+ ["https://cdn.example.test/photo.webp", "image", "image", "image"],
82
+ ["https://cdn.example.test/movie.mp4", "video", "video", "video"],
83
+ ["https://cdn.example.test/audio.mp3", "audio", "audio", "audio"],
84
+ ["https://cdn.example.test/runbook.pdf", "document", "document", "file-preview"]
85
+ ];
86
+
87
+ test("default embed registry covers a broad RES-inspired provider set", () => {
88
+ assert.ok(DEFAULT_MARKDOWN_PLUGINS.length >= 60, `expected a large provider registry, got ${DEFAULT_MARKDOWN_PLUGINS.length}`);
89
+ assert.ok(RES_INSPIRED_EMBED_PROVIDERS.includes("imgur"));
90
+ assert.ok(RES_INSPIRED_EMBED_PROVIDERS.includes("twitch"));
91
+ assert.ok(RES_INSPIRED_EMBED_PROVIDERS.includes("bluesky"));
92
+ assert.ok(EMBED_PROVIDER_KEYS.includes("youtube"));
93
+ assert.ok(EMBED_PROVIDER_KEYS.includes("spotify"));
94
+ assert.ok(EMBED_PROVIDER_KEYS.includes("soundcloud"));
95
+ assert.ok(EMBED_PROVIDER_KEYS.includes("figma"));
96
+ });
97
+
98
+ test("resolveEmbed auto-detects common media providers", () => {
99
+ for (const [url, provider, kind, renderMode] of providerCases) {
100
+ const embed = resolveEmbed(url, { title: "Shared item" });
101
+ assert.equal(embed.provider, provider, url);
102
+ assert.equal(embed.kind, kind, url);
103
+ assert.equal(embed.renderMode, renderMode, url);
104
+ assert.equal(embed.title, "Shared item");
105
+ }
106
+ });
107
+
108
+ test("directives and aliases resolve to the expected provider plugins", () => {
109
+ assert.equal(pluginForDirective("yt").provider, "youtube");
110
+ assert.equal(pluginForDirective("drive").provider, "google-drive");
111
+ assert.equal(pluginForDirective("bsky").provider, "bluesky");
112
+ assert.equal(pluginForDirective("fiddle").provider, "jsfiddle");
113
+ assert.equal(pluginForDirective("screen")?.provider, undefined);
114
+ });
115
+
116
+ test("autoDetectEmbeds extracts bare links, strips punctuation, dedupes, and can skip generic links", () => {
117
+ const text = "Watch https://vimeo.com/123456789, listen https://soundcloud.com/artist/track! duplicate https://vimeo.com/123456789 and plain https://example.com/story.";
118
+ const detected = autoDetectEmbeds(text);
119
+ assert.deepEqual(detected.map((embed) => embed.provider), ["vimeo", "soundcloud", "link"]);
120
+ assert.deepEqual(detected.map((embed) => embed.autoDetected), [true, true, true]);
121
+
122
+ const richOnly = autoDetectEmbeds(text, { includeGeneric: false });
123
+ assert.deepEqual(richOnly.map((embed) => embed.provider), ["vimeo", "soundcloud"]);
124
+ });
125
+
126
+ test("parseMarkdown auto-detects embeddables in markdown links, bare URLs, and directives", () => {
127
+ const parsed = parseMarkdown([
128
+ "# Media roundup",
129
+ "Here is [a tweet](https://x.com/matterhorn/status/1234567890) and https://streamable.com/abcd12.",
130
+ "::codepen[Demo pen](https://codepen.io/user/pen/abc123)",
131
+ "::figma[Design](https://www.figma.com/file/abc123/demo)"
132
+ ].join("\n\n"));
133
+ assert.deepEqual(parsed.embeds.map((embed) => embed.provider), ["twitter", "streamable", "codepen", "figma"]);
134
+ assert.equal(parsed.nodes[1].children[0].type, "text");
135
+ assert.equal(parsed.nodes[2].type, "embed");
136
+ assert.equal(parsed.embedCount, 4);
137
+ });
138
+
@@ -0,0 +1,201 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const { manifestHash } = require("@mh-gg/base");
4
+ const { createSignedPartyEvent, generatePartyIdentity, partyEventToNostrEvent } = require("@mh-gg/event");
5
+ const { HostPluginRuntime, createMemoryOperationLog, createMemoryRoomStore } = require("@mh-gg/host-runtime");
6
+ const { ensureOperationIdentity } = require("@mh-gg/protocol");
7
+ const { buildExampleMatterhornApp, createExampleActor, createExampleOperationFactory } = require("../../../examples/_shared/matterhornExample/index.cjs");
8
+ const {
9
+ APPROVALS_PLUGIN_ID,
10
+ ATTACHMENTS_PLUGIN_ID,
11
+ CALENDAR_PLUGIN_ID,
12
+ CHECKLISTS_PLUGIN_ID,
13
+ COMMENTS_PLUGIN_ID,
14
+ EMBEDS_PLUGIN_ID,
15
+ FILES_PLUGIN_ID,
16
+ LABELS_PLUGIN_ID,
17
+ MARKDOWN_PLUGIN_ID,
18
+ REACTIONS_PLUGIN_ID,
19
+ createCollaborationPluginSuite,
20
+ defineMarkdownPlugin,
21
+ parseMarkdown,
22
+ resolveEmbed
23
+ } = require("../src/index.cjs");
24
+
25
+ const suite = createCollaborationPluginSuite("all");
26
+ const built = buildExampleMatterhornApp({
27
+ slug: "content-workflow",
28
+ packageName: "@mh-gg/base-plugins",
29
+ appId: "com.matterhorn.examples.content-workflow",
30
+ name: "Content Workflow Plugin Tests",
31
+ hostPlugins: [suite]
32
+ });
33
+
34
+ function actor(overrides = {}) {
35
+ return createExampleActor({ memberId: "admin", role: "admin", displayName: "Admin", ...overrides });
36
+ }
37
+
38
+ function makeOps() {
39
+ return createExampleOperationFactory({ appPack: built.appPack, hostPlugin: suite.plugins[0], roomId: "room_content", actor: actor(), startTime: 3000 });
40
+ }
41
+
42
+ async function runtime() {
43
+ const rt = new HostPluginRuntime({
44
+ room: { id: "room_content", appPack: { id: built.appPack.id, version: built.appPack.version, hash: manifestHash(built.appPack), protocolHash: built.appPack.compatibility.appProtocolHash } },
45
+ plugins: suite.plugins,
46
+ store: createMemoryRoomStore(),
47
+ operationLog: createMemoryOperationLog(),
48
+ authenticateActor: async (auth, actorValue) => {
49
+ assert.equal(auth.signature, "sig");
50
+ return actorValue;
51
+ }
52
+ });
53
+ await rt.start();
54
+ return rt;
55
+ }
56
+
57
+ async function rejected(promise, pattern) {
58
+ const result = await promise;
59
+ assert.equal(result.ok, false);
60
+ assert.match(result.reason, pattern);
61
+ }
62
+
63
+ test("markdown parser supports extension resolvers, media directives, inline links, lists, and code", () => {
64
+ const custom = defineMarkdownPlugin({
65
+ id: "markdown.embed.example-video",
66
+ provider: "example-video",
67
+ aliases: ["example"],
68
+ matchUrl(url) { return new URL(url).hostname === "video.example.test"; },
69
+ toEmbed(url, ctx = {}) {
70
+ return { provider: "example-video", kind: "video", title: ctx.title || "Example", url, externalUrl: url, renderMode: "card", metadata: { custom: true } };
71
+ }
72
+ });
73
+ const parsed = parseMarkdown(`# Demo\n\nA [clip](https://video.example.test/watch/1) and a bare https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC\n\n::youtube[Launch video](https://youtu.be/dQw4w9WgXcQ)\n\n- task one\n- task two\n\n\`\`\`js\nconsole.log(1)\n\`\`\``, { plugins: [custom, ...require("../src/index.cjs").DEFAULT_MARKDOWN_PLUGINS] });
74
+
75
+ assert.equal(parsed.nodes[0].type, "heading");
76
+ assert.equal(parsed.nodes.some((node) => node.type === "list"), true);
77
+ assert.equal(parsed.nodes.some((node) => node.type === "code" && node.language === "js"), true);
78
+ assert.deepEqual(parsed.embeds.map((embed) => embed.provider), ["example-video", "spotify", "youtube"]);
79
+ assert.equal(parsed.embeds.find((embed) => embed.provider === "youtube").embedUrl, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ");
80
+ });
81
+
82
+ test("embed resolver supports YouTube, Spotify, Dropbox, Google Drive, Google Photos, and generic links", () => {
83
+ const cases = [
84
+ ["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "youtube", "iframe"],
85
+ ["https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "spotify", "iframe"],
86
+ ["https://www.dropbox.com/s/example/file.png?dl=0", "dropbox", "file-preview"],
87
+ ["https://drive.google.com/file/d/abc123/view?usp=sharing", "google-drive", "iframe"],
88
+ ["https://photos.app.goo.gl/abc123", "google-photos", "external-link"],
89
+ ["https://example.com/story", "link", "card"]
90
+ ];
91
+
92
+ for (const [url, provider, renderMode] of cases) {
93
+ const embed = resolveEmbed(url, { title: "Shared media" });
94
+ assert.equal(embed.provider, provider);
95
+ assert.equal(embed.renderMode, renderMode);
96
+ assert.equal(embed.title, "Shared media");
97
+ }
98
+ assert.throws(() => resolveEmbed("http://evil.example/video"), /https/);
99
+ });
100
+
101
+ test("markdown, embeds, and attachments plugins store parsed content and reusable media cards", async () => {
102
+ const rt = await runtime();
103
+ const op = makeOps();
104
+ const member = actor({ memberId: "mina", role: "member", displayName: "Mina" });
105
+ await rt.handleOperation(op("markdown.document.upsert", {
106
+ scopeType: "wiki-page",
107
+ scopeId: "page_1",
108
+ title: "Launch Plan",
109
+ markdown: "# Launch\n\nWatch :: no-op paragraph\n\n::spotify[Playlist](https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)",
110
+ tags: ["launch", "music"]
111
+ }, { id: "markdown_1", pluginId: MARKDOWN_PLUGIN_ID, actor: member }));
112
+ await rt.handleOperation(op("embed.add", { scopeType: "wiki-page", scopeId: "page_1", url: "https://drive.google.com/file/d/abc123/view", title: "Spec" }, { id: "embed_1", pluginId: EMBEDS_PLUGIN_ID, actor: member }));
113
+ await rt.handleOperation(op("attachment.add", { scopeType: "wiki-page", scopeId: "page_1", url: "https://www.dropbox.com/s/example/file.pdf?dl=0", title: "Runbook", mimeType: "application/pdf" }, { id: "attachment_1", pluginId: ATTACHMENTS_PLUGIN_ID, actor: member }));
114
+
115
+ const document = await rt.query(MARKDOWN_PLUGIN_ID, "documentForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member);
116
+ const embeds = await rt.query(EMBEDS_PLUGIN_ID, "embedsForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member);
117
+ const attachments = await rt.query(ATTACHMENTS_PLUGIN_ID, "attachmentsForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member);
118
+
119
+ assert.equal(document.title, "Launch Plan");
120
+ assert.equal(document.embeds[0].provider, "spotify");
121
+ assert.equal(embeds[0].provider, "google-drive");
122
+ assert.equal(attachments[0].provider, "dropbox");
123
+
124
+ await rejected(rt.handleOperation(op("embed.remove", { embedId: embeds[0].id }, { id: "embed_2", pluginId: EMBEDS_PLUGIN_ID, actor: actor({ memberId: "lee", role: "member" }) })), /Only embed authors/);
125
+ await rt.handleOperation(op("embed.remove", { embedId: embeds[0].id }, { id: "embed_3", pluginId: EMBEDS_PLUGIN_ID, actor: member }));
126
+ assert.deepEqual(await rt.query(EMBEDS_PLUGIN_ID, "embedsForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member), []);
127
+ });
128
+
129
+ test("files plugin stores encrypted Nostr upload events by scope", async () => {
130
+ const rt = await runtime();
131
+ const op = makeOps();
132
+ const member = actor({ memberId: "mina", role: "member", displayName: "Mina" });
133
+ const fileEvent = partyEventToNostrEvent(createSignedPartyEvent({
134
+ kind: "file.upload",
135
+ partyId: "room_content",
136
+ identity: generatePartyIdentity(1000),
137
+ roomSecret: "invite-secret",
138
+ payload: {
139
+ fileName: "runbook.txt",
140
+ mimeType: "text/plain",
141
+ sizeBytes: 11,
142
+ sha256: "64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c",
143
+ dataBase64: Buffer.from("hello world").toString("base64")
144
+ },
145
+ createdAt: 3001
146
+ }));
147
+ const result = await rt.handleOperation(op("file.upload", {
148
+ scopeType: "wiki-page",
149
+ scopeId: "page_1",
150
+ event: fileEvent
151
+ }, { id: "file_1", pluginId: FILES_PLUGIN_ID, actor: member, createdAt: 3001 }));
152
+
153
+ assert.equal(result.ok, true, result.reason);
154
+ const scoped = await rt.query(FILES_PLUGIN_ID, "filesForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member);
155
+ assert.equal(scoped[0].eventId, fileEvent.id);
156
+ const stored = JSON.stringify(scoped[0]);
157
+ assert.equal(stored.includes("hello world"), false);
158
+ assert.equal(stored.includes("runbook.txt"), false);
159
+ assert.equal(stored.includes("text/plain"), false);
160
+ assert.equal(stored.includes("64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c"), false);
161
+ await rejected(rt.handleOperation(op("file.remove", { fileId: scoped[0].id }, { id: "file_2", pluginId: FILES_PLUGIN_ID, actor: actor({ memberId: "lee", role: "member" }) })), /Only file uploaders/);
162
+ await rt.handleOperation(op("file.remove", { fileId: scoped[0].id }, { id: "file_3", pluginId: FILES_PLUGIN_ID, actor: member }));
163
+ assert.deepEqual(await rt.query(FILES_PLUGIN_ID, "filesForScope", { scopeType: "wiki-page", scopeId: "page_1" }, member), []);
164
+ });
165
+
166
+ test("workflow plugins compose reactions, labels, approvals, checklists, and calendar events", async () => {
167
+ const rt = await runtime();
168
+ const op = makeOps();
169
+ const member = actor({ memberId: "mina", role: "member", displayName: "Mina" });
170
+ await rt.handleOperation(op("reaction.toggle", { scopeType: "card", scopeId: "card_1", emoji: "🚀" }, { id: "react_1", pluginId: REACTIONS_PLUGIN_ID, actor: member }));
171
+ assert.deepEqual((await rt.query(REACTIONS_PLUGIN_ID, "reactionsForScope", { scopeType: "card", scopeId: "card_1" }, member)).reactions["🚀"], ["mina"]);
172
+
173
+ await rt.handleOperation(op("label.create", { name: "Blocked", color: "red" }, { id: "label_1", pluginId: LABELS_PLUGIN_ID }));
174
+ const labelId = Object.keys((await rt.getState()).plugins[LABELS_PLUGIN_ID].labels)[0];
175
+ await rt.handleOperation(op("label.apply", { scopeType: "card", scopeId: "card_1", labelId }, { id: "label_2", pluginId: LABELS_PLUGIN_ID, actor: member }));
176
+ assert.equal((await rt.query(LABELS_PLUGIN_ID, "labelsForScope", { scopeType: "card", scopeId: "card_1" }, member))[0].name, "Blocked");
177
+
178
+ await rt.handleOperation(op("approval.request", { scopeType: "budget-expense", scopeId: "expense_1", title: "Approve venue deposit", requiredRole: "admin" }, { id: "approval_1", pluginId: APPROVALS_PLUGIN_ID, actor: member }));
179
+ const requestId = Object.keys((await rt.getState()).plugins[APPROVALS_PLUGIN_ID].requests)[0];
180
+ await rejected(rt.handleOperation(op("approval.decide", { requestId, decision: "approved" }, { id: "approval_2", pluginId: APPROVALS_PLUGIN_ID, actor: actor({ memberId: "mod", role: "moderator" }) })), /Admins only/);
181
+ await rt.handleOperation(op("approval.decide", { requestId, decision: "approved", note: "Paid" }, { id: "approval_3", pluginId: APPROVALS_PLUGIN_ID }));
182
+ assert.deepEqual(await rt.query(APPROVALS_PLUGIN_ID, "openApprovals", {}, member), []);
183
+
184
+ await rt.handleOperation(op("checklist.create", { scopeType: "event-task", scopeId: "task_1", title: "Launch checklist", items: ["Book room", "Send invite"] }, { id: "check_1", pluginId: CHECKLISTS_PLUGIN_ID, actor: member }));
185
+ const checklist = (await rt.query(CHECKLISTS_PLUGIN_ID, "checklistsForScope", { scopeType: "event-task", scopeId: "task_1" }, member))[0];
186
+ await rt.handleOperation(op("checklist.item.toggle", { checklistId: checklist.id, itemId: checklist.items[0].id, completed: true }, { id: "check_2", pluginId: CHECKLISTS_PLUGIN_ID, actor: member }));
187
+ assert.equal((await rt.query(CHECKLISTS_PLUGIN_ID, "checklistsForScope", { scopeType: "event-task", scopeId: "task_1" }, member))[0].items[0].completed, true);
188
+
189
+ await rt.handleOperation(op("calendar.event.create", { scopeType: "event", scopeId: "event_1", title: "Doors", startsAt: 1900000000000, location: "Main hall" }, { id: "cal_1", pluginId: CALENDAR_PLUGIN_ID, actor: member }));
190
+ const upcoming = await rt.query(CALENDAR_PLUGIN_ID, "upcomingEvents", { now: 1800000000000 }, member);
191
+ assert.equal(upcoming[0].title, "Doors");
192
+ });
193
+
194
+ test("composer exposes content, workflow, project, and community presets", () => {
195
+ assert.equal(suite.hasPlugin(MARKDOWN_PLUGIN_ID), true);
196
+ assert.equal(suite.hasCapability("embed.youtube"), true);
197
+ assert.equal(suite.hasCapability("workflow.checklists"), true);
198
+ assert.deepEqual(createCollaborationPluginSuite("content").selection, ["markdown", "embeds", "files"]);
199
+ assert.deepEqual(createCollaborationPluginSuite("workflow").selection, ["labels", "approvals", "checklists", "calendar"]);
200
+ assert.equal(createCollaborationPluginSuite("community").hasPlugin("screenShare"), true);
201
+ });
@@ -0,0 +1,86 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ autoDetectEmbeds,
6
+ cleanUrlToken,
7
+ defineMarkdownPlugin,
8
+ extractUrlsFromText,
9
+ parseMarkdown,
10
+ pluginForDirective,
11
+ pluginForUrl,
12
+ renderMarkdownToPlainText,
13
+ resolveEmbed
14
+ } = require("../src/markdown/index.cjs");
15
+ const { createEmbedRecord } = require("../src/shared/index.cjs");
16
+
17
+ const widgetPlugin = defineMarkdownPlugin({
18
+ id: "com.matterhorn.embed.widget",
19
+ provider: "widget",
20
+ aliases: ["w"],
21
+ matchUrl(url) {
22
+ return new URL(url).hostname === "widgets.example.com";
23
+ },
24
+ toEmbed(url, ctx = {}) {
25
+ return createEmbedRecord({ provider: "widget", kind: "widget", title: ctx.title || "Widget", url, renderMode: "interactive", metadata: { directive: ctx.directive || null } });
26
+ }
27
+ });
28
+
29
+ test("markdown parser handles code fences, blockquotes, lists, headings, and plain text rendering", () => {
30
+ const parsed = parseMarkdown([
31
+ "# Launch Notes",
32
+ "",
33
+ "> Keep this scoped",
34
+ "> and readable",
35
+ "",
36
+ "- Item one",
37
+ "- Item two with https://youtu.be/dQw4w9WgXcQ",
38
+ "",
39
+ "```js",
40
+ "const safe = true;",
41
+ "```"
42
+ ].join("\n"));
43
+
44
+ assert.deepEqual(parsed.nodes.map((node) => node.type), ["heading", "blockquote", "list", "code"]);
45
+ assert.equal(parsed.nodes[0].depth, 1);
46
+ assert.equal(parsed.nodes[2].items.length, 2);
47
+ assert.equal(parsed.nodes[3].language, "js");
48
+ assert.equal(parsed.embeds[0].provider, "youtube");
49
+ assert.match(renderMarkdownToPlainText(parsed), /Launch Notes/);
50
+ assert.match(parsed.text, /const safe = true/);
51
+ });
52
+
53
+ test("markdown parser supports custom directive and URL plugins without global mutation", () => {
54
+ const parsed = parseMarkdown("::w[Widget demo](https://widgets.example.com/demo)", { plugins: [widgetPlugin] });
55
+
56
+ assert.equal(parsed.nodes[0].type, "embed");
57
+ assert.equal(parsed.nodes[0].provider, "widget");
58
+ assert.equal(parsed.embeds[0].renderMode, "interactive");
59
+ assert.equal(parsed.embeds[0].metadata.directive, "w");
60
+ assert.equal(pluginForDirective("w", [widgetPlugin]).provider, "widget");
61
+ assert.equal(pluginForUrl("https://widgets.example.com/demo", [widgetPlugin]).provider, "widget");
62
+ assert.throws(() => parseMarkdown("::unknown[Nope](https://example.com)", { plugins: [widgetPlugin] }), /Unknown markdown directive unknown/);
63
+ });
64
+
65
+ test("auto detection trims punctuation, balances trailing parentheses, dedupes URLs, and can suppress generic links", () => {
66
+ assert.equal(cleanUrlToken("https://example.com/a)."), "https://example.com/a");
67
+ assert.equal(cleanUrlToken("https://example.com/a_(b))"), "https://example.com/a_(b)");
68
+
69
+ const text = "Watch (https://vimeo.com/123456789), then https://vimeo.com/123456789 and https://plain.example/path.";
70
+ const urls = extractUrlsFromText(text).map((entry) => entry.url);
71
+ const embeds = autoDetectEmbeds(text);
72
+ const filtered = autoDetectEmbeds(text, { includeGeneric: false });
73
+
74
+ assert.deepEqual(urls, ["https://vimeo.com/123456789", "https://vimeo.com/123456789", "https://plain.example/path"]);
75
+ assert.deepEqual(embeds.map((embed) => embed.provider), ["vimeo", "link"]);
76
+ assert.deepEqual(filtered.map((embed) => embed.provider), ["vimeo"]);
77
+ });
78
+
79
+ test("embed resolution normalizes URLs and rejects insecure non-local URLs", () => {
80
+ const local = resolveEmbed("http://localhost:8080/demo");
81
+ assert.equal(local.provider, "link");
82
+ assert.equal(local.url, "http://localhost:8080/demo");
83
+
84
+ assert.throws(() => resolveEmbed("http://example.com/not-allowed"), /url must use https unless it is localhost/);
85
+ assert.equal(resolveEmbed("https://open.spotify.com/track/abc#fragment").url, "https://open.spotify.com/track/abc");
86
+ });
@@ -0,0 +1,69 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ const srcRoot = path.join(__dirname, "..", "src");
7
+
8
+ function sourceFiles(dir = srcRoot) {
9
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
10
+ const entryPath = path.join(dir, entry.name);
11
+ if (entry.isDirectory()) return sourceFiles(entryPath);
12
+ return entry.name.endsWith(".cjs") ? [entryPath] : [];
13
+ });
14
+ }
15
+
16
+ function lineCount(file) {
17
+ return fs.readFileSync(file, "utf8").split("\n").length;
18
+ }
19
+
20
+ test("example plugin package uses capability folders rather than generic catch-all files", () => {
21
+ for (const forbidden of ["plugins.cjs", "workflow.cjs", "markdown.cjs", "composer.cjs"]) {
22
+ assert.equal(fs.existsSync(path.join(srcRoot, forbidden)), false, `${forbidden} should not exist at src root`);
23
+ }
24
+
25
+ for (const folder of [
26
+ "comments",
27
+ "presence",
28
+ "media-rooms",
29
+ "screen-share",
30
+ "markdown",
31
+ "markdown/providers",
32
+ "embeds",
33
+ "attachments",
34
+ "files",
35
+ "reactions",
36
+ "labels",
37
+ "approvals",
38
+ "checklists",
39
+ "calendar",
40
+ "crdt",
41
+ "composer"
42
+ ]) {
43
+ assert.equal(fs.existsSync(path.join(srcRoot, folder, "index.cjs")), true, `${folder} should expose an index barrel`);
44
+ }
45
+ });
46
+
47
+ test("example plugin source files stay small enough to remain composable", () => {
48
+ const tooLarge = sourceFiles()
49
+ .map((file) => ({ file: path.relative(srcRoot, file), lines: lineCount(file) }))
50
+ .filter(({ file, lines }) => lines > 260 && file !== "shared/index.cjs");
51
+
52
+ assert.deepEqual(tooLarge, []);
53
+ });
54
+
55
+ test("root plugin barrel is only a thin export surface", () => {
56
+ assert.ok(lineCount(path.join(srcRoot, "index.cjs")) <= 30);
57
+ });
58
+
59
+
60
+ test("capability subpath exports load the same focused modules as the root barrel", () => {
61
+ const root = require("../src/index.cjs");
62
+ assert.equal(require("../src/comments/index.cjs").commentsHostPlugin, root.commentsHostPlugin);
63
+ assert.equal(require("../src/media-rooms/index.cjs").mediaRoomsHostPlugin, root.mediaRoomsHostPlugin);
64
+ assert.equal(require("../src/markdown/index.cjs").parseMarkdown, root.parseMarkdown);
65
+ assert.equal(require("../src/embeds/index.cjs").embedsHostPlugin, root.embedsHostPlugin);
66
+ assert.equal(require("../src/files/index.cjs").filesHostPlugin, root.filesHostPlugin);
67
+ assert.equal(require("../src/crdt/index.cjs").crdtHostPlugin, root.crdtHostPlugin);
68
+ assert.equal(require("../src/composer/index.cjs").createCollaborationPluginSuite, root.createCollaborationPluginSuite);
69
+ });