@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.
Files changed (119) hide show
  1. package/package.json +89 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +57 -3
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -16,6 +16,46 @@ body {
16
16
  max-width: 30rem;
17
17
  string-set: doctitle content(text);
18
18
  }
19
+ /* Metadata eyebrow above the title — driven purely by theme tokens. */
20
+ .doc-title::before {
21
+ content: var(--eyebrow-text, '');
22
+ display: var(--eyebrow-display, none);
23
+ font: 600 8pt / 1.3 var(--sans);
24
+ letter-spacing: 0.14em;
25
+ text-transform: uppercase;
26
+ color: var(--accent);
27
+ margin: 0 0 calc(var(--rhythm) * 0.6);
28
+ }
29
+ /* Drop cap on the opening paragraph — inert unless the theme enables it.
30
+ THREE separate rules on purpose: Paged.js crashes on comma-lists of
31
+ ::first-letter selectors ("item doesn't belong to list"). */
32
+ .doc-title + p::first-letter {
33
+ float: var(--drop-cap-float, none);
34
+ font-family: var(--drop-cap-font, inherit);
35
+ font-size: var(--drop-cap-size, 1em);
36
+ line-height: var(--drop-cap-lh, inherit);
37
+ font-weight: var(--drop-cap-weight, inherit);
38
+ padding: var(--drop-cap-pad, 0);
39
+ color: var(--drop-cap-ink, inherit);
40
+ }
41
+ .doc-title + section > p:first-child::first-letter {
42
+ float: var(--drop-cap-float, none);
43
+ font-family: var(--drop-cap-font, inherit);
44
+ font-size: var(--drop-cap-size, 1em);
45
+ line-height: var(--drop-cap-lh, inherit);
46
+ font-weight: var(--drop-cap-weight, inherit);
47
+ padding: var(--drop-cap-pad, 0);
48
+ color: var(--drop-cap-ink, inherit);
49
+ }
50
+ section:first-of-type > p:first-child::first-letter {
51
+ float: var(--drop-cap-float, none);
52
+ font-family: var(--drop-cap-font, inherit);
53
+ font-size: var(--drop-cap-size, 1em);
54
+ line-height: var(--drop-cap-lh, inherit);
55
+ font-weight: var(--drop-cap-weight, inherit);
56
+ padding: var(--drop-cap-pad, 0);
57
+ color: var(--drop-cap-ink, inherit);
58
+ }
19
59
  .authors {
20
60
  font: 500 11.5pt/1.4 var(--sans);
21
61
  margin: 0 0 0.4rem;
@@ -83,6 +123,10 @@ h2 {
83
123
  line-height: 1.15;
84
124
  margin: calc(var(--rhythm) * 1.5) 0 calc(var(--rhythm) * 0.4);
85
125
  string-set: sectitle content(text);
126
+ /* Optional accent rule beneath section headings (theme token). */
127
+ border-bottom: var(--heading-rule-width, 0) solid var(--accent);
128
+ padding-bottom: var(--heading-rule-pad, 0);
129
+ max-width: var(--measure);
86
130
  }
87
131
  section:not([data-type='abstract']):not([data-type='frontmatter']):not(
88
132
  [data-type='backmatter']
@@ -1,5 +1,10 @@
1
1
  import { buildPrintHtmlDocument } from '../printDocument.js'
2
- import { normalizePureRenderHtml } from './normalize.js'
2
+ import {
3
+ injectCoverSection,
4
+ injectGeneratedToc,
5
+ normalizePureRenderHtml,
6
+ wrapLooseBodyRun,
7
+ } from './normalize.js'
3
8
  import { buildPureRenderStylesheet } from './styles.js'
4
9
  import type { PureRenderRequest } from './types.js'
5
10
 
@@ -22,6 +27,28 @@ export function buildPureRenderHtmlDocument(options: {
22
27
  )
23
28
  }
24
29
 
30
+ // Theme-driven structure: cover first (title block onto its own named
31
+ // page), then a generated table of contents from the normalized
32
+ // article's sections (ids are assigned during normalization).
33
+ const setting = options.request.theme?.setting
34
+ let bodyHtml = normalized.html
35
+ if (setting?.coverPage) {
36
+ bodyHtml = injectCoverSection(bodyHtml, {
37
+ author: setting.coverAuthor,
38
+ date: setting.coverDate,
39
+ })
40
+ }
41
+ // Contents implies a cover: without one, a toc page before the title
42
+ // reads as clutter rather than structure.
43
+ if (setting?.coverPage && setting?.toc) {
44
+ bodyHtml = injectGeneratedToc(bodyHtml)
45
+ }
46
+ if (options.request.theme?.page?.columns === 2) {
47
+ // Columns attach to body sections; unsectioned (writer) articles need
48
+ // their loose body run wrapped so there is a section to columnize.
49
+ bodyHtml = wrapLooseBodyRun(bodyHtml)
50
+ }
51
+
25
52
  return buildPrintHtmlDocument({
26
53
  rootPath: options.rootPath,
27
54
  title: options.title ?? normalized.title,
@@ -30,6 +57,6 @@ export function buildPureRenderHtmlDocument(options: {
30
57
  theme: options.request.theme,
31
58
  }),
32
59
  bodyClass: `pure-render pure-render-${options.request.profile}`,
33
- bodyHtml: normalized.html,
60
+ bodyHtml,
34
61
  })
35
62
  }
@@ -1,4 +1,5 @@
1
1
  export { compilePrintTheme } from './compiler.js'
2
+ export { sanitizeBodyForPureRenderExport } from './sanitizeExportBody.js'
2
3
  export {
3
4
  buildBookPureRenderRequest,
4
5
  buildWriterPureRenderRequest,
@@ -46,7 +47,9 @@ export {
46
47
  } from './samples.js'
47
48
  export {
48
49
  inspectPureRenderVisualLayout,
50
+ relevantPagedSnapshotPages,
49
51
  visualIssuesForMetrics,
52
+ type PureRenderSnapshotRole,
50
53
  type PureRenderVisualCandidate,
51
54
  type PureRenderVisualDecision,
52
55
  type PureRenderVisualInspection,
@@ -149,6 +149,173 @@ function parseHtml(html: string): Document {
149
149
  return new DOMParser().parseFromString(html || '', 'text/html')
150
150
  }
151
151
 
152
+ const TOC_EXCLUDED_TYPES = new Set([
153
+ 'abstract',
154
+ 'toc',
155
+ 'cover',
156
+ 'references',
157
+ 'endnotes',
158
+ 'acknowledgments',
159
+ 'frontmatter',
160
+ 'backmatter',
161
+ 'part',
162
+ ])
163
+
164
+ /**
165
+ * Insert a structural cover section (its own named page via CSS) BEFORE
166
+ * the title block. The cover shows a CLONE of the title plus optional
167
+ * author/date lines — the document keeps its own title afterwards.
168
+ * Idempotent.
169
+ */
170
+ export function injectCoverSection(
171
+ articleHtml: string,
172
+ options: { author?: string; date?: string } = {},
173
+ ): string {
174
+ const doc = parseHtml(articleHtml)
175
+ const article = doc.body.querySelector('article')
176
+ if (!article) return articleHtml
177
+ if (article.querySelector('section[data-type="cover"]')) return articleHtml
178
+ const title = article.querySelector('.doc-title')
179
+ if (!title) return articleHtml
180
+
181
+ const cover = doc.createElement('section')
182
+ cover.setAttribute('data-type', 'cover')
183
+ const coverTitle = doc.createElement('p')
184
+ coverTitle.setAttribute('class', 'cover-title')
185
+ coverTitle.textContent = title.textContent?.replace(/\s+/g, ' ').trim() ?? ''
186
+ cover.appendChild(coverTitle)
187
+ const author = options.author?.trim()
188
+ if (author) {
189
+ const line = doc.createElement('p')
190
+ line.setAttribute('class', 'cover-author')
191
+ line.textContent = author
192
+ cover.appendChild(line)
193
+ }
194
+ const date = options.date?.trim()
195
+ if (date) {
196
+ const line = doc.createElement('p')
197
+ line.setAttribute('class', 'cover-date')
198
+ line.textContent = date
199
+ cover.appendChild(line)
200
+ }
201
+ // The article opens with the cover; the title block stays in the flow.
202
+ article.insertBefore(cover, article.firstChild)
203
+ return article.outerHTML
204
+ }
205
+
206
+ /**
207
+ * Generate a table of contents from the article's top-level sections and
208
+ * insert it before the first body section. Idempotent (an existing toc
209
+ * section wins); a no-op when there is nothing to list.
210
+ */
211
+ export function injectGeneratedToc(articleHtml: string): string {
212
+ const doc = parseHtml(articleHtml)
213
+ const article = doc.body.querySelector('article')
214
+ if (!article) return articleHtml
215
+ if (article.querySelector('section[data-type="toc"]')) return articleHtml
216
+
217
+ const entries: { id: string; title: string }[] = []
218
+ let anchor: Element | null = null
219
+ // Child traversal instead of :scope selectors (not supported everywhere).
220
+ // Writer articles keep h2s as LOOSE children (no section wrappers), so
221
+ // both shapes are handled.
222
+ for (const child of Array.from(article.children)) {
223
+ if (child.tagName === 'SECTION') {
224
+ const type = child.getAttribute('data-type')
225
+ if (type && TOC_EXCLUDED_TYPES.has(type)) continue
226
+ const heading = Array.from(child.children).find(
227
+ node => node.tagName === 'H2',
228
+ )
229
+ const id = child.getAttribute('id') ?? heading?.getAttribute('id') ?? ''
230
+ const title = heading?.textContent?.replace(/\s+/g, ' ').trim() ?? ''
231
+ if (!anchor) anchor = child
232
+ if (id && title) entries.push({ id, title })
233
+ } else if (child.tagName === 'H2') {
234
+ const id = child.getAttribute('id') ?? ''
235
+ const title = child.textContent?.replace(/\s+/g, ' ').trim() ?? ''
236
+ if (!anchor) anchor = child
237
+ if (id && title) entries.push({ id, title })
238
+ }
239
+ }
240
+ if (entries.length === 0 || !anchor) return articleHtml
241
+
242
+ const toc = doc.createElement('section')
243
+ toc.setAttribute('data-type', 'toc')
244
+ const heading = doc.createElement('h2')
245
+ heading.textContent = 'Contents'
246
+ const list = doc.createElement('ol')
247
+ for (const entry of entries) {
248
+ const item = doc.createElement('li')
249
+ const link = doc.createElement('a')
250
+ link.setAttribute('href', `#${entry.id}`)
251
+ // Label + flex dotted leader; the page number is the link's ::after
252
+ // (target-counter). CSS leader() is NOT implemented by Paged.js.
253
+ const label = doc.createElement('span')
254
+ label.setAttribute('class', 'toc-label')
255
+ label.textContent = entry.title
256
+ const leader = doc.createElement('span')
257
+ leader.setAttribute('class', 'toc-leader')
258
+ link.appendChild(label)
259
+ link.appendChild(leader)
260
+ item.appendChild(link)
261
+ list.appendChild(item)
262
+ }
263
+ toc.appendChild(heading)
264
+ toc.appendChild(list)
265
+ // Contents sits IMMEDIATELY after the cover when there is one; only
266
+ // coverless documents fall back to before-the-first-heading.
267
+ const cover = article.querySelector('section[data-type="cover"]')
268
+ if (cover) {
269
+ cover.after(toc)
270
+ } else {
271
+ anchor.before(toc)
272
+ }
273
+ return article.outerHTML
274
+ }
275
+
276
+ const BODY_RUN_PRELUDE_CLASSES = new Set([
277
+ 'doc-title',
278
+ 'authors',
279
+ 'affil',
280
+ 'keywords',
281
+ ])
282
+
283
+ /**
284
+ * Writer articles keep body content as LOOSE children (no sections), so
285
+ * per-section styling (columns) has nothing to attach to. Wrap the loose
286
+ * body run — everything after the title block / cover / toc — into one
287
+ * untyped section. Bails when the article is already sectioned.
288
+ */
289
+ export function wrapLooseBodyRun(articleHtml: string): string {
290
+ const doc = parseHtml(articleHtml)
291
+ const article = doc.body.querySelector('article')
292
+ if (!article) return articleHtml
293
+
294
+ const children = Array.from(article.children)
295
+ const bodyStart = children.findIndex(child => {
296
+ if (child.tagName === 'H1') return false
297
+ const cls = child.getAttribute('class') ?? ''
298
+ if (cls.split(/\s+/).some(name => BODY_RUN_PRELUDE_CLASSES.has(name))) {
299
+ return false
300
+ }
301
+ if (child.tagName === 'SECTION') {
302
+ const type = child.getAttribute('data-type')
303
+ // Front-matter sections stay outside the run.
304
+ return !(type && TOC_EXCLUDED_TYPES.has(type))
305
+ }
306
+ return true
307
+ })
308
+ if (bodyStart === -1) return articleHtml
309
+ const run = children.slice(bodyStart)
310
+ // Already sectioned (manuscript/book shapes) — nothing to do.
311
+ if (run.some(child => child.tagName === 'SECTION')) return articleHtml
312
+
313
+ const wrapper = doc.createElement('section')
314
+ run[0].before(wrapper)
315
+ for (const child of run) wrapper.appendChild(child)
316
+ return article.outerHTML
317
+ }
318
+
152
319
  function collectHardErrors(
153
320
  doc: Document,
154
321
  errors: PureRenderValidationError[],
@@ -0,0 +1,51 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { sanitizeBodyForPureRenderExport } from './sanitizeExportBody.js'
4
+ import { buildPureRenderHtmlDocument } from './document.js'
5
+
6
+ describe('sanitizeBodyForPureRenderExport', () => {
7
+ it('converts inline SVG (mermaid) to a data-URI image', () => {
8
+ // Note: happy-dom's text/html parser drops SVG children, so the
9
+ // style-content preservation inside the data URI can only be exercised in
10
+ // a real browser; here we pin the structural conversion.
11
+ const out = sanitizeBodyForPureRenderExport(
12
+ '<p>Before</p><svg aria-label="Flow chart"><style>.a{fill:red}</style><g style="stroke:blue"><rect/></g></svg><p>After</p>',
13
+ )
14
+ expect(out).not.toContain('<svg')
15
+ expect(out).not.toContain('<style')
16
+ expect(out).toContain('<img')
17
+ expect(out).toContain('data:image/svg+xml')
18
+ expect(out).toContain('alt="Flow chart"')
19
+ })
20
+
21
+ it('demotes body h1s to h2 with content and attributes preserved', () => {
22
+ expect(
23
+ sanitizeBodyForPureRenderExport('<h1 id="x">Mid <em>doc</em></h1>'),
24
+ ).toBe('<h2 id="x">Mid <em>doc</em></h2>')
25
+ })
26
+
27
+ it('strips inline style attributes and unwraps center', () => {
28
+ const out = sanitizeBodyForPureRenderExport(
29
+ '<p style="text-align: center">Centered</p><center><p>Old-school</p></center><script>x()</script>',
30
+ )
31
+ expect(out).toBe('<p>Centered</p><p>Old-school</p>')
32
+ })
33
+
34
+ it('sanitized ordinary-document body passes Pure Render hard validation', () => {
35
+ // Each of these previously made buildPureRenderHtmlDocument THROW and the
36
+ // export fail outright: a user-typed H1, a centered paragraph, a mermaid
37
+ // SVG with an embedded <style> element.
38
+ const body = sanitizeBodyForPureRenderExport(
39
+ '<h2>Intro</h2><h1>User heading</h1>' +
40
+ '<p style="text-align: center">Centered</p>' +
41
+ '<svg><style>.x{}</style><rect style="fill:red"/></svg>',
42
+ )
43
+ expect(() =>
44
+ buildPureRenderHtmlDocument({
45
+ rootPath: '/tmp/ws',
46
+ title: 'Doc',
47
+ request: { html: body, profile: 'writer' },
48
+ }),
49
+ ).not.toThrow()
50
+ })
51
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Make editor-produced body HTML acceptable to Pure Render's hard content
3
+ * validation (`collectHardErrors` in normalize.ts): any `[style]` attribute,
4
+ * any `<script|style|link|font|center>` element, and any body `<h1>` makes
5
+ * `buildPureRenderHtmlDocument` THROW — which turned "Export PDF" into a hard
6
+ * failure for perfectly ordinary documents (a user-typed H1, one centered
7
+ * paragraph, or a mermaid diagram whose rendered SVG carries a `<style>`
8
+ * element and inline styles).
9
+ *
10
+ * Apps call this on body content just before building the print document.
11
+ * Rules applied, in order:
12
+ *
13
+ * 1. Inline `<svg>` (mermaid renders) is converted to an `<img>` with a
14
+ * `data:image/svg+xml` URI. The SVG's internal styles are REQUIRED for it
15
+ * to draw correctly; inside a data URI they still render but are invisible
16
+ * to DOM validation.
17
+ * 2. Body `<h1>`s are demoted to `<h2>` — content preserved; the document
18
+ * title is rendered by the front matter, never body prose.
19
+ * 3. Inline `style` attributes are dropped (Pure Render owns presentation).
20
+ * 4. `<script|style|link|font>` are removed; `<center>` is unwrapped so its
21
+ * content survives.
22
+ */
23
+ export function sanitizeBodyForPureRenderExport(html: string): string {
24
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
25
+
26
+ doc.body.querySelectorAll('svg').forEach(svg => {
27
+ const serialized = new XMLSerializer().serializeToString(svg)
28
+ const img = doc.createElement('img')
29
+ img.setAttribute(
30
+ 'src',
31
+ `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`,
32
+ )
33
+ img.setAttribute('alt', svg.getAttribute('aria-label') || 'Diagram')
34
+ svg.replaceWith(img)
35
+ })
36
+
37
+ doc.body.querySelectorAll('h1').forEach(heading => {
38
+ const demoted = doc.createElement('h2')
39
+ for (const attr of Array.from(heading.attributes)) {
40
+ demoted.setAttribute(attr.name, attr.value)
41
+ }
42
+ while (heading.firstChild) demoted.appendChild(heading.firstChild)
43
+ heading.replaceWith(demoted)
44
+ })
45
+
46
+ doc.body
47
+ .querySelectorAll('[style]')
48
+ .forEach(element => element.removeAttribute('style'))
49
+
50
+ doc.body
51
+ .querySelectorAll('script, style, link, font')
52
+ .forEach(element => element.remove())
53
+ doc.body.querySelectorAll('center').forEach(element => {
54
+ const parent = element.parentNode
55
+ if (!parent) return
56
+ while (element.firstChild) parent.insertBefore(element.firstChild, element)
57
+ element.remove()
58
+ })
59
+
60
+ return doc.body.innerHTML
61
+ }
@@ -19,7 +19,23 @@ body{
19
19
  hyphens:var(--hyphenate);
20
20
  counter-reset:sec fig tbl ref eq;
21
21
  }
22
- .doc-title{ font:700 var(--t-title)/1.1 var(--display); margin:0 0 var(--rhythm); max-width:30rem; string-set:doctitle content(text); }
22
+ .doc-title{ font:700 var(--t-title)/1.1 var(--display); margin:0 0 var(--rhythm); max-width:min(var(--measure),100%); }
23
+ /* string-set MUST live in its own rule (Paged.js drops it otherwise). */
24
+ .doc-title{ string-set:doctitle content(text); }
25
+ /* Cover: a structural section on its own named page — the ONLY reliable
26
+ way to page-break out of a (possibly multicol) flow under Paged.js. */
27
+ section[data-type="cover"]{ page: cover; break-after:page; text-align:center; padding-top:34%; max-width:none; }
28
+ section[data-type="cover"] .cover-title{ font:700 var(--t-title)/1.08 var(--display); margin:0 auto calc(var(--rhythm)*1.2); max-width:none; text-align:center; }
29
+ section[data-type="cover"] .cover-author{ font:500 11.5pt/1.5 var(--sans); margin:0; max-width:none; text-align:center; }
30
+ section[data-type="cover"] .cover-date{ font:400 9.5pt/1.6 var(--sans); color:var(--muted); margin:.35rem 0 0; max-width:none; text-align:center; }
31
+ .doc-title::before{ content:var(--eyebrow-text,""); display:var(--eyebrow-display,none); font:600 8pt/1.3 var(--sans); letter-spacing:.14em; text-transform:uppercase; color:var(--accent); margin:0 0 calc(var(--rhythm)*.6); }
32
+ /* Drop cap: THREE separate rules on purpose — Paged.js' stylesheet
33
+ transformer crashes ("item doesn't belong to list", a css-tree List
34
+ error) on a comma-separated selector list where several selectors carry
35
+ a ::first-letter pseudo-element. Do NOT merge these. */
36
+ .doc-title + p::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
37
+ .doc-title + section > p:first-child::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
38
+ section:first-of-type > p:first-child::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
23
39
  .authors{ font:500 11.5pt/1.4 var(--sans); margin:0 0 .4rem; }
24
40
  .affil{ font:400 9pt/1.4 var(--sans); color:var(--muted); margin:0 0 calc(var(--rhythm)*1.2); }
25
41
  .affil sup,.authors sup{ font-size:.7em; }
@@ -32,9 +48,22 @@ section[data-type="abstract"] > h2::before{ content:none; }
32
48
  section[data-type="abstract"] p{ margin:0; font-size:9.8pt; text-indent:0; }
33
49
  .keywords{ font:400 8.6pt/1.4 var(--sans); color:var(--muted); margin:.6rem 0 var(--rhythm); }
34
50
  .keywords b{ color:var(--ink-soft); font-weight:600; }
35
- section,p,ul,ol,blockquote{ max-width:var(--measure); }
51
+ section,p,ul,ol,blockquote{ max-width:min(var(--measure),100%); }
52
+ /* Centre the text column on the page: the measure is narrower than the
53
+ page content box, and hugging the left margin leaves a lopsided right
54
+ margin. Two columns widen the article to two measures plus the gap. */
55
+ article{ max-width:min(calc((var(--measure) * var(--body-columns,1)) + (7mm * (var(--body-columns,1) - 1))), 100%); margin-inline:auto; }
56
+ /* Columns per BODY section (not on the article): title, cover, toc and
57
+ typed front/backmatter stay single-column, and page breaks from cover/
58
+ toc are real page breaks instead of column breaks. The wrapper spans
59
+ the full (two-measure) article; inner blocks cap at one measure per
60
+ column. */
61
+ article > section:not([data-type]){ column-count:var(--body-columns,1); column-gap:7mm; column-fill:auto; max-width:none; }
36
62
  h2,h3,h4{ font-family:var(--display); color:var(--ink); break-after:avoid-page; }
37
- h2{ font-size:var(--t-h2); font-weight:700; line-height:1.15; margin:calc(var(--rhythm)*1.5) 0 calc(var(--rhythm)*.4); string-set:sectitle content(text); }
63
+ h2{ font-size:var(--t-h2); font-weight:700; line-height:1.15; margin:calc(var(--rhythm)*1.5) 0 calc(var(--rhythm)*.4); border-bottom:var(--heading-rule-width,0) solid var(--accent); padding-bottom:var(--heading-rule-pad,0); max-width:min(var(--measure),100%); }
64
+ /* string-set MUST live in its own rule: Paged.js drops it when the same
65
+ declaration list continues with var()/min() declarations after it. */
66
+ h2{ string-set:sectitle content(text); }
38
67
  section:not([data-type="abstract"]):not([data-type="frontmatter"]):not([data-type="backmatter"]):not([data-type="toc"]):not([data-type="references"]):not([data-type="endnotes"]):not([data-type="acknowledgments"]):not([data-type="part"]){ counter-increment:sec; counter-reset:sub; }
39
68
  h2::before{ content:counter(sec) "\\00a0\\00a0"; color:var(--accent); }
40
69
  section[data-type="abstract"] > h2::before,
@@ -61,7 +90,9 @@ figcaption::before{ content:"Figure " attr(data-label) ".\\00a0"; font-family:va
61
90
  .table-wrap{ counter-increment:tbl; break-inside:avoid; margin:calc(var(--rhythm)*1.2) 0; }
62
91
  .table-wrap > .tcaption{ font:400 8.8pt/1.4 var(--serif); color:var(--ink-soft); margin:0 0 .45rem; }
63
92
  .table-wrap > .tcaption::before{ content:"Table " attr(data-label) ".\\00a0"; font-family:var(--sans); font-weight:var(--caption-label-weight); text-transform:var(--caption-label-transform); color:var(--ink); }
64
- table{ width:100%; font:400 9pt/1.35 var(--serif); }
93
+ table{ width:100%; font:400 9pt/1.35 var(--serif); border:var(--table-grid-width,0) solid var(--rule); }
94
+ td,th{ border-left:var(--table-grid-width,0) solid var(--table-row-rule-color); }
95
+ td:first-child,th:first-child{ border-left:0; }
65
96
  thead th{ font-family:var(--sans); font-weight:600; font-size:8.4pt; text-transform:var(--table-header-transform); letter-spacing:var(--table-header-letter-spacing); text-align:left; padding:var(--table-cell-padding-y) var(--table-cell-padding-x); color:var(--table-header-ink); background:var(--table-header-bg); border-bottom:var(--table-rule-width) solid var(--table-header-rule-color); }
66
97
  tbody td{ padding:var(--table-cell-padding-y) var(--table-cell-padding-x); border-bottom:var(--table-row-rule-width) solid var(--table-row-rule-color); }
67
98
  tbody tr:last-child td{ border-bottom:var(--table-rule-width) solid var(--rule); }
@@ -77,6 +108,17 @@ code,pre{ font-family:var(--mono); font-size:8.8pt; }
77
108
  pre{ background:#F5F6F8; border:1px solid var(--rule-soft); padding:.7rem .9rem; break-inside:avoid; white-space:pre-wrap; line-height:1.45; }
78
109
  a.xref-fig::after,a.xref-tbl::after,a.xref-eq::after{ content:none; }
79
110
  a.xref-sec::after{ content:target-counter(attr(href), sec); }
111
+ /* Contents: its OWN page between cover and body. Dotted leader is a flex
112
+ filler element — Paged.js does not implement CSS leader(). */
113
+ section[data-type="toc"]{ break-before:page; break-after:page; max-width:var(--measure); }
114
+ section[data-type="toc"] > h2{ counter-increment:none; }
115
+ section[data-type="toc"] > h2::before{ content:none; }
116
+ section[data-type="toc"] ol{ list-style:none; padding:0; margin:calc(var(--rhythm)*.6) 0 0; }
117
+ section[data-type="toc"] li{ margin:.5rem 0; }
118
+ section[data-type="toc"] a{ display:flex; align-items:baseline; gap:.5rem; color:var(--ink); }
119
+ section[data-type="toc"] .toc-label{ flex:0 1 auto; }
120
+ section[data-type="toc"] .toc-leader{ flex:1 1 auto; min-width:1.5rem; border-bottom:1px dotted var(--rule); transform:translateY(-.22em); }
121
+ section[data-type="toc"] a::after{ content:target-counter(attr(href), page); font-variant-numeric:tabular-nums; color:var(--muted); }
80
122
  section[data-type="references"]{ margin-top:calc(var(--rhythm)*1.5); }
81
123
  section[data-type="references"] > h2{ string-set:sectitle "References"; }
82
124
  section[data-type="references"] ol{ list-style:none; padding:0; margin:0; max-width:var(--measure); }
@@ -109,7 +151,7 @@ export const PROFILE_CSS: Record<PureRenderProfile, string> = {
109
151
  [data-variant="two-column"] figure[data-span="page"],[data-variant="two-column"] .table-wrap[data-span="page"]{ column-span:all; }
110
152
  `,
111
153
  writer: `
112
- @page{ size:A4; margin:25mm; @bottom-center{ content:counter(page); font:500 8.4pt var(--mono); color:var(--muted); } }
154
+ @page{ size:A4; margin:20mm 18mm; @bottom-center{ content:counter(page); font:500 8.4pt var(--mono); color:var(--muted); } }
113
155
  @page:first{ @top-left{content:none} @top-right{content:none} }
114
156
  .pure-render-writer{ --text-align:var(--theme-text-align,left); --hyphenate:var(--theme-hyphenate,manual); }
115
157
  .pure-render-writer p + p{ text-indent:0; margin-top:calc(var(--rhythm)*.55); }
@@ -145,6 +187,82 @@ figcaption::before{ content:"Figure " attr(data-label) ".\\00a0"; }
145
187
  `,
146
188
  }
147
189
 
190
+ /**
191
+ * Theme-conditional @page overrides (running header, folio, logo). These
192
+ * append AFTER the profile CSS so a later margin-box rule wins.
193
+ */
194
+ export function themePageOverridesCss(theme?: PureRenderTheme): string {
195
+ if (!theme) return ''
196
+ const parts: string[] = []
197
+ // User-set page margins (theme.page.margin, e.g. "20mm 18mm") override
198
+ // the profile's @page margins. Only simple length lists are accepted.
199
+ const pageMargin = theme.page?.margin?.trim()
200
+ if (pageMargin && /^[0-9.]+(mm|cm|in|pt)(\s+[0-9.]+(mm|cm|in|pt)){0,3}$/.test(pageMargin)) {
201
+ parts.push(`@page{ margin:${pageMargin}; }`)
202
+ }
203
+ if (!theme.setting) return parts.join('\n')
204
+ const runningHeader = theme.setting.runningHeader ?? 'none'
205
+ const customHeaderText = theme.setting.runningHeaderText?.trim() ?? ''
206
+ if (runningHeader !== 'none' && (runningHeader !== 'custom' || customHeaderText)) {
207
+ const content =
208
+ runningHeader === 'custom'
209
+ ? JSON.stringify(customHeaderText)
210
+ : runningHeader === 'section'
211
+ ? 'string(sectitle)'
212
+ : 'string(doctitle)'
213
+ parts.push(
214
+ `@page{ @top-center{ content:${content}; font:600 8pt var(--sans); letter-spacing:.08em; text-transform:uppercase; color:var(--muted); } }`,
215
+ '@page:first{ @top-center{ content:none } }',
216
+ )
217
+ }
218
+ if (theme.setting.coverPage) {
219
+ const coverImage = theme.setting.coverImageDataUri?.trim()
220
+ const coverBackground = theme.setting.coverBackground?.trim() || 'var(--paper)'
221
+ const background =
222
+ coverImage && /^data:image\//.test(coverImage)
223
+ ? `url("${coverImage}") center / cover no-repeat, ${coverBackground}`
224
+ : coverBackground
225
+ // The cover's named page: full-bleed background, no page furniture.
226
+ parts.push(
227
+ `@page cover{ background:${background}; @top-left{ content:none } @top-center{ content:none } @top-right{ content:none } @bottom-left{ content:none } @bottom-center{ content:none } @bottom-right{ content:none } }`,
228
+ )
229
+ }
230
+ const folio = theme.setting.folio ?? 'page'
231
+ const folioSlot = theme.setting.folioSlot ?? 'bottom-center'
232
+ const folioStyle = theme.setting.folioStyle ?? 'plain'
233
+ if (folio === 'none') {
234
+ parts.push('@page{ @bottom-center{ content:none } }')
235
+ } else if (folioSlot !== 'bottom-center' || folioStyle !== 'plain') {
236
+ const folioContent =
237
+ folioStyle === 'of-total'
238
+ ? 'counter(page) " / " counter(pages)'
239
+ : 'counter(page)'
240
+ const folioColor = folioStyle === 'accent' ? 'var(--accent)' : 'var(--muted)'
241
+ const folioWeight = folioStyle === 'accent' ? '700' : '500'
242
+ if (folioSlot !== 'bottom-center') {
243
+ // Silence the profile's default folio before placing our own.
244
+ parts.push('@page{ @bottom-center{ content:none } }')
245
+ }
246
+ parts.push(
247
+ `@page{ @${folioSlot}{ content:${folioContent}; font:${folioWeight} 8.4pt var(--mono); letter-spacing:.04em; color:${folioColor}; } }`,
248
+ )
249
+ }
250
+ const logo = theme.setting.logoDataUri?.trim()
251
+ if (logo && /^data:image\//.test(logo)) {
252
+ const slot = theme.setting.logoSlot ?? 'top-right'
253
+ // Anchor to the slot's edge: Paged.js gives a lone occupied margin box
254
+ // a wide span, so a CENTERED background reads as page-centre.
255
+ const align = slot.endsWith('left') ? 'left' : 'right'
256
+ const box = `content:" "; background:url("${logo}") no-repeat ${align} center / contain;`
257
+ parts.push(
258
+ `@page{ @${slot}{ ${box} } }`,
259
+ // Profiles blank the top corners on page 1 — the logo stays.
260
+ `@page:first{ @${slot}{ ${box} } }`,
261
+ )
262
+ }
263
+ return parts.join('\n')
264
+ }
265
+
148
266
  export function buildPureRenderStylesheet(options: {
149
267
  profile: PureRenderProfile
150
268
  theme?: PureRenderTheme
@@ -158,6 +276,8 @@ export function buildPureRenderStylesheet(options: {
158
276
  BASE_CSS,
159
277
  '/* pure-render: profile */',
160
278
  PROFILE_CSS[options.profile],
279
+ '/* pure-render: theme page overrides */',
280
+ themePageOverridesCss(options.theme),
161
281
  '/* pure-render: flow */',
162
282
  FLOW_CSS,
163
283
  ].join('\n')