@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ # [2.31.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.30.1...ui-next-v2.31.0) (2026-10-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * allow intentional handbook detail-link exclusions ([67475cc](https://github.com/academydistrict20/asd20-ui-next/commit/67475ccf3dd0f240746d5be421be21223b9bb4fa))
9
+
10
+
11
+ ### Features
12
+
13
+ * add contextual publication heading renderer ([9b3aca9](https://github.com/academydistrict20/asd20-ui-next/commit/9b3aca9f440aeb8cb39c4586e772857e24e7abaf))
14
+ * add opt-in document print layer ([541d4e6](https://github.com/academydistrict20/asd20-ui-next/commit/541d4e65db4b75a8ffff9c4df673bc42104e416a))
15
+ * checkpoint authenticated PDF staging and accessibility acceptance ([4319b1e](https://github.com/academydistrict20/asd20-ui-next/commit/4319b1edf132191c0d7d4d22a179182c819848e0))
16
+
17
+ ## [2.30.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.30.0...ui-next-v2.30.1) (2026-10-06)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * update styling on student records form ([3c9e73b](https://github.com/academydistrict20/asd20-ui-next/commit/3c9e73b669569aead47346fe52c5df432d1ecbf7))
23
+
3
24
  # [2.30.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.29.1...ui-next-v2.30.0) (2026-10-06)
4
25
 
5
26
 
package/README.md CHANGED
@@ -149,6 +149,10 @@ and runs `npm run verify:release` before publishing.
149
149
 
150
150
  ## Change Guidance
151
151
 
152
+ The [PDFService implementation overview](docs/projects/pdf-service/README.md)
153
+ records the shared PDF publishing proposal, Azure findings, contextual heading
154
+ requirements, affected repositories, and staged implementation work.
155
+
152
156
  Before changing this package, identify which public entry point is affected and
153
157
  which downstream repos consume it. Template, style/token, icon, input model, and
154
158
  helper changes are especially likely to require downstream website or app
package/components.js CHANGED
@@ -25,6 +25,8 @@ export { default as Asd20Viewport } from './src/components/atoms/Asd20Viewport/i
25
25
  export { default as Asd20WidgetViewport } from './src/components/atoms/Asd20WidgetViewport/index.vue'
26
26
 
27
27
  // Molecules
28
+ export { default as Asd20PrintHeader } from './src/components/molecules/Asd20PrintHeader/index.vue'
29
+ export { default as Asd20PrintContents } from './src/components/molecules/Asd20PrintContents/index.vue'
28
30
  export { default as Asd20Accordion } from './src/components/molecules/Asd20Accordion/index.vue'
29
31
  export { default as Asd20ActionMenuItem } from './src/components/molecules/Asd20ActionMenuItem/index.vue'
30
32
  export { default as Asd20BlockSchedule } from './src/components/molecules/Asd20BlockSchedule/index.vue'
package/helpers.js CHANGED
@@ -1,5 +1,12 @@
1
1
  export { default as arraySearch } from './src/helpers/arraySearch.js'
2
+ export { buildPublicationIntroduction } from './src/helpers/publicationIntroduction.js'
3
+ export { prepareDocumentPrint, printDocument, installDocumentPrintLifecycle } from './src/helpers/documentPrint.js'
2
4
  export { default as buildFileSearchUrl } from './src/helpers/buildFileSearchUrl.js'
5
+ export {
6
+ buildPublicationHeadingModels,
7
+ buildRichBodyRenderModel,
8
+ } from './src/components/atoms/Asd20RichBodyContent/messageDocumentRenderer.js'
9
+ export { createPublicationHeadingPrefix } from './src/components/atoms/Asd20RichBodyContent/headingContext.js'
3
10
  export { getCookie, setCookie } from './src/helpers/cookieRecipe.js'
4
11
  export { default as expandEvents } from './src/helpers/expandEvents.js'
5
12
  export { default as formatEventTime } from './src/helpers/formatEventTime.js'
@@ -52,3 +59,4 @@ export { default as queryPages } from './src/helpers/queryPages.js'
52
59
  export { default as queryPeople } from './src/helpers/queryPeople.js'
53
60
  export { default as sendEmail } from './src/helpers/sendEmail.js'
54
61
  export { default as sortOrgsByLevel } from './src/helpers/sortOrgsByLevel.js'
62
+ export { buildHandbookDocument, documentImageUrls } from './src/helpers/handbookDocument.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -41,6 +41,7 @@
41
41
  "focus-trap": "^6.9.4",
42
42
  "js-cookie": "3.0.8",
43
43
  "lodash": "^4.17.23",
44
+ "parse5": "8.0.0",
44
45
  "swiper": "12.2.0"
45
46
  },
46
47
  "peerDependencies": {
@@ -59,12 +59,15 @@
59
59
  v-html="longDescription"
60
60
  />
61
61
  <div
62
- v-if="bodyContent"
62
+ v-if="bodyContent || bodyJson"
63
63
  class="asd20-messaging__body-content"
64
64
  >
65
65
  <asd20-rich-body-content
66
66
  :body-content="bodyContent"
67
67
  :body-json="bodyJson"
68
+ :heading-level-offset="headingLevelOffset"
69
+ :heading-id-prefix="headingIdPrefix"
70
+ @headings-change="$emit('headings-change', $event)"
68
71
  />
69
72
  </div>
70
73
 
@@ -113,6 +116,8 @@ export default {
113
116
  images: { type: Array, default: () => [] },
114
117
  heading: { type: String, default: '' },
115
118
  headingTagName: { type: String, default: 'h2' },
119
+ headingLevelOffset: { type: Number, default: 0 },
120
+ headingIdPrefix: { type: String, default: '' },
116
121
  shortDescription: { type: String, default: '' },
117
122
  longDescription: { type: String, default: '' },
118
123
  bodyContent: { type: String, default: '' },
@@ -0,0 +1,255 @@
1
+ import { defaultTreeAdapter, parseFragment, serialize, serializeOuter } from 'parse5'
2
+
3
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
4
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
5
+
6
+ // Each publication provides a complete, immutable plan before descendants render.
7
+ export const publicationHeadingPlanKey = Symbol('asd20-publication-heading-plan')
8
+ export const contextualAccordionItemKey = Symbol('asd20-contextual-accordion-item')
9
+
10
+ export function createPublicationHeadingPrefix(documentId, occurrenceId) {
11
+ const encode = value => Array.from(new TextEncoder().encode(String(value)),
12
+ byte => byte.toString(16).padStart(2, '0')).join('')
13
+ return `pub-${encode(documentId)}-topic-${encode(occurrenceId)}`
14
+ }
15
+
16
+ export function normalizeHeadingContext(context = {}) {
17
+ const supplied = context.levelOffset === undefined ? 0 : context.levelOffset
18
+ const valid = Number.isInteger(supplied) && supplied >= 0 && supplied <= 5
19
+ const idPrefix = typeof context.idPrefix === 'string' ? context.idPrefix : ''
20
+ return {
21
+ ...context,
22
+ levelOffset: valid ? supplied : 0,
23
+ idPrefix,
24
+ active: supplied !== 0 || Boolean(idPrefix),
25
+ invalidOffset: Boolean(context.invalidOffset) || !valid,
26
+ }
27
+ }
28
+
29
+ function textLabel(node) {
30
+ if (node.nodeName === '#text') return node.value
31
+ if (['script', 'style', 'template'].includes(node.tagName)) return ''
32
+ if (node.tagName === 'br') return ' '
33
+ return (node.childNodes || []).map(textLabel).join('')
34
+ }
35
+
36
+ function getAttribute(node, name) {
37
+ return node.attrs?.find(attr => attr.name === name)?.value || ''
38
+ }
39
+
40
+ function setAttribute(node, name, value) {
41
+ const existing = node.attrs.find(attr => attr.name === name)
42
+ if (existing) existing.value = value
43
+ else node.attrs.push({ name, value })
44
+ }
45
+
46
+ function fragmentTarget(href, documentUrl) {
47
+ let hash = href.startsWith('#') ? href : ''
48
+ if (!hash && documentUrl) {
49
+ try {
50
+ const base = new URL(documentUrl)
51
+ const url = new URL(href, base)
52
+ if (url.origin === base.origin && url.pathname === base.pathname &&
53
+ url.search === base.search) hash = url.hash
54
+ } catch (_error) { /* Ordinary links retain the existing URL policy. */ }
55
+ }
56
+ if (!hash || hash === '#') return null
57
+ try { return decodeURIComponent(hash.slice(1)) } catch (_error) { return hash.slice(1) }
58
+ }
59
+
60
+ export function createHeadingSession(inputContext = {}) {
61
+ const context = normalizeHeadingContext(inputContext)
62
+ const prefix = context.idPrefix || 'body'
63
+ const entries = []
64
+ const headings = []
65
+ const diagnostics = []
66
+ const references = []
67
+ const headingStack = []
68
+ const accordionHeadings = new Map()
69
+ const report = (code, sourceKey, detail = '') => {
70
+ if (!diagnostics.some(row => row.code === code && row.sourceKey === sourceKey && row.detail === detail)) {
71
+ diagnostics.push({ code, sourceKey, detail })
72
+ }
73
+ }
74
+ if (context.invalidOffset) report('invalid-heading-offset', prefix)
75
+ if (!context.idPrefix) report('heading-namespace-missing', prefix)
76
+ if (!context.idPlan) report('document-heading-plan-unavailable', prefix)
77
+
78
+ function anchor(sourcePath, sourceId, candidate, kind = 'anchor') {
79
+ const key = `${prefix}:${sourcePath}`
80
+ entries.push({ key, sourcePath, sourceId: sourceId || null, candidate, prefix, kind })
81
+ if (sourceId && /\s/.test(sourceId)) report('invalid-source-anchor', key, sourceId)
82
+ const planned = context.idPlan?.idsBySourceKey[key]
83
+ if (context.idPlan && !planned) report('heading-plan-mismatch', key)
84
+ return { key, id: planned || sourceId || candidate }
85
+ }
86
+
87
+ function heading({ authoredLevel, label, sourcePath, sourceId = null,
88
+ kind = 'detail', accordionItemId = null }) {
89
+ const { key, id } = anchor(sourcePath, sourceId,
90
+ `${prefix}-${sourcePath}`, 'heading')
91
+ const requestedLevel = authoredLevel + context.levelOffset
92
+ const renderedLevel = Math.min(6, requestedLevel)
93
+ const codes = []
94
+ const add = code => { codes.push(code); report(code, key) }
95
+ if (requestedLevel > 6) add('heading-overflow')
96
+ if (authoredLevel === 1 && context.levelOffset) add('authored-h1-in-composition')
97
+ const normalizedLabel = String(label || '').replace(/\s+/g, ' ').trim()
98
+ if (!normalizedLabel) add('empty-heading-label')
99
+ while (headingStack.length && headingStack.at(-1).requestedLevel >= requestedLevel) headingStack.pop()
100
+ let parent = headingStack.at(-1) || null
101
+ const accordionParent = accordionItemId ? accordionHeadings.get(accordionItemId) : null
102
+ if (kind === 'detail' && accordionParent && !parent?.accordionItemId) parent = accordionParent
103
+ if (kind === 'detail' && accordionParent && requestedLevel <= accordionParent.requestedLevel) {
104
+ add('accordion-body-level-conflict')
105
+ }
106
+ // The containing message/topic title is one level above detail H2.
107
+ const parentLevel = parent?.requestedLevel ?? (1 + context.levelOffset)
108
+ if (requestedLevel > parentLevel + 1) add('heading-level-gap')
109
+ const descriptor = {
110
+ key, id, sourceId, sourcePath, kind, label: normalizedLabel,
111
+ authoredLevel, requestedLevel, renderedLevel,
112
+ overflow: requestedLevel > 6,
113
+ parentHeadingId: parent?.id || null, accordionItemId, aliasIds: [], diagnostics: codes,
114
+ }
115
+ headings.push(descriptor)
116
+ headingStack.push(descriptor)
117
+ if (kind === 'accordion-item') accordionHeadings.set(accordionItemId, descriptor)
118
+ return descriptor
119
+ }
120
+
121
+ function scope(render) {
122
+ const previous = [...headingStack]
123
+ try { return render() } finally { headingStack.splice(0, headingStack.length, ...previous) }
124
+ }
125
+
126
+ function href(value, sourcePath) {
127
+ const target = fragmentTarget(value, context.documentUrl || context.idPlan?.documentUrl)
128
+ if (target === null) return value
129
+ const sourceKey = `${prefix}:${sourcePath}`
130
+ references.push({ target, prefix, sourceKey })
131
+ const mapped = context.idPlan?.fragmentsByTopic[prefix]?.[target]
132
+ return mapped ? `#${encodeURIComponent(mapped)}` : value
133
+ }
134
+
135
+ function html(value, fieldPath, renderPlaceholder = null, accordionItemId = null) {
136
+ const fragment = parseFragment(
137
+ defaultTreeAdapter.createElement('div', HTML_NAMESPACE, []), value,
138
+ { onParseError: error => report('legacy-html-parse-recovery', `${prefix}:${fieldPath}`, error.code) }
139
+ )
140
+ let headingOrdinal = 0
141
+ const replacements = new Map()
142
+ const walk = (node, path, depth) => {
143
+ if (node.namespaceURI !== HTML_NAMESPACE) {
144
+ (node.childNodes || []).forEach((child, index) => walk(child, `${path}-${index}`, depth + 1))
145
+ return
146
+ }
147
+ const sourceId = getAttribute(node, 'id')
148
+ if (HEADING_TAGS.has(node.tagName)) {
149
+ const descriptor = heading({ authoredLevel: Number(node.tagName[1]),
150
+ label: textLabel(node), sourcePath: `html-${fieldPath}-h-${headingOrdinal++}`,
151
+ sourceId: sourceId || null, accordionItemId })
152
+ node.tagName = `h${descriptor.renderedLevel}`
153
+ node.nodeName = node.tagName
154
+ setAttribute(node, 'id', descriptor.id)
155
+ } else if (sourceId) {
156
+ const allocated = anchor(`html-${fieldPath}-element-${path}-id`, sourceId,
157
+ `${prefix}-html-${fieldPath}-element-${path}`)
158
+ setAttribute(node, 'id', allocated.id)
159
+ }
160
+ const name = node.tagName === 'a' ? getAttribute(node, 'name') : ''
161
+ if (name) {
162
+ // Identical id/name attributes represent one browser target, not two.
163
+ const allocated = name === sourceId
164
+ ? { id: getAttribute(node, 'id') }
165
+ : anchor(`html-${fieldPath}-element-${path}-name`, name,
166
+ `${prefix}-html-${fieldPath}-element-${path}-name`)
167
+ setAttribute(node, 'name', allocated.id)
168
+ }
169
+ const link = node.tagName === 'a' ? getAttribute(node, 'href') : ''
170
+ if (link) setAttribute(node, 'href', href(link, `html-${fieldPath}-element-${path}-href`))
171
+ const blockType = getAttribute(node, 'data-cc-block')
172
+ if (blockType) {
173
+ const nonempty = (node.childNodes || []).some(child =>
174
+ child.nodeName !== '#text' || child.value.trim())
175
+ if (nonempty) report('nonempty-body-placeholder', `${prefix}:${fieldPath}-${path}`)
176
+ else if (depth === 0 && node.tagName === 'div' && renderPlaceholder) {
177
+ const block = renderPlaceholder(blockType, getAttribute(node, 'data-block-id'), path)
178
+ if (block) replacements.set(node, { ...block, rootId: getAttribute(node, 'id') })
179
+ else report('unresolved-body-placeholder', `${prefix}:${fieldPath}-${path}`)
180
+ } else report('nested-body-placeholder', `${prefix}:${fieldPath}-${path}`)
181
+ }
182
+ (node.childNodes || []).forEach((child, index) => walk(child, `${path}-${index}`, depth + 1))
183
+ }
184
+ fragment.childNodes.forEach((node, index) => walk(node, String(index), 0))
185
+ const nodes = []
186
+ let pendingHtml = ''
187
+ const flush = () => {
188
+ if (pendingHtml) nodes.push({ type: 'html', html: pendingHtml })
189
+ pendingHtml = ''
190
+ }
191
+ for (const node of fragment.childNodes) {
192
+ if (replacements.has(node)) { flush(); nodes.push(replacements.get(node)) }
193
+ else pendingHtml += serializeOuter(node)
194
+ }
195
+ flush()
196
+ return { html: serialize(fragment), nodes }
197
+ }
198
+
199
+ return { context, entries, headings, diagnostics, references, report, anchor, heading, href, html, scope }
200
+ }
201
+
202
+ export function buildHeadingIdPlan(sessions, reservedIds = []) {
203
+ const entries = sessions.flatMap(session => session.entries)
204
+ const sourceCounts = new Map()
205
+ const used = new Set(reservedIds)
206
+ for (const entry of entries) {
207
+ if (entry.sourceId) {
208
+ sourceCounts.set(entry.sourceId, (sourceCounts.get(entry.sourceId) || 0) + 1)
209
+ used.add(entry.sourceId)
210
+ }
211
+ }
212
+ const idsBySourceKey = Object.create(null)
213
+ const fragmentsByTopic = Object.create(null)
214
+ const diagnostics = []
215
+ const sourceTargets = new Map()
216
+ for (const entry of entries) {
217
+ const preserve = entry.sourceId && sourceCounts.get(entry.sourceId) === 1 && !reservedIds.includes(entry.sourceId)
218
+ let id = preserve ? entry.sourceId : entry.candidate
219
+ if (!preserve) {
220
+ const candidate = id
221
+ let suffix = 2
222
+ while (used.has(id)) id = `${candidate}--${suffix++}`
223
+ }
224
+ used.add(id)
225
+ idsBySourceKey[entry.key] = id
226
+ if (entry.sourceId) {
227
+ const local = fragmentsByTopic[entry.prefix] || (fragmentsByTopic[entry.prefix] = Object.create(null))
228
+ if (Object.hasOwn(local, entry.sourceId)) {
229
+ diagnostics.push({ code: 'ambiguous-anchor', sourceKey: entry.key, detail: entry.sourceId })
230
+ } else local[entry.sourceId] = id
231
+ const targets = sourceTargets.get(entry.sourceId) || []
232
+ targets.push(id)
233
+ sourceTargets.set(entry.sourceId, targets)
234
+ }
235
+ }
236
+ for (const session of sessions) {
237
+ const prefix = session.context.idPrefix || 'body'
238
+ const local = fragmentsByTopic[prefix] || (fragmentsByTopic[prefix] = Object.create(null))
239
+ for (const reference of session.references) {
240
+ if (Object.hasOwn(local, reference.target)) continue
241
+ const targets = sourceTargets.get(reference.target)
242
+ if (targets?.length === 1) local[reference.target] = targets[0]
243
+ else if (targets?.length) diagnostics.push({ code: 'ambiguous-anchor', sourceKey: reference.sourceKey, detail: reference.target })
244
+ else if (reservedIds.includes(reference.target)) local[reference.target] = reference.target
245
+ else diagnostics.push({ code: 'unresolved-anchor', sourceKey: reference.sourceKey, detail: reference.target })
246
+ }
247
+ }
248
+ for (const local of Object.values(fragmentsByTopic)) Object.freeze(local)
249
+ return Object.freeze({
250
+ documentUrl: sessions[0]?.context.documentUrl || '',
251
+ idsBySourceKey: Object.freeze(idsBySourceKey),
252
+ fragmentsByTopic: Object.freeze(fragmentsByTopic),
253
+ diagnostics: Object.freeze(diagnostics.map(row => Object.freeze(row))),
254
+ })
255
+ }
@@ -14,11 +14,15 @@
14
14
  />
15
15
  <asd20-body-accordion
16
16
  v-else-if="node.type === 'accordion'"
17
+ :id="node.rootId || undefined"
17
18
  :key="`accordion-${node.block.id}-${index}`"
18
19
  :items="node.block.items"
20
+ :heading-level-offset="headingLevelOffset"
21
+ :heading-id-prefix="headingIdPrefix"
19
22
  />
20
23
  <asd20-tableau-embed
21
24
  v-else-if="node.type === 'tableau'"
25
+ :id="node.rootId || undefined"
22
26
  :key="`tableau-${node.block.id}-${index}`"
23
27
  :src="node.block.src"
24
28
  :height="node.block.height"
@@ -45,17 +49,26 @@ import {
45
49
  buildRichBodyRenderModel,
46
50
  containsBodyBlockPlaceholder,
47
51
  } from './messageDocumentRenderer'
52
+ import { publicationHeadingPlanKey } from './headingContext'
48
53
 
49
54
  export default {
50
55
  name: 'Asd20RichBodyContent',
51
56
 
52
57
  components: { Asd20BodyAccordion, Asd20TableauEmbed },
53
58
 
59
+ inject: {
60
+ publicationHeadingPlan: { from: publicationHeadingPlanKey, default: null },
61
+ },
62
+
54
63
  props: {
55
64
  bodyContent: { type: String, default: '' },
56
65
  bodyJson: { type: [String, Object], default: '' },
66
+ headingLevelOffset: { type: Number, default: 0 },
67
+ headingIdPrefix: { type: String, default: '' },
57
68
  },
58
69
 
70
+ emits: ['headings-change'],
71
+
59
72
  data: () => ({
60
73
  hasMounted: false,
61
74
  }),
@@ -65,9 +78,18 @@ export default {
65
78
  return buildRichBodyRenderModel({
66
79
  bodyContent: this.bodyContent,
67
80
  bodyJson: this.bodyJson,
81
+ headingContext: {
82
+ levelOffset: this.headingLevelOffset,
83
+ idPrefix: this.headingIdPrefix,
84
+ idPlan: this.publicationHeadingPlan?.(),
85
+ },
68
86
  })
69
87
  },
70
88
  renderNodes() {
89
+ if (this.renderModel.headingContextApplied) {
90
+ return !this.hasMounted && this.renderModel.status === 'legacy'
91
+ ? this.renderModel.initialNodes : this.renderModel.nodes
92
+ }
71
93
  const html = this.bodyContent || ''
72
94
  // Keep SSR and the initial client render on the same raw HTML markup,
73
95
  // then enhance block placeholders after hydration.
@@ -82,6 +104,15 @@ export default {
82
104
  },
83
105
  },
84
106
 
107
+ watch: {
108
+ renderModel: {
109
+ immediate: true,
110
+ handler(model) {
111
+ this.$emit('headings-change', { headings: model.headings, diagnostics: model.headingDiagnostics })
112
+ },
113
+ },
114
+ },
115
+
85
116
  mounted() {
86
117
  this.hasMounted = true
87
118
  this.applyListColumnExceptions()