@asd20/ui-next 2.30.0 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,12 @@
1
1
  <template>
2
- <div class="asd20-template-handbook">
2
+ <div
3
+ class="asd20-template-handbook"
4
+ :data-asd20-document-root="documentPrint ? publicationDocumentId : undefined"
5
+ >
3
6
  <asd20-skip-to to="#handbook-content" />
4
7
 
5
8
  <asd20-site-navigation
9
+ :data-asd20-print-hide="documentPrint ? '' : undefined"
6
10
  v-bind="siteNavigationSearchProps"
7
11
  :navigation="navigation"
8
12
  :action-items="actionItems"
@@ -15,6 +19,7 @@
15
19
  />
16
20
 
17
21
  <asd20-notification-group
22
+ :data-asd20-print-hide="documentPrint ? '' : undefined"
18
23
  :notifications="activeNotificationsByType.banner"
19
24
  group-type="banner"
20
25
  @dismiss="$emit('dismiss-notification', $event)"
@@ -42,7 +47,18 @@
42
47
  </template>
43
48
  </asd20-page-header>
44
49
 
50
+ <slot name="document-actions" />
51
+
52
+ <asd20-print-header
53
+ v-if="documentPrint"
54
+ :organization-title="organization?.title || organization?.name || ''"
55
+ :canonical-url="publicationDocumentUrl"
56
+ v-bind="printMetadata"
57
+ title=""
58
+ />
59
+
45
60
  <asd20-notification-group
61
+ :data-asd20-print-hide="documentPrint ? '' : undefined"
46
62
  :notifications="activeNotificationsByType.inline"
47
63
  group-type="inline"
48
64
  @dismiss="$emit('dismiss-notification', $event)"
@@ -67,34 +83,34 @@
67
83
  >
68
84
  Topics
69
85
  </button>
70
- <h2 class="asd20-template-handbook__index-heading">
86
+ <div class="asd20-template-handbook__index-heading">
71
87
  Topics
72
- </h2>
88
+ </div>
73
89
  <ol
74
90
  id="handbook-topics"
75
91
  :class="{ 'is-open': topicsOpen }"
76
92
  >
77
93
  <li
78
- v-for="message in topicMessages"
79
- :key="message.id"
94
+ v-for="entry in topicEntries"
95
+ :key="entry.key"
80
96
  >
81
97
  <div class="asd20-template-handbook__topic-row">
82
98
  <button
83
99
  class="asd20-template-handbook__topic-button"
84
100
  type="button"
85
- :aria-current="activeMessage && activeMessage.id === message.id ? 'true' : null"
86
- @click="selectTopic(message.id)"
101
+ :aria-current="activeEntry && activeEntry.key === entry.key ? 'true' : null"
102
+ @click="selectTopic(entry.key)"
87
103
  >
88
- {{ message.heading || message.title }}
104
+ {{ entry.message.heading || entry.message.title }}
89
105
  </button>
90
106
  <button
91
- v-if="subtopicsFor(message.id).length"
107
+ v-if="subtopicsFor(entry.key).length"
92
108
  class="asd20-template-handbook__expand-button"
93
109
  type="button"
94
- :aria-expanded="expandedTopicId === message.id"
95
- :aria-controls="`handbook-subtopics-${message.id}`"
96
- :aria-label="`${expandedTopicId === message.id ? 'Hide' : 'Show'} sections in ${message.heading || message.title}`"
97
- @click="toggleTopic(message.id)"
110
+ :aria-expanded="expandedTopicId === entry.key"
111
+ :aria-controls="entry.subtopicsId"
112
+ :aria-label="`${expandedTopicId === entry.key ? 'Hide' : 'Show'} sections in ${entry.message.heading || entry.message.title}`"
113
+ @click="toggleTopic(entry.key)"
98
114
  >
99
115
  <asd20-icon
100
116
  name="chevron"
@@ -104,19 +120,19 @@
104
120
  </button>
105
121
  </div>
106
122
  <ol
