@dxos/plugin-crx 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +105 -0
  2. package/PLUGIN.mdl +599 -0
  3. package/README.md +25 -0
  4. package/dist/lib/CrxCapabilities.mjs +21 -0
  5. package/dist/lib/CrxCapabilities.mjs.map +1 -0
  6. package/dist/lib/CrxEvents.mjs +15 -0
  7. package/dist/lib/CrxEvents.mjs.map +1 -0
  8. package/dist/lib/CrxOperation.mjs +50 -0
  9. package/dist/lib/CrxOperation.mjs.map +1 -0
  10. package/dist/lib/CrxPlugin.mjs +16 -0
  11. package/dist/lib/CrxPlugin.mjs.map +1 -0
  12. package/dist/lib/PageAction.mjs +35 -0
  13. package/dist/lib/PageAction.mjs.map +1 -0
  14. package/dist/lib/Settings.mjs +84 -0
  15. package/dist/lib/Settings.mjs.map +1 -0
  16. package/dist/lib/capabilities.mjs +41 -0
  17. package/dist/lib/capabilities.mjs.map +1 -0
  18. package/dist/lib/capabilities.node.mjs +32 -0
  19. package/dist/lib/capabilities.node.mjs.map +1 -0
  20. package/dist/lib/capabilities.workerd.mjs +32 -0
  21. package/dist/lib/capabilities.workerd.mjs.map +1 -0
  22. package/dist/lib/chunk-add-note-from-snapshot.mjs +15 -0
  23. package/dist/lib/chunk-add-note-from-snapshot.mjs.map +1 -0
  24. package/dist/lib/chunk-add-organization-from-snapshot.mjs +15 -0
  25. package/dist/lib/chunk-add-organization-from-snapshot.mjs.map +1 -0
  26. package/dist/lib/chunk-add-person-from-snapshot.mjs +15 -0
  27. package/dist/lib/chunk-add-person-from-snapshot.mjs.map +1 -0
  28. package/dist/lib/chunk-install-page-actions.mjs +284 -0
  29. package/dist/lib/chunk-install-page-actions.mjs.map +1 -0
  30. package/dist/lib/chunk-mapping.mjs +81 -0
  31. package/dist/lib/chunk-mapping.mjs.map +1 -0
  32. package/dist/lib/chunk-operation-handler.mjs +12 -0
  33. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  34. package/dist/lib/chunk-page-action-provider.mjs +41 -0
  35. package/dist/lib/chunk-page-action-provider.mjs.map +1 -0
  36. package/dist/lib/chunk-react-surface.mjs +18 -0
  37. package/dist/lib/chunk-react-surface.mjs.map +1 -0
  38. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  39. package/dist/lib/chunk-settings.mjs +28 -0
  40. package/dist/lib/chunk-settings.mjs.map +1 -0
  41. package/dist/lib/containers.mjs +80 -0
  42. package/dist/lib/containers.mjs.map +1 -0
  43. package/dist/lib/index.mjs +4 -0
  44. package/dist/lib/meta.mjs +34 -0
  45. package/dist/lib/meta.mjs.map +1 -0
  46. package/dist/lib/operations.mjs +13 -0
  47. package/dist/lib/operations.mjs.map +1 -0
  48. package/dist/lib/plugin.mjs +9 -0
  49. package/dist/lib/plugin.mjs.map +1 -0
  50. package/dist/lib/translations.mjs +22 -0
  51. package/dist/lib/translations.mjs.map +1 -0
  52. package/dist/lib/types.mjs +6 -0
  53. package/dist/lib/util.mjs +63 -0
  54. package/dist/lib/util.mjs.map +1 -0
  55. package/dist/types/dx.config.d.ts +28 -0
  56. package/dist/types/dx.config.d.ts.map +1 -0
  57. package/dist/types/src/CrxPlugin.d.ts +37 -0
  58. package/dist/types/src/CrxPlugin.d.ts.map +1 -0
  59. package/dist/types/src/capabilities/gen/node.d.ts +9 -0
  60. package/dist/types/src/capabilities/gen/node.d.ts.map +1 -0
  61. package/dist/types/src/capabilities/gen/workerd.d.ts +9 -0
  62. package/dist/types/src/capabilities/gen/workerd.d.ts.map +1 -0
  63. package/dist/types/src/capabilities/index.d.ts +9 -0
  64. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  65. package/dist/types/src/capabilities/install-page-actions.d.ts +5 -0
  66. package/dist/types/src/capabilities/install-page-actions.d.ts.map +1 -0
  67. package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
  68. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  69. package/dist/types/src/capabilities/page-action-provider.d.ts +5 -0
  70. package/dist/types/src/capabilities/page-action-provider.d.ts.map +1 -0
  71. package/dist/types/src/capabilities/react-surface.d.ts +5 -0
  72. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  73. package/dist/types/src/capabilities/settings.d.ts +10 -0
  74. package/dist/types/src/capabilities/settings.d.ts.map +1 -0
  75. package/dist/types/src/containers/CrxSettings/CrxSettings.d.ts +10 -0
  76. package/dist/types/src/containers/CrxSettings/CrxSettings.d.ts.map +1 -0
  77. package/dist/types/src/containers/CrxSettings/CrxSettings.stories.d.ts +25 -0
  78. package/dist/types/src/containers/CrxSettings/CrxSettings.stories.d.ts.map +1 -0
  79. package/dist/types/src/containers/CrxSettings/index.d.ts +2 -0
  80. package/dist/types/src/containers/CrxSettings/index.d.ts.map +1 -0
  81. package/dist/types/src/containers/index.d.ts +2 -0
  82. package/dist/types/src/containers/index.d.ts.map +1 -0
  83. package/dist/types/src/index.d.ts +4 -0
  84. package/dist/types/src/index.d.ts.map +1 -0
  85. package/dist/types/src/mapping.d.ts +24 -0
  86. package/dist/types/src/mapping.d.ts.map +1 -0
  87. package/dist/types/src/mapping.test.d.ts +2 -0
  88. package/dist/types/src/mapping.test.d.ts.map +1 -0
  89. package/dist/types/src/meta.d.ts +33 -0
  90. package/dist/types/src/meta.d.ts.map +1 -0
  91. package/dist/types/src/operations/add-note-from-snapshot.d.ts +5 -0
  92. package/dist/types/src/operations/add-note-from-snapshot.d.ts.map +1 -0
  93. package/dist/types/src/operations/add-organization-from-snapshot.d.ts +5 -0
  94. package/dist/types/src/operations/add-organization-from-snapshot.d.ts.map +1 -0
  95. package/dist/types/src/operations/add-person-from-snapshot.d.ts +5 -0
  96. package/dist/types/src/operations/add-person-from-snapshot.d.ts.map +1 -0
  97. package/dist/types/src/operations/index.d.ts +3 -0
  98. package/dist/types/src/operations/index.d.ts.map +1 -0
  99. package/dist/types/src/page-actions.d.ts +39 -0
  100. package/dist/types/src/page-actions.d.ts.map +1 -0
  101. package/dist/types/src/page-actions.test.d.ts +2 -0
  102. package/dist/types/src/page-actions.test.d.ts.map +1 -0
  103. package/dist/types/src/plugin.d.ts +4 -0
  104. package/dist/types/src/plugin.d.ts.map +1 -0
  105. package/dist/types/src/stories/Testbench.stories.d.ts +8 -0
  106. package/dist/types/src/stories/Testbench.stories.d.ts.map +1 -0
  107. package/dist/types/src/translations.d.ts +22 -0
  108. package/dist/types/src/translations.d.ts.map +1 -0
  109. package/dist/types/src/types/CrxCapabilities.d.ts +12 -0
  110. package/dist/types/src/types/CrxCapabilities.d.ts.map +1 -0
  111. package/dist/types/src/types/CrxEvents.d.ts +8 -0
  112. package/dist/types/src/types/CrxEvents.d.ts.map +1 -0
  113. package/dist/types/src/types/CrxOperation.d.ts +108 -0
  114. package/dist/types/src/types/CrxOperation.d.ts.map +1 -0
  115. package/dist/types/src/types/PageAction.d.ts +153 -0
  116. package/dist/types/src/types/PageAction.d.ts.map +1 -0
  117. package/dist/types/src/types/Settings.d.ts +25 -0
  118. package/dist/types/src/types/Settings.d.ts.map +1 -0
  119. package/dist/types/src/types/Settings.test.d.ts +2 -0
  120. package/dist/types/src/types/Settings.test.d.ts.map +1 -0
  121. package/dist/types/src/types/index.d.ts +6 -0
  122. package/dist/types/src/types/index.d.ts.map +1 -0
  123. package/dist/types/src/util/index.d.ts +2 -0
  124. package/dist/types/src/util/index.d.ts.map +1 -0
  125. package/dist/types/src/util/pingExtension.d.ts +15 -0
  126. package/dist/types/src/util/pingExtension.d.ts.map +1 -0
  127. package/dist/types/src/util/pingExtension.test.d.ts +2 -0
  128. package/dist/types/src/util/pingExtension.test.d.ts.map +1 -0
  129. package/dist/types/tsconfig.tsbuildinfo +1 -0
  130. package/dx.config.ts +32 -0
  131. package/package.json +162 -0
  132. package/src/CrxPlugin.ts +15 -0
  133. package/src/capabilities/gen/node.ts +43 -0
  134. package/src/capabilities/gen/workerd.ts +43 -0
  135. package/src/capabilities/index.ts +47 -0
  136. package/src/capabilities/install-page-actions.ts +64 -0
  137. package/src/capabilities/operation-handler.ts +16 -0
  138. package/src/capabilities/page-action-provider.ts +47 -0
  139. package/src/capabilities/react-surface.ts +26 -0
  140. package/src/capabilities/settings.ts +36 -0
  141. package/src/containers/CrxSettings/CrxSettings.stories.tsx +99 -0
  142. package/src/containers/CrxSettings/CrxSettings.tsx +100 -0
  143. package/src/containers/CrxSettings/index.ts +5 -0
  144. package/src/containers/index.ts +5 -0
  145. package/src/index.ts +7 -0
  146. package/src/mapping.test.ts +87 -0
  147. package/src/mapping.ts +101 -0
  148. package/src/meta.ts +9 -0
  149. package/src/operations/add-note-from-snapshot.ts +24 -0
  150. package/src/operations/add-organization-from-snapshot.ts +29 -0
  151. package/src/operations/add-person-from-snapshot.ts +29 -0
  152. package/src/operations/index.ts +16 -0
  153. package/src/page-actions.test.ts +164 -0
  154. package/src/page-actions.ts +171 -0
  155. package/src/plugin.tsx +29 -0
  156. package/src/stories/Testbench.stories.tsx +45 -0
  157. package/src/translations.ts +31 -0
  158. package/src/types/CrxCapabilities.ts +26 -0
  159. package/src/types/CrxEvents.ts +16 -0
  160. package/src/types/CrxOperation.ts +54 -0
  161. package/src/types/PageAction.ts +57 -0
  162. package/src/types/Settings.test.ts +30 -0
  163. package/src/types/Settings.ts +101 -0
  164. package/src/types/index.ts +9 -0
  165. package/src/util/index.ts +5 -0
  166. package/src/util/pingExtension.test.ts +69 -0
  167. package/src/util/pingExtension.ts +83 -0
  168. package/src/vite-env.d.ts +10 -0
