@asd20/ui-next 2.30.1 → 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.
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ import { parseFragment } from 'parse5'
2
+
3
+ // Only empty paragraphs/whitespace are editorially empty. Media, placeholders,
4
+ // links, tables, and otherwise unknown elements must remain visible to review.
5
+ function nonemptyHtml(html) {
6
+ const visit = node => {
7
+ if (node.nodeName === '#text') return Boolean(node.value.trim())
8
+ if (node.tagName && node.tagName !== 'p') return true
9
+ return (node.childNodes || []).some(visit)
10
+ }
11
+ return (parseFragment(String(html || '')).childNodes || []).some(visit)
12
+ }
13
+
14
+ function nonemptyJson(value) {
15
+ if (!value) return false
16
+ let doc = value
17
+ if (typeof value === 'string') {
18
+ try { doc = JSON.parse(value) } catch { return true }
19
+ }
20
+ if (doc?.version === 1 && Array.isArray(doc.blocks)) return doc.blocks.length > 0
21
+ const visit = node => {
22
+ if (node?.type === 'text') return Boolean(node.text?.trim())
23
+ if (!['doc', 'paragraph'].includes(node?.type)) return true
24
+ return (node.content || []).some(visit)
25
+ }
26
+ return visit(doc)
27
+ }
28
+
29
+ export function buildPublicationIntroduction(firstMessage, metaDescription = '') {
30
+ const message = firstMessage || {}
31
+ const lead = message.shortDescription || message.summary || metaDescription || ''
32
+ const excludedFields = ['heading', 'title', 'bodyContent', 'bodyJson', 'longDescription', 'images', 'callsToAction', 'detailLink', 'detailLinkLabel']
33
+ const unmappedFields = []
34
+ for (const field of ['bodyContent', 'longDescription']) {
35
+ if (nonemptyHtml(message[field])) unmappedFields.push(field)
36
+ }
37
+ if (nonemptyJson(message.bodyJson)) unmappedFields.push('bodyJson')
38
+ for (const field of ['images', 'callsToAction']) {
39
+ if (message[field]?.length) unmappedFields.push(field)
40
+ }
41
+ // Detail links direct readers from wayfinding messages to detailed pages.
42
+ // The handbook already is that destination; their omission is intentional.
43
+ // Only explicitly typed detail links qualify. Other links still need review.
44
+ if (message.links?.some(item => (item?.link ?? item)?.type !== 'Detail Link')) unmappedFields.push('links')
45
+ if (message.shortDescription && nonemptyHtml(message.summary) && message.summary !== lead) unmappedFields.push('summary')
46
+ return {
47
+ messageId: message.id ?? null, mode: 'first-summary-lead', lead,
48
+ detailLinkPolicy: 'omit-wayfinding',
49
+ leadSource: message.shortDescription || message.summary ? 'summary' : metaDescription ? 'metaDescription' : 'none',
50
+ excludedFields: [...excludedFields, 'links', ...(message.shortDescription ? ['summary'] : [])],
51
+ diagnostics: unmappedFields.length ? [{ code: 'introduction-content-unmapped', fields: unmappedFields }] : [],
52
+ }
53
+ }
package/templates.js CHANGED
@@ -27,3 +27,4 @@ export { default as Asd20SearchAppTemplate } from './src/components/templates/As
27
27
  export { default as Asd20WayfindingAlternateTemplate } from './src/components/templates/Asd20WayfindingAlternateTemplate/index.vue'
28
28
  export { default as Asd20WayfindingImageTemplate } from './src/components/templates/Asd20WayfindingImageTemplate/index.vue'
29
29
  export { default as Asd20WayfindingTemplate } from './src/components/templates/Asd20WayfindingTemplate/index.vue'
30
+ export { default as Asd20HandbookDocument } from './src/components/templates/Asd20HandbookDocument/index.vue'