107
- v-if="subtopicsFor(message.id).length"
108
- v-show="expandedTopicId === message.id"
109
- :id="`handbook-subtopics-${message.id}`"
123
+ v-if="subtopicsFor(entry.key).length"
124
+ v-show="expandedTopicId === entry.key"
125
+ :id="entry.subtopicsId"
110
126
  class="asd20-template-handbook__subtopics"
111
127
  >
112
128
  <li
113
- v-for="subtopic in subtopicsFor(message.id)"
114
- :key="subtopic.index"
129
+ v-for="subtopic in subtopicsFor(entry.key)"
130
+ :key="subtopic.id"
115
131
  >
116
132
  <button
117
133
  class="asd20-template-handbook__subtopic-button"
118
134
  type="button"
119
- @click="selectSubtopic(message.id, subtopic.index)"
135
+ @click="selectSubtopic(entry.key, subtopic.id)"
120
136
  >
121
137
  {{ subtopic.label }}
122
138
  </button>
@@ -126,30 +142,46 @@
126
142
  </ol>
127
143
  </nav>
128
144
 
145
+ <p
146
+ v-if="publicationDiagnostics.length"
147
+ class="asd20-template-handbook__publication-review"
148
+ role="status"
149
+ >
150
+ This handbook has content that needs publication review before PDF export.
151
+ </p>
152
+
153
+ <asd20-print-contents
154
+ v-if="documentPrint"
155
+ :headings="printHeadings"
156
+ />
157
+
129
158
  <article
130
- v-for="message in topicMessages"
131
- :key="message.id"
132
- :data-message-id="message.id"
159
+ v-for="entry in topicEntries"
160
+ :key="entry.key"
161
+ :data-message-id="entry.message.id"
162
+ :data-topic-key="entry.key"
133
163
  class="asd20-template-handbook__topic"
134
- :class="{ 'is-active': activeMessage && activeMessage.id === message.id }"
164
+ :class="{ 'is-active': activeEntry && activeEntry.key === entry.key }"
135
165
  >
136
- <h2>
137
- {{ message.heading || message.title }}
166
+ <h2 :id="entry.titleId">
167
+ {{ entry.message.heading || entry.message.title }}
138
168
  </h2>
139
169
  <div
140
- v-if="topicSummary(message)"
170
+ v-if="topicSummary(entry.message)"
141
171
  class="asd20-template-handbook__summary"
142
- v-html="topicSummary(message)"
172
+ v-html="topicSummary(entry.message)"
143
173
  />
144
174
  <hr
145
- v-if="topicSummary(message)"
175
+ v-if="topicSummary(entry.message)"
146
176
  class="asd20-template-handbook__summary-divider"
147
177
  />
148
178
  <asd20-messaging
149
- v-bind="message"
179
+ v-bind="entry.message"
150
180
  heading=""
151
181
  short-description=""
152
182
  detail-link=""
183
+ :heading-level-offset="1"
184
+ :heading-id-prefix="entry.key"
153
185
  />
154
186
  </article>
155
187
  <p v-if="!topicMessages.length">
@@ -177,7 +209,12 @@ import Asd20SiteNavigation from '../../../components/organisms/Asd20SiteNavigati
177
209
  import Asd20NotificationGroup from '../../../components/organisms/Asd20NotificationGroup'
178
210
  import Asd20QuicklinksMenu from '../../../components/organisms/Asd20QuicklinksMenu'
179
211
  import Asd20PageFooter from '../../../components/organisms/Asd20PageFooter'
212
+ import Asd20PrintHeader from '../../molecules/Asd20PrintHeader'
213
+ import Asd20PrintContents from '../../molecules/Asd20PrintContents'
214
+ import { buildHandbookDocument } from '../../../helpers/handbookDocument'
215
+ import { installDocumentPrintLifecycle } from '../../../helpers/documentPrint'
180
216
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
217
+ import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
181
218
 
