@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +89 -5
- package/src/agents/mcpServerPresets.test.ts +105 -0
- package/src/agents/mcpServerPresets.ts +380 -0
- package/src/agents/runtime/index.ts +7 -0
- package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
- package/src/agents/runtime/pendingAsks.test.ts +117 -0
- package/src/agents/runtime/pendingAsks.ts +110 -0
- package/src/bridge/agentScopeDefaults.ts +65 -0
- package/src/bridge/agentTypes.ts +57 -3
- package/src/bridge/agents.d.mts +6 -0
- package/src/bridge/agents.mjs +10 -0
- package/src/bridge/agents.ts +4 -0
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/credentials.d.mts +62 -0
- package/src/bridge/credentials.mjs +53 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/events.d.mts +1 -0
- package/src/bridge/events.mjs +1 -0
- package/src/bridge/googleAuth.ts +7 -152
- package/src/bridge/mcpClient.d.mts +74 -0
- package/src/bridge/mcpClient.mjs +79 -0
- package/src/bridge/methods.d.mts +45 -0
- package/src/bridge/methods.mjs +46 -0
- package/src/bridge/notifications.d.mts +12 -0
- package/src/bridge/notifications.mjs +36 -0
- package/src/bridge/operations.d.mts +44 -0
- package/src/bridge/operations.mjs +40 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
- package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/react/usePlatformOperations.tsx +75 -0
- package/src/bridge/storage.d.mts +23 -10
- package/src/bridge/storage.mjs +6 -3
- package/src/bridge/storage.test.ts +6 -6
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentComposer.tsx +10 -6
- package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
- package/src/components/agents/AgentDrawerPanel.tsx +23 -62
- package/src/components/agents/AgentMessageBubble.tsx +59 -33
- package/src/components/agents/AgentMessageList.tsx +4 -4
- package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
- package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/AgentToolPendingList.tsx +6 -6
- package/src/components/agents/AgentToolPendingReview.tsx +6 -6
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/QuestionRequestList.tsx +278 -0
- package/src/components/agents/agentPanelStyles.ts +8 -31
- package/src/components/agents/agentTypes.ts +25 -41
- package/src/components/agents/index.ts +4 -0
- package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
- package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/chat/ChatBox.tsx +9 -9
- package/src/components/common/chat/ChatModelMenu.tsx +34 -4
- package/src/components/common/chat/ChatThread.tsx +23 -7
- package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
- package/src/components/common/connections/ProviderConnection.tsx +277 -0
- package/src/components/common/connections/index.ts +5 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +26 -10
- package/src/components/common/containers/AppHeader.tsx +37 -15
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
- package/src/components/common/dropdown/MenuPortal.tsx +3 -1
- package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
- package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
- package/src/components/common/overlays/Modal.tsx +13 -2
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/ics/generateIcs.test.ts +153 -0
- package/src/ics/generateIcs.ts +197 -0
- package/src/index.ts +3 -0
- package/src/net/httpRetry.test.ts +117 -0
- package/src/net/httpRetry.ts +111 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +93 -104
- package/src/theme/appIdentityCss.mjs +178 -229
- package/src/theme/appIdentityCss.ts +222 -231
- package/src/theme/themes/dark.ts +3 -1
- package/src/theme/themes/light.ts +3 -1
- package/src/bridge/googleProviderConfig.ts +0 -73
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { htmlToContextMarkdown } from './htmlToContextMarkdown.js'
|
|
4
|
+
|
|
5
|
+
describe('htmlToContextMarkdown', () => {
|
|
6
|
+
it('converts headings and paragraphs', () => {
|
|
7
|
+
expect(
|
|
8
|
+
htmlToContextMarkdown('<h1>Title</h1><p>Hello <strong>world</strong>.</p>'),
|
|
9
|
+
).toBe('# Title\n\nHello **world**.\n')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('keeps inline marks in headings', () => {
|
|
13
|
+
expect(htmlToContextMarkdown('<h2>My <em>Title</em></h2>')).toBe(
|
|
14
|
+
'## My *Title*\n',
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('converts links', () => {
|
|
19
|
+
expect(
|
|
20
|
+
htmlToContextMarkdown('<p>See <a href="https://x.test/a">the paper</a>.</p>'),
|
|
21
|
+
).toBe('See [the paper](https://x.test/a).\n')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('prefers the durable collection asset src for images', () => {
|
|
25
|
+
expect(
|
|
26
|
+
htmlToContextMarkdown(
|
|
27
|
+
'<p><img src="blob:ephemeral" data-collection-asset-src="assets/fig-01.png" alt="Figure 1"></p>',
|
|
28
|
+
),
|
|
29
|
+
).toBe('\n')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('drops data: image payloads', () => {
|
|
33
|
+
expect(
|
|
34
|
+
htmlToContextMarkdown('<p><img src="data:image/png;base64,AAAA" alt="P"></p>'),
|
|
35
|
+
).toBe('![P]()\n')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('converts unordered and ordered lists', () => {
|
|
39
|
+
expect(
|
|
40
|
+
htmlToContextMarkdown(
|
|
41
|
+
'<ul><li><p>One</p></li><li><p>Two</p></li></ul>',
|
|
42
|
+
),
|
|
43
|
+
).toBe('- One\n- Two\n')
|
|
44
|
+
expect(
|
|
45
|
+
htmlToContextMarkdown('<ol><li><p>First</p></li><li><p>Second</p></li></ol>'),
|
|
46
|
+
).toBe('1. First\n2. Second\n')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('nests lists with indentation', () => {
|
|
50
|
+
expect(
|
|
51
|
+
htmlToContextMarkdown('<ul><li>A<ul><li>B</li></ul></li></ul>'),
|
|
52
|
+
).toBe('- A\n - B\n')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('converts blockquotes', () => {
|
|
56
|
+
expect(
|
|
57
|
+
htmlToContextMarkdown('<blockquote><p>Quoted</p></blockquote>'),
|
|
58
|
+
).toBe('> Quoted\n')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('converts pre/code with language to fenced blocks', () => {
|
|
62
|
+
expect(
|
|
63
|
+
htmlToContextMarkdown(
|
|
64
|
+
'<pre><code class="language-js">const a = 1;\n</code></pre>',
|
|
65
|
+
),
|
|
66
|
+
).toBe('```js\nconst a = 1;\n```\n')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('converts inline code', () => {
|
|
70
|
+
expect(htmlToContextMarkdown('<p>Run <code>npm test</code>.</p>')).toBe(
|
|
71
|
+
'Run `npm test`.\n',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('converts mermaid figures to mermaid fences', () => {
|
|
76
|
+
expect(
|
|
77
|
+
htmlToContextMarkdown(
|
|
78
|
+
'<figure data-type="mermaid"><code class="language-mermaid">graph TD</code></figure>',
|
|
79
|
+
),
|
|
80
|
+
).toBe('```mermaid\ngraph TD\n```\n')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('converts block math via data-latex', () => {
|
|
84
|
+
expect(
|
|
85
|
+
htmlToContextMarkdown(
|
|
86
|
+
'<span data-type="block-math" data-latex="E=mc^2"></span>',
|
|
87
|
+
),
|
|
88
|
+
).toBe('$$E=mc^2$$\n')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('converts tables to GFM', () => {
|
|
92
|
+
expect(
|
|
93
|
+
htmlToContextMarkdown(
|
|
94
|
+
'<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>',
|
|
95
|
+
),
|
|
96
|
+
).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |\n')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('decodes entities', () => {
|
|
100
|
+
expect(htmlToContextMarkdown('<p>a & b <c> —</p>')).toBe(
|
|
101
|
+
'a & b <c> —\n',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips script and style content', () => {
|
|
106
|
+
expect(
|
|
107
|
+
htmlToContextMarkdown('<p>Hi</p><script>ignored()</script><p>Bye</p>'),
|
|
108
|
+
).toBe('Hi\n\nBye\n')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('renders figcaptions as emphasis', () => {
|
|
112
|
+
expect(
|
|
113
|
+
htmlToContextMarkdown(
|
|
114
|
+
'<figure><img data-collection-asset-src="assets/a.png" alt="A"><figcaption>Caption text</figcaption></figure>',
|
|
115
|
+
),
|
|
116
|
+
).toBe('\n\n*Caption text*\n')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('converts hard breaks inside paragraphs', () => {
|
|
120
|
+
expect(htmlToContextMarkdown('<p>Line one<br>Line two</p>')).toBe(
|
|
121
|
+
'Line one\nLine two\n',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('converts horizontal rules', () => {
|
|
126
|
+
expect(htmlToContextMarkdown('<p>Above</p><hr><p>Below</p>')).toBe(
|
|
127
|
+
'Above\n\n---\n\nBelow\n',
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('ignores inter-tag whitespace', () => {
|
|
132
|
+
expect(htmlToContextMarkdown('<p>A</p>\n <p>B</p>')).toBe('A\n\nB\n')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic HTML → markdown conversion for context documents.
|
|
3
|
+
*
|
|
4
|
+
* Converts the well-formed HTML that suite apps persist (TipTap-style output)
|
|
5
|
+
* into readable markdown. Intentionally DOM-free (regex tokenizer + stack) so
|
|
6
|
+
* it runs identically in app iframes and in a headless shell process.
|
|
7
|
+
*
|
|
8
|
+
* Not a general-purpose sanitizer: input is trusted app-generated HTML.
|
|
9
|
+
* Binary/image references keep their durable relative paths
|
|
10
|
+
* (`data-collection-asset-src` wins over `src`); `data:` URLs are dropped —
|
|
11
|
+
* context files link binaries, never embed them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'source', 'wbr', 'col', 'embed', 'area', 'base', 'track'])
|
|
15
|
+
const SKIP_TAGS = new Set(['script', 'style', 'head', 'title', 'meta', 'link', 'template'])
|
|
16
|
+
const TRANSPARENT_TAGS = new Set(['div', 'span', 'section', 'article', 'main', 'header', 'footer', 'aside', 'nav', 'body', 'html', 'u', 'mark', 'small', 'sub', 'sup', 'font', 'colgroup', 'col', 'tbody', 'thead', 'tfoot'])
|
|
17
|
+
|
|
18
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
19
|
+
amp: '&',
|
|
20
|
+
lt: '<',
|
|
21
|
+
gt: '>',
|
|
22
|
+
quot: '"',
|
|
23
|
+
apos: "'",
|
|
24
|
+
nbsp: ' ',
|
|
25
|
+
mdash: '—',
|
|
26
|
+
ndash: '–',
|
|
27
|
+
hellip: '…',
|
|
28
|
+
rsquo: '’',
|
|
29
|
+
lsquo: '‘',
|
|
30
|
+
rdquo: '”',
|
|
31
|
+
ldquo: '“',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ListFrame {
|
|
35
|
+
type: 'ul' | 'ol'
|
|
36
|
+
index: number
|
|
37
|
+
itemBlockEmitted: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TableState {
|
|
41
|
+
rows: string[][]
|
|
42
|
+
headerRows: number
|
|
43
|
+
currentRow: string[] | null
|
|
44
|
+
inHeader: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Converter {
|
|
48
|
+
out: string[]
|
|
49
|
+
inline: string
|
|
50
|
+
linkHrefs: string[]
|
|
51
|
+
quoteDepth: number
|
|
52
|
+
lists: ListFrame[]
|
|
53
|
+
table: TableState | null
|
|
54
|
+
pre: { lang: string; text: string } | null
|
|
55
|
+
codeFence: { lang: string; text: string } | null
|
|
56
|
+
skipDepth: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeEntities(text: string): string {
|
|
60
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity: string) => {
|
|
61
|
+
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
|
62
|
+
const code = Number.parseInt(entity.slice(2), 16)
|
|
63
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code)
|
|
64
|
+
}
|
|
65
|
+
if (entity.startsWith('#')) {
|
|
66
|
+
const code = Number.parseInt(entity.slice(1), 10)
|
|
67
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code)
|
|
68
|
+
}
|
|
69
|
+
return NAMED_ENTITIES[entity] ?? whole
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readAttributes(tag: string): Record<string, string> {
|
|
74
|
+
const attrs: Record<string, string> = {}
|
|
75
|
+
const pattern = /([a-zA-Z][-\w:]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g
|
|
76
|
+
let match: RegExpExecArray | null
|
|
77
|
+
while ((match = pattern.exec(tag)) !== null) {
|
|
78
|
+
attrs[match[1].toLowerCase()] = decodeEntities(match[3] ?? match[4] ?? match[5] ?? '')
|
|
79
|
+
}
|
|
80
|
+
return attrs
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function currentList(state: Converter): ListFrame | null {
|
|
84
|
+
return state.lists.length > 0 ? state.lists[state.lists.length - 1] : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Push finished block lines through list + blockquote prefixing into out. */
|
|
88
|
+
function emitBlock(state: Converter, lines: string[]): void {
|
|
89
|
+
if (lines.length === 0) return
|
|
90
|
+
let prefixed = lines
|
|
91
|
+
const list = currentList(state)
|
|
92
|
+
if (list) {
|
|
93
|
+
const indent = ' '.repeat(state.lists.length - 1)
|
|
94
|
+
const marker = list.type === 'ol' ? `${list.index}. ` : '- '
|
|
95
|
+
const continuation = ' '.repeat(marker.length)
|
|
96
|
+
prefixed = prefixed.map((line, index) => {
|
|
97
|
+
if (!list.itemBlockEmitted && index === 0) return `${indent}${marker}${line}`
|
|
98
|
+
return `${indent}${continuation}${line}`
|
|
99
|
+
})
|
|
100
|
+
list.itemBlockEmitted = true
|
|
101
|
+
}
|
|
102
|
+
if (state.quoteDepth > 0) {
|
|
103
|
+
const quote = '> '.repeat(state.quoteDepth)
|
|
104
|
+
prefixed = prefixed.map(line => `${quote}${line}`.trimEnd())
|
|
105
|
+
}
|
|
106
|
+
if (state.out.length > 0) {
|
|
107
|
+
const separator = list ? null : ''
|
|
108
|
+
if (separator !== null) state.out.push(separator)
|
|
109
|
+
}
|
|
110
|
+
state.out.push(...prefixed)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function flushInline(state: Converter, headingLevel?: number): void {
|
|
114
|
+
const text = state.inline.replace(/[ \t]+/g, ' ').trim()
|
|
115
|
+
state.inline = ''
|
|
116
|
+
if (!text) return
|
|
117
|
+
if (state.table?.currentRow) {
|
|
118
|
+
state.table.currentRow.push(text.replace(/\|/g, '\\|'))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (headingLevel) {
|
|
122
|
+
emitBlock(state, [`${'#'.repeat(headingLevel)} ${text}`])
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
emitBlock(state, text.split('\n').map(line => line.trim()).filter(Boolean))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function flushTableCell(state: Converter): void {
|
|
129
|
+
if (!state.table) return
|
|
130
|
+
const text = state.inline.replace(/[ \t\n]+/g, ' ').trim()
|
|
131
|
+
state.inline = ''
|
|
132
|
+
state.table.currentRow?.push(text.replace(/\|/g, '\\|'))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function emitTable(state: Converter): void {
|
|
136
|
+
const table = state.table
|
|
137
|
+
state.table = null
|
|
138
|
+
if (!table || table.rows.length === 0) return
|
|
139
|
+
const width = Math.max(...table.rows.map(row => row.length))
|
|
140
|
+
const pad = (row: string[]): string[] => {
|
|
141
|
+
const cells = [...row]
|
|
142
|
+
while (cells.length < width) cells.push('')
|
|
143
|
+
return cells
|
|
144
|
+
}
|
|
145
|
+
const [first, ...rest] = table.rows
|
|
146
|
+
const lines = [
|
|
147
|
+
`| ${pad(first).join(' | ')} |`,
|
|
148
|
+
`| ${Array.from({ length: width }, () => '---').join(' | ')} |`,
|
|
149
|
+
...rest.map(row => `| ${pad(row).join(' | ')} |`),
|
|
150
|
+
]
|
|
151
|
+
emitBlock(state, lines)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function emitFence(state: Converter, lang: string, text: string): void {
|
|
155
|
+
const clean = text.replace(/^\n+/, '').replace(/\s+$/, '')
|
|
156
|
+
emitBlock(state, [`\`\`\`${lang}`, ...clean.split('\n'), '```'])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const HEADING_TAG = /^h([1-6])$/
|
|
160
|
+
|
|
161
|
+
/** Convert app-persisted HTML into context markdown. */
|
|
162
|
+
export function htmlToContextMarkdown(html: string): string {
|
|
163
|
+
const state: Converter = {
|
|
164
|
+
out: [],
|
|
165
|
+
inline: '',
|
|
166
|
+
linkHrefs: [],
|
|
167
|
+
quoteDepth: 0,
|
|
168
|
+
lists: [],
|
|
169
|
+
table: null,
|
|
170
|
+
pre: null,
|
|
171
|
+
codeFence: null,
|
|
172
|
+
skipDepth: 0,
|
|
173
|
+
}
|
|
174
|
+
const source = html.replace(/<!--[\s\S]*?-->/g, '')
|
|
175
|
+
const tokens = source.match(/<[^>]+>|[^<]+/g) ?? []
|
|
176
|
+
const openHeadings: number[] = []
|
|
177
|
+
|
|
178
|
+
for (const token of tokens) {
|
|
179
|
+
if (!token.startsWith('<')) {
|
|
180
|
+
if (state.skipDepth > 0) continue
|
|
181
|
+
const text = decodeEntities(token)
|
|
182
|
+
if (state.pre) state.pre.text += text
|
|
183
|
+
else if (state.codeFence) state.codeFence.text += text
|
|
184
|
+
else state.inline += text.replace(/\s+/g, ' ')
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const tagMatch = /^<\s*(\/)?\s*([a-zA-Z][-\w]*)/.exec(token)
|
|
189
|
+
if (!tagMatch) continue
|
|
190
|
+
const closing = Boolean(tagMatch[1])
|
|
191
|
+
const name = tagMatch[2].toLowerCase()
|
|
192
|
+
|
|
193
|
+
if (SKIP_TAGS.has(name)) {
|
|
194
|
+
if (VOID_TAGS.has(name)) continue
|
|
195
|
+
state.skipDepth = Math.max(0, state.skipDepth + (closing ? -1 : 1))
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (state.skipDepth > 0) continue
|
|
199
|
+
|
|
200
|
+
if (state.pre) {
|
|
201
|
+
if (closing && name === 'pre') {
|
|
202
|
+
const pre = state.pre
|
|
203
|
+
state.pre = null
|
|
204
|
+
emitFence(state, pre.lang, pre.text)
|
|
205
|
+
} else if (!closing && name === 'code') {
|
|
206
|
+
const lang = /language-([-\w]+)/.exec(readAttributes(token).class ?? '')
|
|
207
|
+
if (lang) state.pre.lang = lang[1]
|
|
208
|
+
}
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (state.codeFence) {
|
|
212
|
+
if (closing && name === 'code') {
|
|
213
|
+
const fence = state.codeFence
|
|
214
|
+
state.codeFence = null
|
|
215
|
+
emitFence(state, fence.lang, fence.text)
|
|
216
|
+
}
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const attrs = closing ? {} : readAttributes(token)
|
|
221
|
+
const headingMatch = HEADING_TAG.exec(name)
|
|
222
|
+
|
|
223
|
+
if (headingMatch) {
|
|
224
|
+
if (closing) {
|
|
225
|
+
flushInline(state, openHeadings.pop())
|
|
226
|
+
} else {
|
|
227
|
+
flushInline(state)
|
|
228
|
+
openHeadings.push(Number(headingMatch[1]))
|
|
229
|
+
}
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
switch (name) {
|
|
234
|
+
case 'p':
|
|
235
|
+
flushInline(state)
|
|
236
|
+
break
|
|
237
|
+
case 'br':
|
|
238
|
+
state.inline += '\n'
|
|
239
|
+
break
|
|
240
|
+
case 'hr':
|
|
241
|
+
flushInline(state)
|
|
242
|
+
emitBlock(state, ['---'])
|
|
243
|
+
break
|
|
244
|
+
case 'strong':
|
|
245
|
+
case 'b':
|
|
246
|
+
state.inline += '**'
|
|
247
|
+
break
|
|
248
|
+
case 'em':
|
|
249
|
+
case 'i':
|
|
250
|
+
state.inline += '*'
|
|
251
|
+
break
|
|
252
|
+
case 's':
|
|
253
|
+
case 'del':
|
|
254
|
+
case 'strike':
|
|
255
|
+
state.inline += '~~'
|
|
256
|
+
break
|
|
257
|
+
case 'code':
|
|
258
|
+
if (!closing) {
|
|
259
|
+
const lang = /language-([-\w]+)/.exec(attrs.class ?? '')
|
|
260
|
+
if (lang) {
|
|
261
|
+
flushInline(state)
|
|
262
|
+
state.codeFence = { lang: lang[1], text: '' }
|
|
263
|
+
} else {
|
|
264
|
+
state.inline += '`'
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
state.inline += '`'
|
|
268
|
+
}
|
|
269
|
+
break
|
|
270
|
+
case 'pre':
|
|
271
|
+
if (!closing) {
|
|
272
|
+
flushInline(state)
|
|
273
|
+
state.pre = { lang: '', text: '' }
|
|
274
|
+
}
|
|
275
|
+
break
|
|
276
|
+
case 'a':
|
|
277
|
+
if (!closing) {
|
|
278
|
+
state.linkHrefs.push(attrs.href ?? '')
|
|
279
|
+
state.inline += '['
|
|
280
|
+
} else {
|
|
281
|
+
const href = state.linkHrefs.pop() ?? ''
|
|
282
|
+
state.inline += `](${href})`
|
|
283
|
+
}
|
|
284
|
+
break
|
|
285
|
+
case 'img': {
|
|
286
|
+
const src = attrs['data-collection-asset-src'] ?? attrs.src ?? ''
|
|
287
|
+
const target = src.startsWith('data:') ? '' : src
|
|
288
|
+
state.inline += ``
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
case 'figure':
|
|
292
|
+
flushInline(state)
|
|
293
|
+
break
|
|
294
|
+
case 'figcaption':
|
|
295
|
+
if (!closing) flushInline(state)
|
|
296
|
+
else {
|
|
297
|
+
const caption = state.inline.replace(/[ \t]+/g, ' ').trim()
|
|
298
|
+
state.inline = ''
|
|
299
|
+
if (caption) emitBlock(state, [`*${caption}*`])
|
|
300
|
+
}
|
|
301
|
+
break
|
|
302
|
+
case 'blockquote':
|
|
303
|
+
flushInline(state)
|
|
304
|
+
state.quoteDepth = Math.max(0, state.quoteDepth + (closing ? -1 : 1))
|
|
305
|
+
break
|
|
306
|
+
case 'ul':
|
|
307
|
+
case 'ol':
|
|
308
|
+
flushInline(state)
|
|
309
|
+
if (!closing) {
|
|
310
|
+
state.lists.push({ type: name, index: 0, itemBlockEmitted: true })
|
|
311
|
+
} else {
|
|
312
|
+
state.lists.pop()
|
|
313
|
+
}
|
|
314
|
+
break
|
|
315
|
+
case 'li': {
|
|
316
|
+
flushInline(state)
|
|
317
|
+
const list = currentList(state)
|
|
318
|
+
if (list && !closing) {
|
|
319
|
+
list.index += 1
|
|
320
|
+
list.itemBlockEmitted = false
|
|
321
|
+
}
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
case 'table':
|
|
325
|
+
flushInline(state)
|
|
326
|
+
if (!closing) {
|
|
327
|
+
state.table = { rows: [], headerRows: 0, currentRow: null, inHeader: false }
|
|
328
|
+
} else {
|
|
329
|
+
emitTable(state)
|
|
330
|
+
}
|
|
331
|
+
break
|
|
332
|
+
case 'tr':
|
|
333
|
+
if (!state.table) break
|
|
334
|
+
if (!closing) state.table.currentRow = []
|
|
335
|
+
else {
|
|
336
|
+
if (state.table.currentRow) state.table.rows.push(state.table.currentRow)
|
|
337
|
+
state.table.currentRow = null
|
|
338
|
+
}
|
|
339
|
+
break
|
|
340
|
+
case 'td':
|
|
341
|
+
case 'th':
|
|
342
|
+
if (!state.table) break
|
|
343
|
+
if (closing) flushTableCell(state)
|
|
344
|
+
else state.inline = ''
|
|
345
|
+
break
|
|
346
|
+
default: {
|
|
347
|
+
const latex = attrs['data-latex']
|
|
348
|
+
if (!closing && typeof latex === 'string' && latex.length > 0) {
|
|
349
|
+
if ((attrs['data-type'] ?? '').includes('block')) {
|
|
350
|
+
flushInline(state)
|
|
351
|
+
emitBlock(state, [`$$${latex}$$`])
|
|
352
|
+
} else {
|
|
353
|
+
state.inline += `$${latex}$`
|
|
354
|
+
}
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
if (!TRANSPARENT_TAGS.has(name)) {
|
|
358
|
+
// Unknown elements are transparent: keep their text content.
|
|
359
|
+
}
|
|
360
|
+
break
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
flushInline(state)
|
|
366
|
+
if (state.pre) emitFence(state, state.pre.lang, state.pre.text)
|
|
367
|
+
emitTable(state)
|
|
368
|
+
return `${state.out.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`
|
|
369
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
escapeIcsText,
|
|
4
|
+
foldIcsLine,
|
|
5
|
+
generateIcsCalendar,
|
|
6
|
+
generateIcsEventExport,
|
|
7
|
+
generateIcsReply,
|
|
8
|
+
} from './generateIcs.js'
|
|
9
|
+
|
|
10
|
+
const BASE = {
|
|
11
|
+
uid: 'uid-123@pure.science',
|
|
12
|
+
timestamp: '2026-07-04T10:00:00.000Z',
|
|
13
|
+
title: 'Project kickoff',
|
|
14
|
+
startsAt: '2026-07-10T09:00:00.000Z',
|
|
15
|
+
endsAt: '2026-07-10T10:00:00.000Z',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('generateIcsCalendar', () => {
|
|
19
|
+
it('emits a valid REQUEST with organizer and attendees', () => {
|
|
20
|
+
const ics = generateIcsCalendar({
|
|
21
|
+
...BASE,
|
|
22
|
+
method: 'REQUEST',
|
|
23
|
+
sequence: 1,
|
|
24
|
+
description: 'Agenda:\n- intro; goals, scope',
|
|
25
|
+
location: 'Room 4',
|
|
26
|
+
organizer: { email: 'adam@pure.science', name: 'Adam' },
|
|
27
|
+
attendees: [
|
|
28
|
+
{ email: 'boss@pure.science', name: 'Boss', rsvp: true, partstat: 'NEEDS-ACTION' },
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(ics).toContain('BEGIN:VCALENDAR')
|
|
33
|
+
expect(ics).toContain('METHOD:REQUEST')
|
|
34
|
+
expect(ics).toContain('UID:uid-123@pure.science')
|
|
35
|
+
expect(ics).toContain('SEQUENCE:1')
|
|
36
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
37
|
+
expect(ics).toContain('DTEND:20260710T100000Z')
|
|
38
|
+
expect(ics).toContain('DTSTAMP:20260704T100000Z')
|
|
39
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
40
|
+
expect(ics).toContain('DESCRIPTION:Agenda:\\n- intro\\; goals\\, scope')
|
|
41
|
+
expect(ics).toContain('ORGANIZER;CN=Adam:mailto:adam@pure.science')
|
|
42
|
+
expect(ics).toContain(
|
|
43
|
+
'ATTENDEE;CN=Boss;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:boss@pure.science',
|
|
44
|
+
)
|
|
45
|
+
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true)
|
|
46
|
+
// CRLF line endings throughout.
|
|
47
|
+
expect(ics.includes('\n')).toBe(true)
|
|
48
|
+
expect(ics.split('\r\n').some(line => line.includes('\n'))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('emits DATE values for all-day events', () => {
|
|
52
|
+
const ics = generateIcsCalendar({
|
|
53
|
+
...BASE,
|
|
54
|
+
method: 'REQUEST',
|
|
55
|
+
allDay: true,
|
|
56
|
+
startsAt: '2026-07-10',
|
|
57
|
+
endsAt: '2026-07-11',
|
|
58
|
+
})
|
|
59
|
+
expect(ics).toContain('DTSTART;VALUE=DATE:20260710')
|
|
60
|
+
expect(ics).toContain('DTEND;VALUE=DATE:20260711')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('defaults CANCEL to STATUS:CANCELLED', () => {
|
|
64
|
+
const ics = generateIcsCalendar({ ...BASE, method: 'CANCEL', sequence: 3 })
|
|
65
|
+
expect(ics).toContain('METHOD:CANCEL')
|
|
66
|
+
expect(ics).toContain('STATUS:CANCELLED')
|
|
67
|
+
expect(ics).toContain('SEQUENCE:3')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('includes RRULE when provided', () => {
|
|
71
|
+
const ics = generateIcsCalendar({
|
|
72
|
+
...BASE,
|
|
73
|
+
method: 'REQUEST',
|
|
74
|
+
rrule: 'FREQ=WEEKLY;INTERVAL=2;COUNT=5',
|
|
75
|
+
})
|
|
76
|
+
expect(ics).toContain('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects invalid dates', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
generateIcsCalendar({ ...BASE, method: 'REQUEST', startsAt: 'garbage' }),
|
|
82
|
+
).toThrow(/Invalid ICS date-time/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('omits METHOD and forces no STATUS for a plain export (no method)', () => {
|
|
86
|
+
const ics = generateIcsCalendar({ ...BASE })
|
|
87
|
+
expect(ics).toContain('BEGIN:VCALENDAR')
|
|
88
|
+
expect(ics).toContain('END:VCALENDAR')
|
|
89
|
+
expect(ics).toContain('BEGIN:VEVENT')
|
|
90
|
+
expect(ics).toContain('END:VEVENT')
|
|
91
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
92
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
93
|
+
expect(ics).toContain('DTEND:20260710T100000Z')
|
|
94
|
+
expect(ics).not.toContain('METHOD:')
|
|
95
|
+
expect(ics).not.toContain('STATUS:')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('generateIcsEventExport', () => {
|
|
100
|
+
it('produces a plain VCALENDAR with the event and no METHOD', () => {
|
|
101
|
+
const ics = generateIcsEventExport({
|
|
102
|
+
...BASE,
|
|
103
|
+
description: 'Agenda',
|
|
104
|
+
location: 'Room 4',
|
|
105
|
+
})
|
|
106
|
+
expect(ics).toContain('BEGIN:VEVENT')
|
|
107
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
108
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
109
|
+
expect(ics).toContain('LOCATION:Room 4')
|
|
110
|
+
expect(ics).not.toContain('METHOD:')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('generateIcsReply', () => {
|
|
115
|
+
it('answers an invite with the attendee partstat and echoed sequence', () => {
|
|
116
|
+
const ics = generateIcsReply({
|
|
117
|
+
uid: 'uid-123@pure.science',
|
|
118
|
+
sequence: 2,
|
|
119
|
+
timestamp: '2026-07-04T10:00:00.000Z',
|
|
120
|
+
title: 'Project kickoff',
|
|
121
|
+
startsAt: '2026-07-10T09:00:00.000Z',
|
|
122
|
+
endsAt: '2026-07-10T10:00:00.000Z',
|
|
123
|
+
organizer: { email: 'boss@pure.science' },
|
|
124
|
+
attendee: { email: 'adam@pure.science', name: 'Adam' },
|
|
125
|
+
response: 'accepted',
|
|
126
|
+
})
|
|
127
|
+
expect(ics).toContain('METHOD:REPLY')
|
|
128
|
+
expect(ics).toContain('SEQUENCE:2')
|
|
129
|
+
expect(ics).toContain(
|
|
130
|
+
'ATTENDEE;CN=Adam;PARTSTAT=ACCEPTED:mailto:adam@pure.science',
|
|
131
|
+
)
|
|
132
|
+
expect(ics).toContain('ORGANIZER:mailto:boss@pure.science')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('formatting helpers', () => {
|
|
137
|
+
it('escapes TEXT values per RFC 5545', () => {
|
|
138
|
+
expect(escapeIcsText('a,b;c\\d\ne')).toBe('a\\,b\\;c\\\\d\\ne')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('folds long lines with leading-space continuations', () => {
|
|
142
|
+
const folded = foldIcsLine(`SUMMARY:${'x'.repeat(200)}`)
|
|
143
|
+
const lines = folded.split('\r\n')
|
|
144
|
+
expect(lines.length).toBeGreaterThan(2)
|
|
145
|
+
expect(lines[0]?.length).toBe(75)
|
|
146
|
+
for (const continuation of lines.slice(1)) {
|
|
147
|
+
expect(continuation.startsWith(' ')).toBe(true)
|
|
148
|
+
expect(continuation.length).toBeLessThanOrEqual(75)
|
|
149
|
+
}
|
|
150
|
+
// Unfolding restores the original.
|
|
151
|
+
expect(folded.replace(/\r\n /g, '')).toBe(`SUMMARY:${'x'.repeat(200)}`)
|
|
152
|
+
})
|
|
153
|
+
})
|