@@ -0,0 +1,100 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useState } from 'react';
6
+
7
+ import { useSettingsState } from '@dxos/app-framework/ui';
8
+ import { type AppSurface, SettingsScope } from '@dxos/app-toolkit/ui';
9
+ import { Flex, IconButton, useTranslation } from '@dxos/react-ui';
10
+ import { Form } from '@dxos/react-ui-form';
11
+
12
+ import { meta } from '#meta';
13
+ import { Settings } from '#types';
14
+
15
+ import { pingExtension } from '../../util/index.ts';
16
+
17
+ type TestState =
18
+ | { kind: 'idle' }
19
+ | { kind: 'pending' }
20
+ | { kind: 'ok'; message: string }
21
+ | { kind: 'error'; message: string };
22
+
23
+ export type CrxSettingsProps = AppSurface.SettingsData<{ readonly?: boolean }>;
24
+
25
+ /**
26
+ * Settings panel for the browser extension: edits the plugin's schema-driven settings and offers a
27
+ * round-trip connection test against the extension's content relay.
28
+ */
29
+ export const CrxSettings = ({ subject, readonly }: CrxSettingsProps) => {
30
+ const { t } = useTranslation(meta.profile.key);
31
+ const { settings, updateSettings } = useSettingsState<Settings.Settings>(subject.atom);
32
+ const [test, setTest] = useState<TestState>({ kind: 'idle' });
33
+
34
+ // Round-trip a ping to the extension and report its identity (or why it failed).
35
+ const handleTest = useCallback(async () => {
36
+ setTest({ kind: 'pending' });
37
+ try {
38
+ const info = await pingExtension();
39
+ setTest({
40
+ kind: 'ok',
41
+ message: t('test.connected.message', { name: info.extensionName, version: info.extensionVersion }),
42
+ });
43
+ } catch (err) {
44
+ setTest({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
45
+ }
46
+ }, [t]);
47
+
48
+ return (
49
+ <Form.Root
50
+ schema={Settings.Settings}
51
+ values={settings}
52
+ variant='settings'
53
+ readonly={readonly}
54
+ onValuesChanged={(values) => updateSettings((current) => ({ ...current, ...values }))}
55
+ >
56
+ <Form.Viewport scroll>
57
+ <Form.Content>
58
+ <Form.FieldSet
59
+ label={meta.profile.name ?? meta.profile.key}
60
+ description={t('settings.description')}
61
+ actions={readonly ? undefined : <SettingsScope prefix={subject.prefix} />}
62
+ >
63
+ <Form.Fields />
64
+ </Form.FieldSet>
65
+
66
+ <Form.FieldSet label={t('test.title')}>
67
+ <Flex gap='sm'>
68
+ <IconButton
69
+ disabled={test.kind === 'pending'}
70
+ icon='ph--plug--regular'
71
+ label={t('test.button.label')}
72
+ onClick={handleTest}
73
+ />
74
+
75
+ {/* role=status + aria-live so screen readers announce the async outcome. */}
76
+ <Flex align='center'>
77
+ <span
78
+ role='status'
79
+ aria-live='polite'
80
+ className={
81
+ test.kind === 'ok'
82
+ ? 'text-sm text-success-text'
83
+ : test.kind === 'error'
84
+ ? 'text-sm text-error-text'
85
+ : 'text-sm text-description'
86
+ }
87
+ >
88
+ {test.kind === 'ok' || test.kind === 'error' ? test.message : ''}
89
+ {test.kind === 'pending' ? t('test.pending.message') : ''}
90
+ </span>
91
+ </Flex>
92
+ </Flex>
93
+ </Form.FieldSet>
94
+ </Form.Content>
95
+ </Form.Viewport>
96
+ </Form.Root>
97
+ );
98
+ };
99
+
100
+ CrxSettings.displayName = 'CrxSettings';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './CrxSettings.tsx';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './CrxSettings/index.ts';
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * as CrxPlugin from './CrxPlugin.ts';
6
+ export * from '#meta';
7
+ export * from '#types';
@@ -0,0 +1,87 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { PageAction } from '#types';
8
+
9
+ import { toNote, toOrganization, toPerson } from './mapping.ts';
10
+
11
+ const baseSource: PageAction.Source = {
12
+ url: 'https://www.linkedin.com/in/ricburdon',
13
+ title: 'Rich Burdon | LinkedIn',
14
+ clippedAt: '2026-04-23T12:00:00.000Z',
15
+ };
16
+
17
+ const baseSelection: PageAction.Selection = {
18
+ text: 'Rich Burdon\nFounder, DXOS\nBrooklyn, NY',
19
+ };
20
+
21
+ const makeSnapshot = (overrides: Partial<PageAction.Snapshot> = {}): PageAction.Snapshot => ({
22
+ source: baseSource,
23
+ selection: baseSelection,
24
+ ...overrides,
25
+ });
26
+
27
+ describe('mapping', () => {
28
+ test('Person prefers h1 for fullName', ({ expect }) => {
29
+ const person = toPerson(makeSnapshot({ hints: { h1: 'Rich Burdon', ogTitle: 'LinkedIn' } }));
30
+ expect(person.fullName).toBe('Rich Burdon');
31
+ expect(person.urls?.[0]?.value).toBe(baseSource.url);
32
+ });
33
+
34
+ test('Person falls back to og:title, then first line of text', ({ expect }) => {
35
+ const fromOg = toPerson(makeSnapshot({ hints: { ogTitle: 'Jane Doe' } }));
36
+ expect(fromOg.fullName).toBe('Jane Doe');
37
+
38
+ const fromText = toPerson(makeSnapshot({ hints: {} }));
39
+ expect(fromText.fullName).toBe('Rich Burdon');
40
+ });
41
+
42
+ test('Person allows empty name when no candidate is present', ({ expect }) => {
43
+ const person = toPerson(makeSnapshot({ hints: {}, selection: { text: '' } }));
44
+ expect(person.fullName).toBeUndefined();
45
+ });
46
+
47
+ test('Organization prefers og:title, then h1', ({ expect }) => {
48
+ const fromOg = toOrganization(makeSnapshot({ hints: { ogTitle: 'DXOS', h1: 'Ignored' } }));
49
+ expect(fromOg.name).toBe('DXOS');
50
+
51
+ const fromH1 = toOrganization(makeSnapshot({ hints: { h1: 'DXOS' } }));
52
+ expect(fromH1.name).toBe('DXOS');
53
+ });
54
+
55
+ test('Organization sets website from source URL and description from og:description', ({ expect }) => {
56
+ const org = toOrganization(
57
+ makeSnapshot({
58
+ source: { ...baseSource, url: 'https://example.com' },
59
+ hints: { ogTitle: 'Example', ogDescription: 'An example company.' },
60
+ }),
61
+ );
62
+ expect(org.website).toBe('https://example.com');
63
+ expect(org.description).toBe('An example company.');
64
+ });
65
+
66
+ test('Note prefers h1 for title, then og:title, then first line, then source title', ({ expect }) => {
67
+ const fromH1 = toNote(makeSnapshot({ hints: { h1: 'From H1', ogTitle: 'From OG' } }));
68
+ expect(fromH1.name).toBe('From H1');
69
+
70
+ const fromOg = toNote(makeSnapshot({ hints: { ogTitle: 'From OG' } }));
71
+ expect(fromOg.name).toBe('From OG');
72
+
73
+ const fromText = toNote(makeSnapshot({ hints: {} }));
74
+ expect(fromText.name).toBe('Rich Burdon');
75
+
76
+ const fromSource = toNote(makeSnapshot({ hints: {}, selection: { text: '' } }));
77
+ expect(fromSource.name).toBe(baseSource.title);
78
+ });
79
+
80
+ test('mappers tolerate a snapshot without selection', ({ expect }) => {
81
+ const person = toPerson(makeSnapshot({ selection: undefined, hints: {} }));
82
+ expect(person.fullName).toBeUndefined();
83
+
84
+ const note = toNote(makeSnapshot({ selection: undefined, hints: { h1: 'Title' } }));
85
+ expect(note.content.target?.content).toContain('# Title');
86
+ });
87
+ });
package/src/mapping.ts ADDED
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Markdown from '@dxos/plugin-markdown/Markdown';
6
+ import { Organization, Person } from '@dxos/types';
7
+
8
+ import { PageAction } from '#types';
9
+
10
+ const MAX_NOTES_LENGTH = 4000;
11
+
12
+ /**
13
+ * Maximum length of the markdown body we write for a Note. Larger than
14
+ * the embedded `notes` field on Person/Organization because a Markdown
15
+ * document is the whole content, not a secondary annotation.
16
+ */
17
+ const MAX_NOTE_BODY_LENGTH = 50_000;
18
+
19
+ const firstLine = (text: string | undefined): string | undefined => {
20
+ if (!text) {
21
+ return undefined;
22
+ }
23
+ const line = text
24
+ .split('\n')
25
+ .map((s) => s.trim())
26
+ .find((s) => s.length > 0);
27
+ return line && line.length > 0 ? line : undefined;
28
+ };
29
+
30
+ const truncate = (text: string | undefined, max: number): string | undefined => {
31
+ if (!text) {
32
+ return undefined;
33
+ }
34
+ return text.length > max ? text.slice(0, max) : text;
35
+ };
36
+
37
+ /**
38
+ * Best-effort mapping from an incoming page snapshot to a Person.
39
+ * Every field is optional on the schema, so an empty name is acceptable.
40
+ */
41
+ export const toPerson = (snapshot: PageAction.Snapshot) => {
42
+ const hints = snapshot.hints ?? {};
43
+ const fullName = hints.h1 ?? hints.ogTitle ?? firstLine(snapshot.selection?.text);
44
+ return Person.make({
45
+ fullName,
46
+ image: hints.ogImage,
47
+ notes: truncate(snapshot.selection?.text, MAX_NOTES_LENGTH),
48
+ urls: [
49
+ {
50
+ label: snapshot.source.title || snapshot.source.url,
51
+ value: snapshot.source.url,
52
+ },
53
+ ],
54
+ });
55
+ };
56
+
57
+ /**
58
+ * Best-effort mapping from an incoming page snapshot to an Organization.
59
+ */
60
+ export const toOrganization = (snapshot: PageAction.Snapshot) => {
61
+ const hints = snapshot.hints ?? {};
62
+ const name = hints.ogTitle ?? hints.h1 ?? firstLine(snapshot.selection?.text);
63
+ return Organization.make({
64
+ name,
65
+ description: hints.ogDescription ?? truncate(snapshot.selection?.text, MAX_NOTES_LENGTH),
66
+ image: hints.ogImage,
67
+ website: snapshot.source.url,
68
+ });
69
+ };
70
+
71
+ /**
72
+ * Best-effort mapping from an incoming page snapshot to a Markdown document.
73
+ *
74
+ * Builds a small prelude (title + source link + date) followed by the
75
+ * picked subtree's rendered text. We deliberately don't attempt HTML →
76
+ * Markdown conversion here — the picker's `innerText` already preserves
77
+ * paragraph structure, and a full converter belongs in the pipeline
78
+ * (where a future agent stage can take over) rather than baked into the
79
+ * receiver.
80
+ */
81
+ export const toNote = (snapshot: PageAction.Snapshot) => {
82
+ const hints = snapshot.hints ?? {};
83
+ const title = hints.h1 ?? hints.ogTitle ?? firstLine(snapshot.selection?.text) ?? snapshot.source.title;
84
+ const body = truncate(snapshot.selection?.text, MAX_NOTE_BODY_LENGTH) ?? '';
85
+
86
+ const sourceLabel = snapshot.source.title || snapshot.source.url;
87
+ const clippedAt = snapshot.source.clippedAt;
88
+ const preludeLines = [
89
+ title ? `# ${title}` : undefined,
90
+ '',
91
+ `_Clipped from [${sourceLabel}](${snapshot.source.url}) on ${clippedAt}._`,
92
+ '',
93
+ body,
94
+ ].filter((line): line is string => line !== undefined);
95
+ const content = preludeLines.join('\n');
96
+
97
+ return Markdown.make({
98
+ name: title ?? undefined,
99
+ content,
100
+ });
101
+ };
package/src/meta.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Plugin from '@dxos/app-framework/Plugin';
6
+
7
+ import config from '../dx.config.ts';
8
+
9
+ export const meta = Plugin.getMetaFromConfig(config);
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import * as Operation from '@dxos/compute/Operation';
8
+ import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
9
+
10
+ import { CrxOperation } from '#types';
11
+
12
+ import { toNote } from '../mapping.ts';
13
+
14
+ const handler: Operation.WithHandler<typeof CrxOperation.AddNoteFromSnapshot> = CrxOperation.AddNoteFromSnapshot.pipe(
15
+ Operation.withHandler(
16
+ Effect.fn(function* ({ snapshot, target }) {
17
+ const note = toNote(snapshot);
18
+ const { id } = yield* Operation.invoke(SpaceOperation.AddObject, { object: note }, { spaceId: target.spaceId });
19
+ return { id };
20
+ }),
21
+ ),
22
+ );
23
+
24
+ export default handler;
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import * as Operation from '@dxos/compute/Operation';
8
+ import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
9
+
10
+ import { CrxOperation } from '#types';
11
+
12
+ import { toOrganization } from '../mapping.ts';
13
+
14
+ const handler: Operation.WithHandler<typeof CrxOperation.AddOrganizationFromSnapshot> =
15
+ CrxOperation.AddOrganizationFromSnapshot.pipe(
16
+ Operation.withHandler(
17
+ Effect.fn(function* ({ snapshot, target }) {
18
+ const organization = toOrganization(snapshot);
19
+ const { id } = yield* Operation.invoke(
20
+ SpaceOperation.AddObject,
21
+ { object: organization },
22
+ { spaceId: target.spaceId },
23
+ );
24
+ return { id };
25
+ }),
26
+ ),
27
+ );
28
+
29
+ export default handler;
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import * as Operation from '@dxos/compute/Operation';
8
+ import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
9
+
10
+ import { CrxOperation } from '#types';
11
+
12
+ import { toPerson } from '../mapping.ts';
13
+
14
+ const handler: Operation.WithHandler<typeof CrxOperation.AddPersonFromSnapshot> =
15
+ CrxOperation.AddPersonFromSnapshot.pipe(
16
+ Operation.withHandler(
17
+ Effect.fn(function* ({ snapshot, target }) {
18
+ const person = toPerson(snapshot);
19
+ const { id } = yield* Operation.invoke(
20
+ SpaceOperation.AddObject,
21
+ { object: person },
22
+ { spaceId: target.spaceId },
23
+ );
24
+ return { id };
25
+ }),
26
+ ),
27
+ );
28
+
29
+ export default handler;
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Operation from '@dxos/compute/Operation';
6
+ import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
7
+
8
+ import { CrxOperation } from '#types';
9
+
10
+ export const CrxOperationHandlerSet = OperationHandlerSet.lazy([
11
+ CrxOperation.AddPersonFromSnapshot.pipe(Operation.lazyHandler(() => import('./add-person-from-snapshot.ts'))),
12
+ CrxOperation.AddOrganizationFromSnapshot.pipe(
13
+ Operation.lazyHandler(() => import('./add-organization-from-snapshot.ts')),
14
+ ),
15
+ CrxOperation.AddNoteFromSnapshot.pipe(Operation.lazyHandler(() => import('./add-note-from-snapshot.ts'))),
16
+ ]);
@@ -0,0 +1,164 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import * as Operation from '@dxos/compute/Operation';
9
+ import { Message, request } from '@dxos/crx-protocol';
10
+ import { createMockPeer } from '@dxos/crx-protocol/testing';
11
+ import { DXN } from '@dxos/keys';
12
+
13
+ import { PageAction } from '#types';
14
+
15
+ import { handleInvokeEvent, handleListEvent } from './page-actions.ts';
16
+
17
+ const TestOp = Operation.make({
18
+ meta: { key: DXN.make('com.example.operation.test.pageAction'), name: 'Test' },
19
+ input: Schema.Struct({ snapshot: PageAction.Snapshot, target: Schema.Any }),
20
+ output: Schema.Struct({ id: Schema.String }),
21
+ });
22
+
23
+ const action: PageAction.PageAction = {
24
+ id: 'org.dxos.test/page-action/test',
25
+ label: 'Test',
26
+ icon: 'ph--bookmark-simple--regular',
27
+ urlPatterns: ['https://*/*'],
28
+ extractor: { name: 'snapshot' },
29
+ contexts: ['popup'],
30
+ operation: TestOp,
31
+ };
32
+
33
+ const snapshot = {
34
+ source: { url: 'https://example.com', title: 'Example', clippedAt: '2026-06-09T00:00:00Z' },
35
+ };
36
+
37
+ const invokeRequest = {
38
+ version: 1,
39
+ id: 'req-1',
40
+ actionId: action.id,
41
+ page: { url: 'https://example.com', title: 'Example' },
42
+ inputs: snapshot,
43
+ invokedFrom: 'popup',
44
+ };
45
+
46
+ const deps = (overrides: Partial<Parameters<typeof handleInvokeEvent>[1]> = {}) => ({
47
+ getActions: () => [action],
48
+ getSettings: () => ({}),
49
+ getTarget: () => ({}) as any, // Stub database; never dereferenced by the handler.
50
+ invoke: async () => ({ data: { id: 'obj-1' }, error: undefined }),
51
+ ...overrides,
52
+ });
53
+
54
+ describe('page-actions', () => {
55
+ test('list returns serializable descriptors', ({ expect }) => {
56
+ const ack = handleListEvent({ version: 1, id: 'list-1' }, () => [action]);
57
+ expect(ack).toEqual({
58
+ version: 1,
59
+ id: 'list-1',
60
+ ok: true,
61
+ actions: [{ ...action, operation: TestOp.meta.key.toString() }],
62
+ });
63
+ });
64
+
65
+ test('list rejects malformed request', ({ expect }) => {
66
+ const ack = handleListEvent({ nope: true }, () => [action]);
67
+ expect(ack.ok).toBe(false);
68
+ });
69
+
70
+ test('invoke happy path returns objectId', async ({ expect }) => {
71
+ const ack = await handleInvokeEvent(invokeRequest, deps());
72
+ expect(ack).toEqual({ version: 1, id: 'req-1', ok: true, objectId: 'obj-1' });
73
+ });
74
+
75
+ test('invoke is ignored when extension actions are disabled', async ({ expect }) => {
76
+ let invoked = false;
77
+ const ack = await handleInvokeEvent(
78
+ invokeRequest,
79
+ deps({
80
+ getSettings: () => ({ enabled: false }),
81
+ invoke: async () => {
82
+ invoked = true;
83
+ return { data: { id: 'obj-1' }, error: undefined };
84
+ },
85
+ }),
86
+ );
87
+ expect(ack).toEqual({ version: 1, id: 'req-1', ok: false, error: 'disabled' });
88
+ expect(invoked).toBe(false);
89
+ });
90
+
91
+ test('invoke honors an explicit enabled=true', async ({ expect }) => {
92
+ const ack = await handleInvokeEvent(invokeRequest, deps({ getSettings: () => ({ enabled: true }) }));
93
+ expect(ack).toMatchObject({ ok: true, objectId: 'obj-1' });
94
+ });
95
+
96
+ test('invoke rejects unsupported version', async ({ expect }) => {
97
+ const ack = await handleInvokeEvent({ ...invokeRequest, version: 2 }, deps());
98
+ expect(ack).toMatchObject({ ok: false, error: 'unsupportedVersion' });
99
+ });
100
+
101
+ test('invoke rejects unknown action', async ({ expect }) => {
102
+ const ack = await handleInvokeEvent({ ...invokeRequest, actionId: 'nope' }, deps());
103
+ expect(ack).toMatchObject({ ok: false, error: 'unknownAction' });
104
+ });
105
+
106
+ test('invoke maps missing space to noSpace', async ({ expect }) => {
107
+ const ack = await handleInvokeEvent(invokeRequest, deps({ getTarget: () => undefined }));
108
+ expect(ack).toMatchObject({ ok: false, error: 'noSpace' });
109
+ });
110
+
111
+ test('invoke maps operation error to operationFailed', async ({ expect }) => {
112
+ const ack = await handleInvokeEvent(
113
+ invokeRequest,
114
+ deps({ invoke: async () => ({ data: undefined, error: new Error('boom') }) }),
115
+ );
116
+ expect(ack).toMatchObject({ ok: false, error: 'operationFailed' });
117
+ });
118
+
119
+ test('invoke echoes request id when payload is invalid', async ({ expect }) => {
120
+ const { page: _page, ...withoutPage } = invokeRequest;
121
+ const ack = await handleInvokeEvent(withoutPage, deps());
122
+ expect(ack).toEqual({ version: 1, id: 'req-1', ok: false, error: 'invalidPayload' });
123
+ });
124
+
125
+ test('invoke maps a rejecting operation to operationFailed', async ({ expect }) => {
126
+ const ack = await handleInvokeEvent(
127
+ invokeRequest,
128
+ deps({
129
+ invoke: async () => {
130
+ throw new Error('boom');
131
+ },
132
+ }),
133
+ );
134
+ expect(ack).toMatchObject({ ok: false, error: 'operationFailed' });
135
+ });
136
+
137
+ test('invoke accepts picker-originated requests', async ({ expect }) => {
138
+ const ack = await handleInvokeEvent({ ...invokeRequest, invokedFrom: 'picker' }, deps());
139
+ expect(ack).toEqual({ version: 1, id: 'req-1', ok: true, objectId: 'obj-1' });
140
+ });
141
+
142
+ test('list serializes picker contexts', ({ expect }) => {
143
+ const pickerAction: PageAction.PageAction = { ...action, contexts: ['picker'] };
144
+ const ack = handleListEvent({ version: 1, id: 'list-2' }, () => [pickerAction]);
145
+ expect(ack.ok).toBe(true);
146
+ expect(ack.ok && ack.actions[0].contexts).toEqual(['picker']);
147
+ });
148
+ });
149
+
150
+ describe('page-actions over the mock protocol peer', () => {
151
+ test('a list request is answered by the real handler', async ({ expect }) => {
152
+ const { extension, composer } = createMockPeer();
153
+ composer.handle((message) => {
154
+ if (message._tag === 'page-actions.list') {
155
+ const ack = handleListEvent({ version: 1, id: message.id }, () => []);
156
+ return ack.ok
157
+ ? Message.make('page-actions.list-ack', { id: ack.id, ok: true, actions: ack.actions })
158
+ : Message.make('page-actions.list-ack', { id: ack.id, ok: false, error: ack.error });
159
+ }
160
+ });
161
+ const reply = await request(extension, Message.make('page-actions.list', { id: 'r1' }));
162
+ expect(reply).toMatchObject({ _tag: 'page-actions.list-ack', id: 'r1', ok: true });
163
+ });
164
+ });