@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.8
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 +55 -1
- package/src/bridge/agentTypes.ts +21 -1
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/methods.d.mts +23 -0
- package/src/bridge/methods.mjs +23 -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/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/storage.d.mts +6 -6
- package/src/bridge/storage.test.ts +6 -6
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentDrawerPanel.tsx +2 -0
- package/src/components/agents/AgentMessageList.tsx +6 -1
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/agentPanelStyles.ts +3 -2
- package/src/components/agents/agentTypes.ts +2 -0
- package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +10 -1
- package/src/components/common/containers/AppHeader.tsx +22 -0
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- 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/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +87 -104
- package/src/theme/appIdentityCss.mjs +169 -231
- package/src/theme/appIdentityCss.ts +213 -233
|
@@ -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
|
+
}
|
|
@@ -3,48 +3,49 @@ import { appAccentForSlug, applyAppAccentToTheme } from './appAccents.js'
|
|
|
3
3
|
import { lightTheme } from './themes/light.js'
|
|
4
4
|
|
|
5
5
|
describe('appAccents', () => {
|
|
6
|
-
it('allocates identity
|
|
7
|
-
expect(appAccentForSlug('
|
|
8
|
-
expect(appAccentForSlug('
|
|
9
|
-
expect(appAccentForSlug('
|
|
10
|
-
expect(appAccentForSlug('
|
|
11
|
-
expect(appAccentForSlug('
|
|
12
|
-
expect(appAccentForSlug('
|
|
13
|
-
expect(appAccentForSlug('
|
|
14
|
-
expect(appAccentForSlug('
|
|
15
|
-
expect(appAccentForSlug('reports')?.accent).toBe('oklch(0.61 0.12 166)')
|
|
16
|
-
expect(appAccentForSlug('chart')?.accent).toBe('oklch(0.48 0.13 184)')
|
|
17
|
-
expect(appAccentForSlug('knowledge')?.accent).toBe('oklch(0.46 0.11 198)')
|
|
18
|
-
expect(appAccentForSlug('research')?.accent).toBe('oklch(0.63 0.12 222)')
|
|
19
|
-
expect(appAccentForSlug('sheets')?.accent).toBe('oklch(0.50 0.13 244)')
|
|
20
|
-
expect(appAccentForSlug('calendar')?.accent).toBe('oklch(0.63 0.13 258)')
|
|
21
|
-
expect(appAccentForSlug('desktop')?.accent).toBe('oklch(0.48 0.13 274)')
|
|
22
|
-
expect(appAccentForSlug('plan')?.accent).toBe('oklch(0.61 0.14 290)')
|
|
23
|
-
expect(appAccentForSlug('review')?.accent).toBe('oklch(0.50 0.15 308)')
|
|
24
|
-
expect(appAccentForSlug('tasks')?.accent).toBe('oklch(0.63 0.15 324)')
|
|
25
|
-
expect(appAccentForSlug('assistant')?.accent).toBe('oklch(0.50 0.15 340)')
|
|
26
|
-
expect(appAccentForSlug('people')?.accent).toBe('oklch(0.61 0.14 356)')
|
|
6
|
+
it('allocates an identity hue to every app on the 20-step ring', () => {
|
|
7
|
+
expect(appAccentForSlug('mail')?.accent).toBe('oklch(0.55 0.125 48)')
|
|
8
|
+
expect(appAccentForSlug('knowledge')?.accent).toBe('oklch(0.55 0.125 192)')
|
|
9
|
+
expect(appAccentForSlug('research')?.accent).toBe('oklch(0.55 0.125 210)')
|
|
10
|
+
expect(appAccentForSlug('whiteboard')?.accent).toBe('oklch(0.55 0.125 282)')
|
|
11
|
+
expect(appAccentForSlug('tasks')?.accent).toBe('oklch(0.55 0.125 318)')
|
|
12
|
+
expect(appAccentForSlug('teams')?.accent).toBe('oklch(0.55 0.125 12)')
|
|
13
|
+
expect(appAccentForSlug('people')?.accent).toBe('oklch(0.55 0.125 354)')
|
|
14
|
+
expect(appAccentForSlug('desktop')?.accent).toBe('oklch(0.55 0.125 264)')
|
|
27
15
|
})
|
|
28
16
|
|
|
29
|
-
it('
|
|
30
|
-
expect(appAccentForSlug('
|
|
31
|
-
'oklch(0.40 0.06 222)',
|
|
32
|
-
)
|
|
33
|
-
expect(appAccentForSlug('tasks')?.accentBlock).toBe('oklch(0.40 0.06 324)')
|
|
17
|
+
it('warms Mail toward terracotta with a dedicated tinted background', () => {
|
|
18
|
+
expect(appAccentForSlug('mail')?.accentMuted).toBe('oklch(0.93 0.05 48)')
|
|
34
19
|
})
|
|
35
20
|
|
|
36
|
-
it('
|
|
37
|
-
expect(appAccentForSlug('
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
expect(appAccentForSlug('
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
21
|
+
it('gives previously house-ink apps their own hue', () => {
|
|
22
|
+
expect(appAccentForSlug('writer')?.accent).toBe('oklch(0.55 0.125 102)')
|
|
23
|
+
expect(appAccentForSlug('book')?.accent).toBe('oklch(0.55 0.125 138)')
|
|
24
|
+
expect(appAccentForSlug('calendar')?.accent).toBe('oklch(0.55 0.125 246)')
|
|
25
|
+
expect(appAccentForSlug('reports')?.accent).toBe('oklch(0.55 0.125 156)')
|
|
26
|
+
expect(appAccentForSlug('slides')?.accent).toBe('oklch(0.55 0.125 84)')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('splits Sheets and Chart into distinct hues', () => {
|
|
30
|
+
expect(appAccentForSlug('sheets')?.accent).toBe('oklch(0.55 0.125 228)')
|
|
31
|
+
expect(appAccentForSlug('chart')?.accent).toBe('oklch(0.55 0.125 174)')
|
|
32
|
+
expect(appAccentForSlug('chart')?.accent).not.toBe(
|
|
33
|
+
appAccentForSlug('sheets')?.accent,
|
|
45
34
|
)
|
|
46
35
|
})
|
|
47
36
|
|
|
37
|
+
it('normalizes the optional pure- prefix', () => {
|
|
38
|
+
expect(appAccentForSlug('purefiles')?.accent).toBe('oklch(0.55 0.125 66)')
|
|
39
|
+
expect(appAccentForSlug('files')?.accent).toBe('oklch(0.55 0.125 66)')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('leaves unassigned apps neutral', () => {
|
|
43
|
+
expect(appAccentForSlug('chat')).toBeNull()
|
|
44
|
+
expect(appAccentForSlug('pages')).toBeNull()
|
|
45
|
+
expect(appAccentForSlug(null)).toBeNull()
|
|
46
|
+
expect(appAccentForSlug(undefined)).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
48
49
|
it('does not mutate the platform theme accent', () => {
|
|
49
50
|
expect(applyAppAccentToTheme(lightTheme, 'tasks')).toBe(lightTheme)
|
|
50
51
|
})
|