182
219
  export default {
183
220
  name: 'Asd20HandbookTemplate',
@@ -190,63 +227,71 @@ export default {
190
227
  Asd20NotificationGroup,
191
228
  Asd20QuicklinksMenu,
192
229
  Asd20PageFooter,
230
+ Asd20PrintHeader,
231
+ Asd20PrintContents,
193
232
  },
194
233
  mixins: [pageTemplateMixin],
234
+ provide() {
235
+ return { [publicationHeadingPlanKey]: () => this.publicationHeadings.idPlan }
236
+ },
237
+ props: {
238
+ documentPrint: { type: Boolean, default: false },
239
+ printMetadata: { type: Object, default: () => ({}) },
240
+ publicationDocumentId: { type: String, default: 'handbook' },
241
+ publicationDocumentUrl: { type: String, default: '' },
242
+ publicationReservedIds: { type: Array, default: () => [] },
243
+ },
195
244
  data: () => ({
196
245
  selectedMessageId: null,
197
246
  expandedTopicId: null,
198
- subtopicsByMessageId: {},
199
247
  topicsOpen: false,
200
248
  }),
201
249
  computed: {
202
- handbookLead() {
203
- return this.firstMessage?.shortDescription || this.firstMessage?.summary || this.metaDescription
250
+ handbookDocument() {
251
+ return buildHandbookDocument({
252
+ messages: this.messages, metaDescription: this.metaDescription,
253
+ documentId: this.publicationDocumentId, documentUrl: this.publicationDocumentUrl,
254
+ reservedIds: this.publicationReservedIds,
255
+ })
204
256
  },
205
- topicMessages() {
206
- return this.messages.slice(1)
257
+ introduction() { return this.handbookDocument.introduction },
258
+ publicationDiagnostics() { return this.handbookDocument.diagnostics },
259
+ printHeadings() { return this.handbookDocument.headings },
260
+ handbookLead() { return this.introduction.lead },
261
+ topicMessages() { return this.messages.slice(1) },
262
+ topicEntries() { return this.handbookDocument.entries },
263
+ publicationHeadings() { return this.handbookDocument.headingModels },
264
+ activeEntry() {
265
+ return this.topicEntries.find(entry => entry.key === this.selectedMessageId)
266
+ || this.topicEntries[0] || null
207
267
  },
208
268
  activeMessage() {
209
- return this.topicMessages.find(message => message.id === this.selectedMessageId)
210
- || this.topicMessages[0]
211
- || null
269
+ return this.activeEntry?.message || null
212
270
  },
213
271
  },
214
272
  watch: {
215
- messages: {
216
- handler() {
217
- this.$nextTick(() => this.refreshSubtopics())
218
- },
219
- deep: true,
220
- },
221
- },
222
- mounted() {
223
- this.$nextTick(() => this.refreshSubtopics())
273
+ documentPrint() { this.updatePrintLifecycle() },
274
+ messages: { deep: true, handler() { this.updatePrintLifecycle() } },
275
+ publicationDocumentId() { this.updatePrintLifecycle() },
224
276
  },
277
+ mounted() { this.updatePrintLifecycle() },
278
+ beforeUnmount() { this.stopPrintLifecycle?.() },
225
279
  methods: {
280
+ updatePrintLifecycle() {
281
+ this.stopPrintLifecycle?.()
282
+ this.stopPrintLifecycle = this.documentPrint ? installDocumentPrintLifecycle(this.$el) : null
283
+ },
226
284
  topicSummary(message) {
227
285
  return message.shortDescription || message.summary || ''
228
286
  },
229
- topicArticle(messageId) {
287
+ topicArticle(topicKey) {
230
288
  return Array.from(this.$el.querySelectorAll('.asd20-template-handbook__topic'))
231
- .find(article => article.dataset.messageId === String(messageId))
289
+ .find(article => article.dataset.topicKey === topicKey)
232
290
  },
233
- subtopicsFor(messageId) {
234
- return this.subtopicsByMessageId[messageId] || []
235
- },
236
- refreshSubtopics() {
237
- const nextSubtopics = {}
238
- this.topicMessages.forEach(message => {
239
- const article = this.topicArticle(message.id)
240
- if (!article) return
241
- const headings = Array.from(article.querySelectorAll('.asd20-rich-body-content h2'))
242
- const subtopics = headings
243
- .map((heading, index) => ({ index, label: heading.textContent.trim() }))
244
- .filter(subtopic => subtopic.label)
245
- if (subtopics.length) nextSubtopics[message.id] = subtopics
246
- })
247
- if (JSON.stringify(nextSubtopics) !== JSON.stringify(this.subtopicsByMessageId)) {
248
- this.subtopicsByMessageId = nextSubtopics
249
- }
291
+ subtopicsFor(topicKey) {
292
+ const index = this.topicEntries.findIndex(entry => entry.key === topicKey)
293
+ return this.publicationHeadings.models[index]?.headings
294
+ .filter(heading => heading.authoredLevel === 2 && heading.label) || []
250
295
  },
251
296
  toggleTopic(messageId) {
252
297
  this.expandedTopicId = this.expandedTopicId === messageId ? null : messageId
@@ -258,14 +303,21 @@ export default {
258
303
  await this.$nextTick()
259
304
  this.topicArticle(messageId)?.scrollIntoView({ block: 'start' })
260
305
  },
261
- async selectSubtopic(messageId, headingIndex) {
306
+ async selectSubtopic(messageId, headingId) {
262
307
  this.selectedMessageId = messageId
263
308
  this.expandedTopicId = messageId
264
309
  this.topicsOpen = false
265
310
  await this.$nextTick()
266
311
  const article = this.topicArticle(messageId)
267
- const headings = article?.querySelectorAll('.asd20-rich-body-content h2')
268
- headings?.[headingIndex]?.scrollIntoView({ block: 'start' })
312
+ const heading = Array.from(article?.querySelectorAll('[id]') || [])
313
+ .find(element => element.id === headingId)
314
+ const toggle = heading?.closest('.asd20-accordion')
315
+ ?.querySelector('[aria-expanded="false"][aria-controls]')
316
+ if (toggle) {
317
+ toggle.click()
318
+ await this.$nextTick()
319
+ }
320
+ heading?.scrollIntoView({ block: 'start' })
269
321
  },
270
322
  },
271
323
  }
@@ -411,7 +463,7 @@ export default {
411
463
  scroll-margin-top: space(2);
412
464
  }
413
465
 
414
- &__topic .asd20-rich-body-content h2 {
466
+ &__topic .asd20-rich-body-content h3 {
415
467
  scroll-margin-top: space(2);
416
468
  }
417
469
 
@@ -0,0 +1,104 @@
1
+ // Additive overrides: every content rule requires BOTH markers. The legacy
2
+ // _print.scss rules remain intact, including its existing unnamed page margin.
3
+ @media print {
4
+ @page asd20-document {
5
+ size: letter portrait;
6
+ margin: 0.5in;
7
+ @bottom-center {
8
+ content: 'Page ' counter(page) ' of ' counter(pages);
9
+ font-family: Arial, sans-serif;
10
+ font-size: 9pt;
11
+ color: #111;
12
+ }
13
+ }
14
+
15
+ html[data-asd20-print-profile='document'] [data-asd20-document-root] {
16
+ page: asd20-document;
17
+ display: block !important;
18
+ color: #111 !important;
19
+ font-size: 10pt !important;
20
+ line-height: 1.45 !important;
21
+ print-color-adjust: exact;
22
+ -webkit-print-color-adjust: exact;
23
+
24
+ [data-asd20-print-hide], .asd20-print-hide,
25
+ .asd20-template-handbook__index, .asd20-accordion__header button,
26
+ .asd20-accordion__header .asd20-badge,
27
+ .asd20-message-video__frame, .asd20-tableau-embed__frame,
28
+ iframe, video, tableau-viz {
29
+ display: none !important;
30
+ }
31
+
32
+ .asd20-print-only { display: block !important; }
33
+ .asd20-print-contents__label { font-weight: bold; }
34
+ .asd20-print-contents li { break-inside: avoid; }
35
+ .asd20-print-contents ol { list-style: none; margin-left: 0; padding-left: 0; }
36
+ .asd20-print-contents a { display: flex; justify-content: space-between; gap: 1em; }
37
+ .asd20-print-contents__page { flex: 0 0 5ch; text-align: right; font-variant-numeric: tabular-nums; }
38
+ .asd20-handbook-document > header h1 { margin-top: 0 !important; }
39
+
40
+ .asd20-template-handbook__layout,
41
+ .asd20-template-handbook__topic,
42
+ .asd20-messaging, .asd20-messaging__content,
43
+ .asd20-accordion__content, .asd20-accordion__content-inner,
44
+ .asd20-accordion__viewport, .tableWrapper {
45
+ display: block !important;
46
+ position: static !important;
47
+ overflow: visible !important;
48
+ transform: none !important;
49
+ height: auto !important;
50
+ max-height: none !important;
51
+ min-height: 0 !important;
52
+ opacity: 1 !important;
53
+ break-inside: auto !important;
54
+ transition: none !important;
55
+ }
56
+
57
+ .asd20-template-handbook__topic { margin: 1.5rem 0 !important; }
58
+ h1, h2, h3, h4, h5, h6, caption, .asd20-accordion__header {
59
+ break-after: avoid-page;
60
+ break-inside: avoid-page;
61
+ }
62
+ p { orphans: 3; widows: 3; }
63
+ blockquote { break-inside: auto; }
64
+ .asd20-print-keep { break-inside: avoid-page !important; }
65
+ .asd20-print-break-before { break-before: page; }
66
+
67
+ img, .message-image {
68
+ display: block !important;
69
+ max-width: 100% !important;
70
+ height: auto !important;
71
+ object-fit: contain !important;
72
+ break-inside: avoid-page;
73
+ }
74
+ .asd20-message-image-gallery__images {
75
+ display: block !important;
76
+ overflow: visible !important;
77
+ max-height: none !important;
78
+ }
79
+ .asd20-message-image-gallery__images > img { max-width: 100% !important; max-height: none !important; }
80
+ table {
81
+ width: 100% !important;
82
+ min-width: 0 !important;
83
+ table-layout: fixed;
84
+ border-collapse: collapse;
85
+ break-inside: auto;
86
+ font-size: 9pt;
87
+ }
88
+ thead { display: table-header-group; }
89
+ tfoot { display: table-footer-group; }
90
+ tr { break-inside: avoid-page; }
91
+ th, td { min-width: 0 !important; overflow-wrap: anywhere; padding: 0.3em !important; border: 1px solid #888; }
92
+ th { background: #eee !important; color: #111 !important; }
93
+ a { overflow-wrap: anywhere; text-decoration: underline !important; break-inside: auto; }
94
+ a[href='/'] { display: inline !important; }
95
+ a::after { content: none !important; display: none !important; }
96
+ a[data-asd20-print-source]::after {
97
+ content: ' (' attr(data-asd20-print-source) ')' !important;
98
+ display: inline !important;
99
+ font-size: 9pt;
100
+ }
101
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; }
102
+ ul, ol { columns: auto !important; }
103
+ }
104
+ }
@@ -1,3 +1,5 @@
1
+ @use './document-print';
2
+
1
3
  @media print {
2
4
  @page {
3
5
  margin: 0.5in;
@@ -0,0 +1,184 @@
1
+ import { nextTick } from 'vue'
2
+
3
+ const blocks = new WeakMap()
4
+ const sessions = new WeakMap()
5
+
6
+ // Component registration is private; preparation is reusable by any opted-in host.
7
+ export function registerDocumentPrintBlock(element, prepare) {
8
+ blocks.set(element, prepare)
9
+ return () => blocks.delete(element)
10
+ }
11
+
12
+ function setTemporaryAttribute(element, name, value, undo) {
13
+ const prior = element.getAttribute(name)
14
+ undo.push(() => prior === null ? element.removeAttribute(name) : element.setAttribute(name, prior))
15
+ value === null ? element.removeAttribute(name) : element.setAttribute(name, value)
16
+ }
17
+
18
+ function prepareTables(root, undo, diagnostics) {
19
+ for (const table of root.querySelectorAll('table')) {
20
+ if (table.tHead || !table.tBodies.length) continue
21
+ const body = table.tBodies[0]
22
+ const rows = []
23
+ // A column header group is a contiguous leading run of all-TH rows. Row
24
+ // headers/mixed cells stay in tbody; never infer headers from bold styling.
25
+ for (const row of body.rows) {
26
+ if (!row.cells.length || !Array.from(row.cells).every(cell => cell.tagName === 'TH' &&
27
+ !['row', 'rowgroup'].includes(cell.getAttribute('scope')))) break
28
+ rows.push(row)
29
+ }
30
+ if (!rows.length) continue
31
+ if (rows.some((row, index) => Array.from(row.cells).some(cell => cell.rowSpan === 0 || cell.rowSpan + index > rows.length))) {
32
+ diagnostics.push({ code: 'table-header-span-unmapped' })
33
+ continue
34
+ }
35
+ const following = rows.at(-1).nextSibling
36
+ const head = table.ownerDocument.createElement('thead')
37
+ body.before(head)
38
+ rows.forEach(row => head.append(row))
39
+ undo.push(() => { rows.forEach(row => body.insertBefore(row, following)); head.remove() })
40
+ for (const row of rows) for (const cell of row.cells) {
41
+ if (!cell.hasAttribute('scope')) setTemporaryAttribute(cell, 'scope', cell.colSpan > 1 ? 'colgroup' : 'col', undo)
42
+ }
43
+ }
44
+ }
45
+
46
+ function prepareEmbeds(root, undo, diagnostics) {
47
+ const doc = root.ownerDocument
48
+ for (const embed of root.querySelectorAll('.asd20-message-video, .asd20-tableau-embed')) {
49
+ const video = embed.classList.contains('asd20-message-video')
50
+ const source = video ? embed.dataset.src || embed.querySelector('iframe')?.src : embed.dataset.printSrc
51
+ const description = video ? embed.dataset.descriptiveText || embed.querySelector('.asd20-message-video__description')?.textContent :
52
+ embed.querySelector('.asd20-tableau-embed__description')?.textContent
53
+ const title = video ? embed.dataset.title || embed.querySelector('iframe')?.title : 'Interactive data visualization'
54
+ const alternative = doc.createElement('div')
55
+ alternative.className = 'asd20-print-only asd20-print-embed-alternative'
56
+ const text = doc.createElement('p')
57
+ text.textContent = `${video ? 'Video' : 'Interactive data'}: ${title || 'Untitled'}. ${description ? '' : 'A descriptive text alternative has not been supplied.'}`
58
+ alternative.append(text)
59
+ if (!description?.trim()) diagnostics.push({ code: 'embed-description-missing', source: source || '' })
60
+ let url
61
+ try { url = new URL(source) } catch { /* Report a missing safe source below. */ }
62
+ if (url && ['https:', 'http:'].includes(url.protocol)) {
63
+ const link = doc.createElement('a')
64
+ link.href = url.href
65
+ link.textContent = `${video ? 'Watch video' : 'View interactive data'}: ${url.href}`
66
+ alternative.append(link)
67
+ } else diagnostics.push({ code: 'embed-source-missing' })
68
+ embed.append(alternative)
69
+ undo.push(() => alternative.remove())
70
+ }
71
+ for (const iframe of root.querySelectorAll('iframe')) {
72
+ if (!iframe.closest('.asd20-message-video, .asd20-tableau-embed')) diagnostics.push({ code: 'embed-alternative-unmapped', source: iframe.src })
73
+ }
74
+ }
75
+
76
+ function beginDocumentPrint(root) {
77
+ if (!root?.hasAttribute('data-asd20-document-root')) throw new TypeError('Document printing requires an explicit document root.')
78
+ const doc = root.ownerDocument
79
+ if (sessions.has(doc)) throw new Error('A document print session is already active.')
80
+ const undo = []
81
+ const diagnostics = []
82
+ const session = { diagnostics, restore() {
83
+ if (sessions.get(doc) !== session) return
84
+ sessions.delete(doc)
85
+ undo.reverse().forEach(restore => restore())
86
+ } }
87
+ sessions.set(doc, session)
88
+ try {
89
+ setTemporaryAttribute(doc.documentElement, 'data-asd20-print-profile', 'document', undo)
90
+ setTemporaryAttribute(root, 'data-asd20-print-prepared', '', undo)
91
+ for (const stamp of root.querySelectorAll('[data-asd20-print-generated-at]')) {
92
+ if (stamp.hasAttribute('data-asd20-print-fixed-generated-at')) continue
93
+ const prior = stamp.textContent
94
+ stamp.textContent = `Generated: ${new Intl.DateTimeFormat('en-US', {
95
+ timeZone: 'America/Denver', dateStyle: 'medium', timeStyle: 'long',
96
+ }).format(new Date())}`
97
+ undo.push(() => { stamp.textContent = prior })
98
+ }
99
+ for (const element of [root, ...root.querySelectorAll('*')]) {
100
+ const prepare = blocks.get(element)
101
+ if (prepare) undo.push(prepare())
102
+ }
103
+ prepareTables(root, undo, diagnostics)
104
+ prepareEmbeds(root, undo, diagnostics)
105
+ if (root.querySelector('[data-cc-block]')) diagnostics.push({ code: 'rich-block-unresolved' })
106
+ for (const link of root.querySelectorAll('a[href]')) {
107
+ const href = link.getAttribute('href')
108
+ if (!href || href.startsWith('#') || link.closest('.asd20-print-contents') || link.hasAttribute('data-asd20-print-no-url')) continue
109
+ const url = new URL(href, doc.baseURI)
110
+ if (!['https:', 'http:'].includes(url.protocol) || link.textContent.includes(url.href)) continue
111
+ setTemporaryAttribute(link, 'data-asd20-print-source', url.href, undo)
112
+ }
113
+ for (const image of root.querySelectorAll('img')) setTemporaryAttribute(image, 'loading', 'eager', undo)
114
+ return session
115
+ } catch (error) { session.restore(); throw error }
116
+ }
117
+
118
+ async function waitForAssets(root) {
119
+ await nextTick() // Includes legacy rich-block enhancement and print state updates.
120
+ const doc = root.ownerDocument
121
+ if (doc.fonts) {
122
+ await doc.fonts.ready
123
+ if (Array.from(doc.fonts).some(font => font.status === 'error')) throw new Error('A document font failed to load.')
124
+ }
125
+ await Promise.all(Array.from(root.querySelectorAll('img')).filter(image => !image.closest('[data-asd20-print-hide], .asd20-print-hide')).map(async image => {
126
+ if (typeof image.decode === 'function') await image.decode()
127
+ else if (!image.complete) await new Promise((resolve, reject) => {
128
+ image.addEventListener('load', resolve, { once: true })
129
+ image.addEventListener('error', reject, { once: true })
130
+ })
131
+ if (!image.naturalWidth) throw new Error(`A document image failed to load: ${image.getAttribute('src')}`)
132
+ }))
133
+ }
134
+
135
+ export async function prepareDocumentPrint(root, { timeoutMs = 15000 } = {}) {
136
+ await nextTick()
137
+ const session = beginDocumentPrint(root)
138
+ let timer
139
+ try {
140
+ await Promise.race([waitForAssets(root), new Promise((_resolve, reject) => {
141
+ timer = setTimeout(() => reject(new Error('Document asset readiness timed out.')), timeoutMs)
142
+ })])
143
+ if (!root.isConnected || sessions.get(root.ownerDocument) !== session) throw new Error('Document print preparation was cancelled.')
144
+ return session
145
+ } catch (error) { session.restore(); throw error }
146
+ finally { clearTimeout(timer) }
147
+ }
148
+
149
+ export async function printDocument(root, options) {
150
+ const session = await prepareDocumentPrint(root, options)
151
+ const win = root.ownerDocument.defaultView
152
+ let onAfterPrint
153
+ try {
154
+ const finished = new Promise(resolve => {
155
+ onAfterPrint = resolve
156
+ win.addEventListener('afterprint', onAfterPrint, { once: true })
157
+ })
158
+ win.print()
159
+ await finished
160
+ } finally {
161
+ win.removeEventListener('afterprint', onAfterPrint)
162
+ session.restore()
163
+ }
164
+ return session.diagnostics
165
+ }
166
+
167
+ // Native beforeprint cannot await fonts/images. It synchronously expands semantic
168
+ // blocks; controlled capture and the host's Print button must await preparation.
169
+ export function installDocumentPrintLifecycle(root) {
170
+ const win = root.ownerDocument.defaultView
171
+ let nativeSession
172
+ const before = () => {
173
+ if (!sessions.has(root.ownerDocument)) nativeSession = beginDocumentPrint(root)
174
+ }
175
+ const after = () => { nativeSession?.restore(); nativeSession = null }
176
+ win.addEventListener('beforeprint', before)
177
+ win.addEventListener('afterprint', after)
178
+ return () => {
179
+ win.removeEventListener('beforeprint', before)
180
+ win.removeEventListener('afterprint', after)
181
+ after()
182
+ sessions.get(root.ownerDocument)?.restore()
183
+ }
184
+ }
@@ -0,0 +1,44 @@
1
+ import { buildPublicationIntroduction } from './publicationIntroduction'
2
+ import { buildPublicationHeadingModels } from '../components/atoms/Asd20RichBodyContent/messageDocumentRenderer'
3
+ import { createPublicationHeadingPrefix } from '../components/atoms/Asd20RichBodyContent/headingContext'
4
+ import { parseFragment } from 'parse5'
5
+
6
+ export function documentImageUrls(html, baseUrl) {
7
+ const urls = new Set()
8
+ const walk = node => {
9
+ if (node.tagName === 'img') {
10
+ const src = node.attrs.find(attribute => attribute.name === 'src')?.value
11
+ if (src && !src.startsWith('data:')) urls.add(new URL(src, baseUrl).href)
12
+ }
13
+ for (const child of node.childNodes || []) walk(child)
14
+ }
15
+ walk(parseFragment(html || ''))
16
+ return [...urls]
17
+ }
18
+
19
+ // The online handbook and the isolated PDF view share ordering, exclusions and
20
+ // heading planning. Rendering a document never changes the authored messages.
21
+ export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [] } = {}) {
22
+ const counts = new Map()
23
+ const entries = messages.slice(1).map((message, index) => {
24
+ const identity = String(message.id ?? `position-${index}`)
25
+ const ordinal = counts.get(identity) || 0
26
+ counts.set(identity, ordinal + 1)
27
+ const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
28
+ return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
29
+ })
30
+ const headingModels = buildPublicationHeadingModels({
31
+ documentUrl,
32
+ bodies: entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key })),
33
+ reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
34
+ })
35
+ const introduction = buildPublicationIntroduction(messages[0], metaDescription)
36
+ return {
37
+ entries, introduction, headingModels,
38
+ headings: entries.flatMap((entry, index) => [
39
+ { id: entry.titleId, label: entry.message.heading || entry.message.title, authoredLevel: 2, renderedLevel: 2 },
40
+ ...(headingModels.models[index]?.headings || []),
41
+ ]),
42
+ diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics],
43
+ }
44
+ }