@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
package/src/index.cjs ADDED
@@ -0,0 +1,19 @@
1
+ module.exports = {
2
+ ...require("./shared/constants.cjs"),
3
+ ...require("./comments/index.cjs"),
4
+ ...require("./presence/index.cjs"),
5
+ ...require("./media-rooms/index.cjs"),
6
+ ...require("./screen-share/index.cjs"),
7
+ ...require("./markdown/index.cjs"),
8
+ ...require("./embeds/index.cjs"),
9
+ ...require("./attachments/index.cjs"),
10
+ ...require("./files/index.cjs"),
11
+ ...require("./reactions/index.cjs"),
12
+ ...require("./labels/index.cjs"),
13
+ ...require("./approvals/index.cjs"),
14
+ ...require("./checklists/index.cjs"),
15
+ ...require("./calendar/index.cjs"),
16
+ ...require("./location-pins/index.cjs"),
17
+ ...require("./crdt/index.cjs"),
18
+ ...require("./composer/index.cjs")
19
+ };
@@ -0,0 +1,46 @@
1
+ const { LABELS_PLUGIN_ID, LABELS_PLUGIN_KEY } = require("../shared/constants.cjs");
2
+ const {
3
+ allow,
4
+ activity,
5
+ actorName,
6
+ array,
7
+ createOperationSchemaDescriptor,
8
+ defineHostPlugin,
9
+ deny,
10
+ entityId,
11
+ enumValue,
12
+ memberOrBetter,
13
+ moderatorOrBetter,
14
+ object,
15
+ optionalString,
16
+ readonlyState,
17
+ scopeKey,
18
+ scopePayload,
19
+ string,
20
+ BASE_PLUGIN_VERSION
21
+ } = require("../shared/index.cjs");
22
+
23
+ function labelPayload(payload) { const value = object(payload, "label payload"); return { name: string(value.name, "name", 80), color: optionalString(value.color, "color", 40) || "neutral", description: optionalString(value.description, "description", 240) }; }
24
+ function labelApplyPayload(payload) { const value = object(payload, "label apply payload"); return { ...scopePayload(value), labelId: string(value.labelId, "labelId") }; }
25
+ const labelsHostPlugin = defineHostPlugin({
26
+ id: LABELS_PLUGIN_ID,
27
+ version: BASE_PLUGIN_VERSION,
28
+ meta: { name: "Reusable Labels And Tags" },
29
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["taxonomy.labels", "taxonomy.scoped-tags"] },
30
+ stateSchemaDescriptor: { plugin: LABELS_PLUGIN_ID, shape: ["labels", "assignments", "activity"] },
31
+ operationSchemaDescriptor: createOperationSchemaDescriptor(LABELS_PLUGIN_ID, BASE_PLUGIN_VERSION, { "label.create": { required: ["name"], optional: ["color", "description"], authorize: { roles: ["moderator"] } }, "label.apply": { required: ["scopeType", "scopeId", "labelId"], authorize: { roles: ["member"] } }, "label.remove": { required: ["scopeType", "scopeId", "labelId"], authorize: { roles: ["member"] } } }),
32
+ schemas: { state: { parse(value) { const state = object(value, "labels state"); object(state.labels, "labels"); object(state.assignments, "assignments"); array(state.activity, "activity"); return state; } }, operations: { "label.create": { parse: labelPayload }, "label.apply": { parse: labelApplyPayload }, "label.remove": { parse: labelApplyPayload } }, publicView: { parse: readonlyState }, queries: { labelsForScope: { parse: readonlyState }, labelDirectory: { parse: readonlyState } } },
33
+ async createInitialState() { return { labels: {}, assignments: {}, activity: [] }; },
34
+ authorize(_ctx, op) { if (op.type === "label.create") return moderatorOrBetter(op.actor) ? allow() : deny("Moderators only"); if (["label.apply", "label.remove"].includes(op.type)) return memberOrBetter(op.actor) ? allow() : deny("Members only"); return deny("Unsupported label operation"); },
35
+ async reduce(_ctx, state, op) {
36
+ if (op.type === "label.create") { const label = { id: entityId("label", op), ...op.payload, createdBy: op.actor.memberId, createdAt: op.createdAt }; return { ...state, labels: { ...state.labels, [label.id]: label }, activity: activity(state, op, `${actorName(op.actor)} created label ${label.name}`) }; }
37
+ const label = state.labels[op.payload.labelId]; if (!label) throw new Error(`Label ${op.payload.labelId} not found`);
38
+ const key = scopeKey(op.payload.scopeType, op.payload.scopeId); const current = new Set(state.assignments[key] || []);
39
+ if (op.type === "label.apply") current.add(label.id); else current.delete(label.id);
40
+ return { ...state, assignments: { ...state.assignments, [key]: [...current].sort() }, activity: activity(state, op, `${actorName(op.actor)} ${op.type === "label.apply" ? "applied" : "removed"} ${label.name}`) };
41
+ },
42
+ getPublicView(_ctx, state) { return state; },
43
+ queries: { labelDirectory(_ctx, state) { return Object.values(state.labels).sort((a, b) => a.name.localeCompare(b.name)); }, labelsForScope(_ctx, state, input = {}) { const scoped = scopePayload(input, "labelsForScope input"); const ids = state.assignments[scopeKey(scoped.scopeType, scoped.scopeId)] || []; return ids.map((id) => state.labels[id]).filter(Boolean); } }
44
+ });
45
+
46
+ module.exports = { LABELS_PLUGIN_ID, LABELS_PLUGIN_KEY, labelsHostPlugin };
@@ -0,0 +1,142 @@
1
+ const { LOCATION_PINS_PLUGIN_ID, LOCATION_PINS_PLUGIN_KEY } = require("../shared/constants.cjs");
2
+ const {
3
+ BASE_PLUGIN_VERSION,
4
+ activity,
5
+ actorName,
6
+ adminOrOwner,
7
+ allow,
8
+ array,
9
+ createOperationSchemaDescriptor,
10
+ defineHostPlugin,
11
+ deny,
12
+ memberOrBetter,
13
+ number,
14
+ object,
15
+ optionalString,
16
+ readonlyState,
17
+ scopeKey,
18
+ scopePayload,
19
+ string
20
+ } = require("../shared/index.cjs");
21
+
22
+ function clampNumber(value, min, max) {
23
+ return Math.max(min, Math.min(max, value));
24
+ }
25
+
26
+ function wrapLongitude(value) {
27
+ return ((((value + 180) % 360) + 360) % 360) - 180;
28
+ }
29
+
30
+ function normalizePin(value) {
31
+ const lat = number(value.lat, "lat");
32
+ const lng = number(value.lng, "lng");
33
+ const zoom = value.zoom === undefined ? 13 : number(value.zoom, "zoom");
34
+ return {
35
+ lat: clampNumber(lat, -85.05112878, 85.05112878),
36
+ lng: wrapLongitude(lng),
37
+ zoom: Math.round(clampNumber(zoom, 2, 18))
38
+ };
39
+ }
40
+
41
+ function locationPinSetPayload(payload) {
42
+ const value = object(payload, "location.pin.set payload");
43
+ return {
44
+ ...scopePayload(value),
45
+ ...normalizePin(value),
46
+ label: optionalString(value.label, "label", 180),
47
+ address: optionalString(value.address, "address", 300)
48
+ };
49
+ }
50
+
51
+ function locationPinClearPayload(payload) {
52
+ const value = object(payload, "location.pin.clear payload");
53
+ return scopePayload(value);
54
+ }
55
+
56
+ function locationPinsForScope(_ctx, state, input = {}) {
57
+ const scoped = scopePayload(input, "locationPinsForScope input");
58
+ return Object.values(state.pins)
59
+ .filter((pin) => !pin.removedAt && pin.scopeType === scoped.scopeType && pin.scopeId === scoped.scopeId)
60
+ .sort((left, right) => right.updatedAt - left.updatedAt);
61
+ }
62
+
63
+ function pinId(payload) {
64
+ return `pin_${scopeKey(payload.scopeType, payload.scopeId).replace(/[^A-Za-z0-9_-]/g, "_")}`;
65
+ }
66
+
67
+ const locationPinsHostPlugin = defineHostPlugin({
68
+ id: LOCATION_PINS_PLUGIN_ID,
69
+ version: BASE_PLUGIN_VERSION,
70
+ meta: { name: "Reusable Location Pins" },
71
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["location.pins", "location.coordinates"] },
72
+ stateSchemaDescriptor: { plugin: LOCATION_PINS_PLUGIN_ID, shape: ["pins", "activity"] },
73
+ operationSchemaDescriptor: createOperationSchemaDescriptor(LOCATION_PINS_PLUGIN_ID, BASE_PLUGIN_VERSION, {
74
+ "location.pin.set": {
75
+ required: { scopeType: { type: "string" }, scopeId: { type: "string" }, lat: { type: "number" }, lng: { type: "number" } },
76
+ optional: { zoom: { type: "number" }, label: { type: "string" }, address: { type: "string" } },
77
+ authorize: { roles: ["member"] }
78
+ },
79
+ "location.pin.clear": { required: { scopeType: { type: "string" }, scopeId: { type: "string" } }, authorize: { roles: ["admin"] } }
80
+ }),
81
+ schemas: {
82
+ state: { parse(value) { const state = object(value, "location pins state"); object(state.pins, "pins"); array(state.activity, "activity"); return state; } },
83
+ operations: {
84
+ "location.pin.set": { parse: locationPinSetPayload },
85
+ "location.pin.clear": { parse: locationPinClearPayload }
86
+ },
87
+ publicView: { parse: readonlyState },
88
+ queries: { locationPinsForScope: { parse: readonlyState } }
89
+ },
90
+ async createInitialState() { return { pins: {}, activity: [] }; },
91
+ authorize(ctx, op) {
92
+ if (op.type === "location.pin.set") {
93
+ if (!memberOrBetter(op.actor)) return deny("Members only");
94
+ return ctx.access.canEdit(op.payload.scopeType, op.payload.scopeId) ? allow() : deny("Scoped edit access required");
95
+ }
96
+ if (op.type === "location.pin.clear") return adminOrOwner(op.actor) ? allow() : deny("Admins only");
97
+ return deny("Unsupported location pin operation");
98
+ },
99
+ async reduce(_ctx, state, op) {
100
+ const id = pinId(op.payload);
101
+ if (op.type === "location.pin.clear") {
102
+ const existing = state.pins[id];
103
+ if (!existing || existing.removedAt) return state;
104
+ const nextPin = { ...existing, removedAt: op.createdAt, removedBy: op.actor.memberId };
105
+ return {
106
+ ...state,
107
+ pins: { ...state.pins, [id]: nextPin },
108
+ activity: activity(state, op, `${actorName(op.actor)} cleared ${existing.label || "a location pin"}`)
109
+ };
110
+ }
111
+ const pin = {
112
+ id,
113
+ scopeType: op.payload.scopeType,
114
+ scopeId: op.payload.scopeId,
115
+ scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId),
116
+ lat: op.payload.lat,
117
+ lng: op.payload.lng,
118
+ zoom: op.payload.zoom,
119
+ label: op.payload.label,
120
+ address: op.payload.address,
121
+ updatedBy: op.actor.memberId,
122
+ updatedByName: actorName(op.actor),
123
+ updatedAt: op.createdAt
124
+ };
125
+ return {
126
+ ...state,
127
+ pins: { ...state.pins, [pin.id]: pin },
128
+ activity: activity(state, op, `${actorName(op.actor)} pinned ${pin.label || pin.scopeId}`)
129
+ };
130
+ },
131
+ getPublicView(ctx, state) {
132
+ return { ...state, pins: Object.fromEntries(Object.entries(state.pins).filter(([, pin]) => !pin.removedAt && ctx.access.canView(pin.scopeType, pin.scopeId))) };
133
+ },
134
+ queries: { locationPinsForScope }
135
+ });
136
+
137
+ module.exports = {
138
+ LOCATION_PINS_PLUGIN_ID,
139
+ LOCATION_PINS_PLUGIN_KEY,
140
+ locationPinsHostPlugin,
141
+ normalizePin
142
+ };
@@ -0,0 +1,128 @@
1
+ const {
2
+ allow,
3
+ activity,
4
+ actorName,
5
+ array,
6
+ createOperationSchemaDescriptor,
7
+ defineHostPlugin,
8
+ deny,
9
+ entityId,
10
+ enumValue,
11
+ object,
12
+ parseMaybeJsonList,
13
+ readonlyState,
14
+ scopeKey,
15
+ scopePayload,
16
+ string,
17
+ memberOrBetter,
18
+ moderatorOrBetter,
19
+ BASE_PLUGIN_VERSION,
20
+ MARKDOWN_PLUGIN_ID,
21
+ MAX_MARKDOWN_BYTES
22
+ } = require("../../shared/index.cjs");
23
+ const { parseMarkdown } = require("../parser/index.cjs");
24
+
25
+ function parseMarkdownDocumentState(state) {
26
+ const value = object(state, "markdown state");
27
+ object(value.documents, "documents");
28
+ array(value.activity, "activity");
29
+ return value;
30
+ }
31
+ function markdownUpsertPayload(payload) {
32
+ const value = object(payload, "markdown.document.upsert payload");
33
+ return {
34
+ ...scopePayload(value, "markdown scope"),
35
+ title: string(value.title, "title", 180),
36
+ markdown: string(value.markdown, "markdown", MAX_MARKDOWN_BYTES),
37
+ visibility: enumValue(value.visibility || "members", "visibility", ["public", "members", "private"]),
38
+ tags: parseMaybeJsonList(value.tags, "tags")
39
+ };
40
+ }
41
+ function markdownDeletePayload(payload) {
42
+ const value = object(payload, "markdown.document.delete payload");
43
+ return { documentId: string(value.documentId, "documentId") };
44
+ }
45
+ const markdownStateSchemaDescriptor = { plugin: MARKDOWN_PLUGIN_ID, shape: ["documents", "activity"] };
46
+ const markdownOperationSchemaDescriptor = {
47
+ "markdown.document.upsert": { required: ["scopeType", "scopeId", "title", "markdown"], optional: ["visibility", "tags"], authorize: { roles: ["member"] } },
48
+ "markdown.document.delete": { required: ["documentId"], authorize: { roles: ["moderator"] } }
49
+ };
50
+ const markdownHostPlugin = defineHostPlugin({
51
+ id: MARKDOWN_PLUGIN_ID,
52
+ version: BASE_PLUGIN_VERSION,
53
+ meta: { name: "Reusable Markdown Documents" },
54
+ capabilities: { requires: ["room.state", "room.roles"], provides: ["content.markdown", "content.markdown.embeds", "content.sanitized-ast"] },
55
+ stateSchemaDescriptor: markdownStateSchemaDescriptor,
56
+ operationSchemaDescriptor: createOperationSchemaDescriptor(MARKDOWN_PLUGIN_ID, BASE_PLUGIN_VERSION, markdownOperationSchemaDescriptor),
57
+ schemas: {
58
+ state: { parse: parseMarkdownDocumentState },
59
+ operations: { "markdown.document.upsert": { parse: markdownUpsertPayload }, "markdown.document.delete": { parse: markdownDeletePayload } },
60
+ publicView: { parse: readonlyState },
61
+ queries: { documentForScope: { parse: readonlyState }, searchDocuments: { parse: readonlyState } }
62
+ },
63
+ async createInitialState() { return { documents: {}, activity: [] }; },
64
+ authorize(_ctx, op) {
65
+ if (op.type === "markdown.document.upsert") return memberOrBetter(op.actor) ? allow() : deny("Members only");
66
+ if (op.type === "markdown.document.delete") return moderatorOrBetter(op.actor) ? allow() : deny("Moderators only");
67
+ return deny("Unsupported markdown operation");
68
+ },
69
+ async reduce(_ctx, state, op) {
70
+ if (op.type === "markdown.document.upsert") {
71
+ const existing = Object.values(state.documents).find((doc) => doc.scopeType === op.payload.scopeType && doc.scopeId === op.payload.scopeId && !doc.deletedAt);
72
+ const id = existing?.id || entityId("md", op);
73
+ const parsed = parseMarkdown(op.payload.markdown);
74
+ const doc = {
75
+ id,
76
+ scopeType: op.payload.scopeType,
77
+ scopeId: op.payload.scopeId,
78
+ scopeKey: scopeKey(op.payload.scopeType, op.payload.scopeId),
79
+ title: op.payload.title,
80
+ markdown: op.payload.markdown,
81
+ ast: parsed,
82
+ embeds: parsed.embeds,
83
+ text: parsed.text,
84
+ visibility: op.payload.visibility,
85
+ tags: op.payload.tags,
86
+ updatedBy: op.actor.memberId,
87
+ updatedByName: actorName(op.actor),
88
+ updatedAt: op.createdAt,
89
+ createdAt: existing?.createdAt || op.createdAt,
90
+ createdBy: existing?.createdBy || op.actor.memberId
91
+ };
92
+ return { ...state, documents: { ...state.documents, [id]: doc }, activity: activity(state, op, `${actorName(op.actor)} updated ${doc.title}`, { documentId: id }) };
93
+ }
94
+ if (op.type === "markdown.document.delete") {
95
+ const doc = state.documents[op.payload.documentId];
96
+ if (!doc || doc.deletedAt) throw new Error(`Document ${op.payload.documentId} not found`);
97
+ return { ...state, documents: { ...state.documents, [doc.id]: { ...doc, deletedAt: op.createdAt, deletedBy: op.actor.memberId } }, activity: activity(state, op, `${actorName(op.actor)} deleted ${doc.title}`) };
98
+ }
99
+ return state;
100
+ },
101
+ getPublicView(_ctx, state) {
102
+ return { ...state, documents: Object.fromEntries(Object.entries(state.documents).filter(([, doc]) => !doc.deletedAt && doc.visibility !== "private")) };
103
+ },
104
+ queries: {
105
+ documentForScope(_ctx, state, input = {}) {
106
+ const scoped = scopePayload(input, "documentForScope input");
107
+ return Object.values(state.documents).find((doc) => doc.scopeType === scoped.scopeType && doc.scopeId === scoped.scopeId && !doc.deletedAt) || null;
108
+ },
109
+ searchDocuments(_ctx, state, input = {}) {
110
+ const q = String(input.query || "").toLowerCase().trim();
111
+ return Object.values(state.documents)
112
+ .filter((doc) => !doc.deletedAt && (!q || doc.title.toLowerCase().includes(q) || doc.text.toLowerCase().includes(q) || doc.tags.some((tag) => tag.toLowerCase().includes(q))))
113
+ .sort((a, b) => b.updatedAt - a.updatedAt)
114
+ .slice(0, 25)
115
+ .map((doc) => ({ id: doc.id, title: doc.title, scopeType: doc.scopeType, scopeId: doc.scopeId, tags: doc.tags, updatedAt: doc.updatedAt, embedCount: doc.embeds.length }));
116
+ }
117
+ },
118
+ methods: {
119
+ parse(_ctx, input = {}) { return parseMarkdown(string(object(input, "parse input").markdown, "markdown", MAX_MARKDOWN_BYTES)); }
120
+ }
121
+ });
122
+
123
+ module.exports = {
124
+ MARKDOWN_PLUGIN_ID,
125
+ markdownHostPlugin,
126
+ markdownOperationSchemaDescriptor,
127
+ markdownStateSchemaDescriptor
128
+ };
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ defineMarkdownPlugin: require("../shared/index.cjs").defineMarkdownPlugin,
3
+ createEmbedRecord: require("../shared/index.cjs").createEmbedRecord,
4
+ ...require("./providers/index.cjs"),
5
+ ...require("./resolve.cjs"),
6
+ ...require("./parser/index.cjs"),
7
+ ...require("./documents/index.cjs")
8
+ };
@@ -0,0 +1,127 @@
1
+ const { MAX_MARKDOWN_BYTES, normalizeUrl } = require("../../shared/index.cjs");
2
+ const { DEFAULT_MARKDOWN_PLUGINS } = require("../providers/index.cjs");
3
+ const { cleanUrlToken, pluginForDirective, resolveEmbed } = require("../resolve.cjs");
4
+
5
+ function tokeniseInline(text, options = {}) {
6
+ const tokens = [];
7
+ const embeds = [];
8
+ const pattern = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>)]+)/g;
9
+ let cursor = 0;
10
+ for (const match of text.matchAll(pattern)) {
11
+ if (match.index > cursor) tokens.push({ type: "text", value: text.slice(cursor, match.index) });
12
+ const label = match[1] || match[3];
13
+ const url = cleanUrlToken(match[2] || match[3]);
14
+ let embed = null;
15
+ try {
16
+ embed = resolveEmbed(url, { ...options, title: label });
17
+ embeds.push(embed);
18
+ } catch {
19
+ // Links remain valid text links even when embed resolution fails.
20
+ }
21
+ tokens.push({ type: "link", text: label, url: normalizeUrl(url), ...(embed ? { embed } : {}) });
22
+ cursor = match.index + match[0].length;
23
+ }
24
+ if (cursor < text.length) tokens.push({ type: "text", value: text.slice(cursor) });
25
+ return { tokens, embeds };
26
+ }
27
+
28
+ function parseMarkdown(markdown, options = {}) {
29
+ if (typeof markdown !== "string") throw new Error("markdown must be a string");
30
+ if (new TextEncoder().encode(markdown).length > (options.maxBytes || MAX_MARKDOWN_BYTES)) throw new Error("markdown is too large");
31
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
32
+ const nodes = [];
33
+ const embeds = [];
34
+ let index = 0;
35
+ function pushInlineNode(type, text, extra = {}) {
36
+ const inline = tokeniseInline(text, options);
37
+ embeds.push(...inline.embeds);
38
+ nodes.push({ type, text, children: inline.tokens, ...extra });
39
+ }
40
+ while (index < lines.length) {
41
+ const line = lines[index];
42
+ if (!line.trim()) { index += 1; continue; }
43
+ const fence = line.match(/^```\s*([A-Za-z0-9_-]+)?\s*$/);
44
+ if (fence) {
45
+ const language = fence[1] || "text";
46
+ index += 1;
47
+ const code = [];
48
+ while (index < lines.length && !/^```\s*$/.test(lines[index])) { code.push(lines[index]); index += 1; }
49
+ if (index < lines.length) index += 1;
50
+ nodes.push({ type: "code", language, value: code.join("\n") });
51
+ continue;
52
+ }
53
+ const directive = line.match(/^::([A-Za-z0-9_-]+)(?:\[([^\]]*)\])?\((https?:\/\/[^\s)]+)\)\s*$/);
54
+ if (directive) {
55
+ const [, provider, title, url] = directive;
56
+ const plugin = pluginForDirective(provider, options.plugins || DEFAULT_MARKDOWN_PLUGINS);
57
+ if (!plugin) throw new Error(`Unknown markdown directive ${provider}`);
58
+ const embed = { ...plugin.toEmbed(normalizeUrl(url), { title: title || undefined, directive: provider }), pluginId: plugin.id };
59
+ embeds.push(embed);
60
+ nodes.push({ type: "embed", provider: embed.provider, title: embed.title, url: embed.url, embed });
61
+ index += 1;
62
+ continue;
63
+ }
64
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
65
+ if (heading) {
66
+ pushInlineNode("heading", heading[2].trim(), { depth: heading[1].length });
67
+ index += 1;
68
+ continue;
69
+ }
70
+ const quote = line.match(/^>\s?(.*)$/);
71
+ if (quote) {
72
+ const quoteLines = [quote[1]];
73
+ index += 1;
74
+ while (index < lines.length) {
75
+ const next = lines[index].match(/^>\s?(.*)$/);
76
+ if (!next) break;
77
+ quoteLines.push(next[1]);
78
+ index += 1;
79
+ }
80
+ pushInlineNode("blockquote", quoteLines.join("\n"));
81
+ continue;
82
+ }
83
+ if (/^[-*]\s+/.test(line)) {
84
+ const items = [];
85
+ while (index < lines.length && /^[-*]\s+/.test(lines[index])) {
86
+ const text = lines[index].replace(/^[-*]\s+/, "").trim();
87
+ const inline = tokeniseInline(text, options);
88
+ embeds.push(...inline.embeds);
89
+ items.push({ text, children: inline.tokens });
90
+ index += 1;
91
+ }
92
+ nodes.push({ type: "list", ordered: false, items });
93
+ continue;
94
+ }
95
+ const paragraph = [line.trim()];
96
+ index += 1;
97
+ while (index < lines.length && lines[index].trim() && !/^(#{1,6})\s+/.test(lines[index]) && !/^::[A-Za-z0-9_-]+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index]) && !/^>/.test(lines[index]) && !/^```/.test(lines[index])) {
98
+ paragraph.push(lines[index].trim());
99
+ index += 1;
100
+ }
101
+ pushInlineNode("paragraph", paragraph.join(" "));
102
+ }
103
+ return {
104
+ kind: "matterhorn.markdown-document",
105
+ version: 1,
106
+ nodes,
107
+ embeds,
108
+ text: renderMarkdownToPlainText({ nodes }),
109
+ embedCount: embeds.length
110
+ };
111
+ }
112
+
113
+ function renderMarkdownToPlainText(parsed) {
114
+ const nodes = parsed?.nodes || [];
115
+ return nodes.map((node) => {
116
+ if (node.type === "code") return node.value;
117
+ if (node.type === "list") return node.items.map((item) => `- ${item.text}`).join("\n");
118
+ if (node.type === "embed") return `[${node.provider}] ${node.title || node.url}`;
119
+ return node.text || "";
120
+ }).filter(Boolean).join("\n").trim();
121
+ }
122
+
123
+ module.exports = {
124
+ parseMarkdown,
125
+ renderMarkdownToPlainText,
126
+ tokeniseInline
127
+ };
@@ -0,0 +1,77 @@
1
+ const {
2
+ cleanId,
3
+ createEmbedRecord,
4
+ defineMarkdownPlugin,
5
+ directRenderMode,
6
+ firstMatch,
7
+ hostEndsWith,
8
+ hostIs,
9
+ mediaKindFromPath,
10
+ normalizeUrl,
11
+ pathParts,
12
+ safeHostname,
13
+ simpleProvider
14
+ } = require("../../shared/index.cjs");
15
+
16
+ const spotifyMarkdownPlugin = defineMarkdownPlugin({
17
+ id: "markdown.embed.spotify",
18
+ provider: "spotify",
19
+ aliases: ["spotify"],
20
+ matchUrl(url) { return safeHostname(url) === "open.spotify.com"; },
21
+ toEmbed(url, ctx = {}) {
22
+ const parsed = new URL(url);
23
+ const parts = parsed.pathname.split("/").filter(Boolean);
24
+ const type = parts[0];
25
+ const id = parts[1];
26
+ const allowed = ["album", "artist", "episode", "playlist", "show", "track"];
27
+ if (!allowed.includes(type) || !id) throw new Error("Spotify URL must point to an album, artist, episode, playlist, show, or track");
28
+ return createEmbedRecord({
29
+ provider: "spotify",
30
+ kind: type === "episode" || type === "show" ? "audio-show" : "audio",
31
+ title: ctx.title || `Spotify ${type}`,
32
+ url,
33
+ embedUrl: `https://open.spotify.com/embed/${type}/${encodeURIComponent(id)}`,
34
+ renderMode: "iframe",
35
+ metadata: { spotifyType: type, spotifyId: id }
36
+ });
37
+ }
38
+ });
39
+
40
+ const soundcloudMarkdownPlugin = defineMarkdownPlugin({
41
+ id: "markdown.embed.soundcloud",
42
+ provider: "soundcloud",
43
+ aliases: ["soundcloud"],
44
+ matchUrl(url) { return hostIs(url, ["soundcloud.com"]); },
45
+ toEmbed(url, ctx = {}) { return createEmbedRecord({ provider: "soundcloud", kind: "audio", title: ctx.title || "SoundCloud track", url, embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(normalizeUrl(url))}`, renderMode: "iframe", metadata: { host: safeHostname(url) } }); }
46
+ });
47
+
48
+ const bandcampMarkdownPlugin = simpleProvider({
49
+ id: "markdown.embed.bandcamp",
50
+ provider: "bandcamp",
51
+ aliases: ["bandcamp"],
52
+ hostSuffixes: ["bandcamp.com"],
53
+ kind: "audio",
54
+ title: "Bandcamp item",
55
+ renderMode: "external-link",
56
+ metadata: (parsed) => ({ artistHost: parsed.hostname.toLowerCase() })
57
+ });
58
+
59
+ const appleMusicMarkdownPlugin = simpleProvider({
60
+ id: "markdown.embed.apple-music",
61
+ provider: "apple-music",
62
+ aliases: ["apple-music", "music"],
63
+ hosts: ["music.apple.com"],
64
+ kind: "audio",
65
+ title: "Apple Music item",
66
+ renderMode: "external-link"
67
+ });
68
+
69
+ const clypMarkdownPlugin = simpleProvider({ id: "markdown.embed.clyp", provider: "clyp", aliases: ["clyp"], hostSuffixes: ["clyp.it"], kind: "audio", title: "Clyp audio", renderMode: "external-link" });
70
+
71
+ module.exports = {
72
+ appleMusicMarkdownPlugin,
73
+ bandcampMarkdownPlugin,
74
+ clypMarkdownPlugin,
75
+ soundcloudMarkdownPlugin,
76
+ spotifyMarkdownPlugin
77
+ };
@@ -0,0 +1,72 @@
1
+ const {
2
+ cleanId,
3
+ createEmbedRecord,
4
+ defineMarkdownPlugin,
5
+ directRenderMode,
6
+ firstMatch,
7
+ hostEndsWith,
8
+ hostIs,
9
+ mediaKindFromPath,
10
+ normalizeUrl,
11
+ pathParts,
12
+ safeHostname,
13
+ simpleProvider
14
+ } = require("../../shared/index.cjs");
15
+
16
+ const googleDriveMarkdownPlugin = defineMarkdownPlugin({
17
+ id: "markdown.embed.google-drive",
18
+ provider: "google-drive",
19
+ aliases: ["drive", "gdrive", "google-drive", "docs", "sheets", "slides"],
20
+ matchUrl(url) { const host = safeHostname(url); return host === "drive.google.com" || host === "docs.google.com"; },
21
+ toEmbed(url, ctx = {}) {
22
+ const parsed = new URL(url);
23
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
24
+ const fileId = firstMatch([/\/file\/d\/([^/]+)/, /[?&]id=([^&]+)/], `${parsed.pathname}${parsed.search}`)?.[1];
25
+ const documentId = firstMatch([/\/document\/d\/([^/]+)/, /\/spreadsheets\/d\/([^/]+)/, /\/presentation\/d\/([^/]+)/], parsed.pathname)?.[1];
26
+ const docKind = parsed.pathname.includes("/spreadsheets/") ? "spreadsheet" : parsed.pathname.includes("/presentation/") ? "presentation" : "document";
27
+ const embedUrl = fileId
28
+ ? `https://drive.google.com/file/d/${encodeURIComponent(fileId)}/preview`
29
+ : documentId
30
+ ? `https://${host}${parsed.pathname.replace(/\/(edit|view).*$/, "/preview")}`
31
+ : undefined;
32
+ return createEmbedRecord({
33
+ provider: "google-drive",
34
+ kind: fileId ? "file" : docKind,
35
+ title: ctx.title || "Google Drive item",
36
+ url,
37
+ ...(embedUrl ? { embedUrl } : {}),
38
+ renderMode: embedUrl ? "iframe" : "external-link",
39
+ metadata: { fileId: fileId || null, documentId: documentId || null, docKind }
40
+ });
41
+ }
42
+ });
43
+
44
+ const googlePhotosMarkdownPlugin = defineMarkdownPlugin({
45
+ id: "markdown.embed.google-photos",
46
+ provider: "google-photos",
47
+ aliases: ["gphotos", "google-photos", "photos"],
48
+ matchUrl(url) { const host = safeHostname(url); return host === "photos.app.goo.gl" || host === "photos.google.com"; },
49
+ toEmbed(url, ctx = {}) { return createEmbedRecord({ provider: "google-photos", kind: "photo-album", title: ctx.title || "Google Photos album", url, renderMode: "external-link", metadata: { host: safeHostname(url) } }); }
50
+ });
51
+
52
+ const dropboxMarkdownPlugin = defineMarkdownPlugin({
53
+ id: "markdown.embed.dropbox",
54
+ provider: "dropbox",
55
+ aliases: ["dropbox", "dbx"],
56
+ matchUrl(url) { const host = safeHostname(url); return host === "dropbox.com" || host === "dl.dropboxusercontent.com"; },
57
+ toEmbed(url, ctx = {}) {
58
+ const parsed = new URL(url);
59
+ parsed.searchParams.delete("dl");
60
+ parsed.searchParams.set("raw", "1");
61
+ return createEmbedRecord({ provider: "dropbox", kind: mediaKindFromPath(parsed.pathname), title: ctx.title || "Dropbox file", url, externalUrl: url, embedUrl: parsed.toString(), renderMode: "file-preview", metadata: { host: safeHostname(url) } });
62
+ }
63
+ });
64
+
65
+ const onedriveMarkdownPlugin = simpleProvider({ id: "markdown.embed.onedrive", provider: "onedrive", aliases: ["onedrive", "1drv"], hosts: ["onedrive.live.com", "1drv.ms"], kind: "file", title: "OneDrive item", renderMode: "external-link" });
66
+
67
+ module.exports = {
68
+ dropboxMarkdownPlugin,
69
+ googleDriveMarkdownPlugin,
70
+ googlePhotosMarkdownPlugin,
71
+ onedriveMarkdownPlugin
72
+ };
@@ -0,0 +1,45 @@
1
+ const {
2
+ cleanId,
3
+ createEmbedRecord,
4
+ defineMarkdownPlugin,
5
+ directRenderMode,
6
+ firstMatch,
7
+ hostEndsWith,
8
+ hostIs,
9
+ mediaKindFromPath,
10
+ normalizeUrl,
11
+ pathParts,
12
+ safeHostname,
13
+ simpleProvider
14
+ } = require("../../shared/index.cjs");
15
+
16
+ const codepenMarkdownPlugin = defineMarkdownPlugin({
17
+ id: "markdown.embed.codepen",
18
+ provider: "codepen",
19
+ aliases: ["codepen", "pen"],
20
+ matchUrl(url) { return hostIs(url, ["codepen.io"]) && pathParts(url)[1] === "pen"; },
21
+ toEmbed(url, ctx = {}) { const parts = pathParts(url); return createEmbedRecord({ provider: "codepen", kind: "code-demo", title: ctx.title || "CodePen", url, embedUrl: `https://codepen.io/${encodeURIComponent(parts[0])}/embed/${encodeURIComponent(parts[2])}`, renderMode: "iframe", metadata: { user: parts[0], penId: parts[2] } }); }
22
+ });
23
+ const jsfiddleMarkdownPlugin = defineMarkdownPlugin({
24
+ id: "markdown.embed.jsfiddle",
25
+ provider: "jsfiddle",
26
+ aliases: ["jsfiddle", "fiddle"],
27
+ matchUrl(url) { return hostIs(url, ["jsfiddle.net"]); },
28
+ toEmbed(url, ctx = {}) { const parsed = new URL(url); const embedPath = parsed.pathname.replace(/\/$/, "") + "/embedded/"; return createEmbedRecord({ provider: "jsfiddle", kind: "code-demo", title: ctx.title || "JSFiddle", url, embedUrl: `https://jsfiddle.net${embedPath}`, renderMode: "iframe", metadata: { path: parsed.pathname } }); }
29
+ });
30
+ const githubMarkdownPlugin = simpleProvider({ id: "markdown.embed.github", provider: "github", aliases: ["github", "gh"], hosts: ["github.com"], kind: (parsed) => parsed.pathname.includes("/pull/") ? "pull-request" : parsed.pathname.includes("/issues/") ? "issue" : parsed.pathname.includes("/blob/") ? "code-file" : "repository", title: "GitHub link", renderMode: "external-link" });
31
+ const gistMarkdownPlugin = simpleProvider({ id: "markdown.embed.gist", provider: "gist", aliases: ["gist"], hosts: ["gist.github.com"], kind: "code-snippet", title: "GitHub Gist", renderMode: "external-link" });
32
+ const pastebinMarkdownPlugin = defineMarkdownPlugin({ id: "markdown.embed.pastebin", provider: "pastebin", aliases: ["pastebin"], matchUrl(url) { return hostIs(url, ["pastebin.com"]) && pathParts(url)[0]; }, toEmbed(url, ctx = {}) { const id = pathParts(url)[0]; return createEmbedRecord({ provider: "pastebin", kind: "code-snippet", title: ctx.title || "Pastebin", url, embedUrl: `https://pastebin.com/embed_iframe/${cleanId(id)}`, renderMode: "iframe", metadata: { pasteId: id } }); } });
33
+ const hastebinMarkdownPlugin = simpleProvider({ id: "markdown.embed.hastebin", provider: "hastebin", aliases: ["hastebin"], hostSuffixes: ["hastebin.com", "hastebin.app"], kind: "code-snippet", title: "Hastebin", renderMode: "external-link" });
34
+
35
+ const wikipediaMarkdownPlugin = simpleProvider({ id: "markdown.embed.wikipedia", provider: "wikipedia", aliases: ["wikipedia", "wiki"], hostSuffixes: ["wikipedia.org", "wikimedia.org"], kind: "article", title: (parsed) => pathParts(parsed.toString()).at(-1)?.replace(/_/g, " ") || "Wikipedia article", renderMode: "external-link" });
36
+
37
+ module.exports = {
38
+ codepenMarkdownPlugin,
39
+ gistMarkdownPlugin,
40
+ githubMarkdownPlugin,
41
+ hastebinMarkdownPlugin,
42
+ jsfiddleMarkdownPlugin,
43
+ pastebinMarkdownPlugin,
44
+ wikipediaMarkdownPlugin
45
+ };