@asd20/ui-next 2.32.1 → 2.32.2

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.32.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.32.1...ui-next-v2.32.2) (2026-10-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * refine handbook template; fix interior handbook links; move ctas; move translation indicator ([89c8b63](https://github.com/academydistrict20/asd20-ui-next/commit/89c8b6308e81ac6a0005f0d0fc8dac0ec92ecf34))
9
+
3
10
  ## [2.32.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.32.0...ui-next-v2.32.1) (2026-10-12)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.32.1",
3
+ "version": "2.32.2",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -1116,7 +1116,7 @@ export function buildRichBodyRenderModel(input = {}) {
1116
1116
  }
1117
1117
 
1118
1118
  // This prepass is deliberately independent of Vue instances and mounted topics.
1119
- export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], documentUrl, linkResolverFactory } = {}) {
1119
+ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], reservedIdsFactory, documentUrl, linkResolverFactory } = {}) {
1120
1120
  const prefixes = new Set()
1121
1121
  const prepared = bodies.map(body => {
1122
1122
  const prefix = body.headingIdPrefix
@@ -1134,7 +1134,9 @@ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], d
1134
1134
  render(session)
1135
1135
  return { context, render, session }
1136
1136
  })
1137
- const headingPlan = buildHeadingIdPlan(prepared.map(row => row.session), reservedIds)
1137
+ const sourceIds = prepared.flatMap(row => row.session.entries.map(entry => entry.sourceId).filter(Boolean))
1138
+ const headingPlan = buildHeadingIdPlan(prepared.map(row => row.session),
1139
+ [...reservedIds, ...(reservedIdsFactory?.(sourceIds) || [])])
1138
1140
  const idPlan = linkResolverFactory
1139
1141
  ? Object.freeze({ ...headingPlan, linkResolver: linkResolverFactory(headingPlan) })
1140
1142
  : headingPlan
@@ -137,6 +137,7 @@
137
137
  class="asd20-template-handbook__index"
138
138
  aria-label="Handbook topics"
139
139
  >
140
+ <slot name="sidebar-status" />
140
141
  <button
141
142
  class="asd20-template-handbook__index-toggle"
142
143
  type="button"
@@ -234,15 +235,30 @@
234
235
  class="asd20-template-handbook__summary"
235
236
  v-html="topicSummary(entry.message)"
236
237
  />
238
+ <div
239
+ v-if="topicCallsToAction(entry.message).length"
240
+ class="asd20-template-handbook__calls-to-action"
241
+ >
242
+ <asd20-button
243
+ v-for="cta in topicCallsToAction(entry.message)"
244
+ :key="cta.label"
245
+ :label="cta.label"
246
+ :link="cta.url"
247
+ :data-asd20-handbook-link="cta.handbookTargetId || undefined"
248
+ bordered
249
+ transparent
250
+ />
251
+ </div>
237
252
  <hr
238
253
  v-if="topicSummary(entry.message)"
239
254
  class="asd20-template-handbook__summary-divider"
240
255
  />
241
256
  <asd20-messaging
242
- v-bind="topicMessage(entry.message)"
257
+ v-bind="entry.message"
243
258
  heading=""
244
259
  short-description=""
245
260
  detail-link=""
261
+ :calls-to-action="[]"
246
262
  :heading-level-offset="1"
247
263
  :heading-id-prefix="entry.key"
248
264
  />
@@ -373,11 +389,11 @@ export default {
373
389
  topicSummary(message) {
374
390
  return this.handbookDocument.linkResolver.html(message.shortDescription || message.summary || '')
375
391
  },
376
- topicMessage(message) {
377
- return { ...message, callsToAction: this.handbookDocument.linkResolver.callsToAction(message.callsToAction) }
392
+ topicCallsToAction(message) {
393
+ return this.handbookDocument.linkResolver.callsToAction(message.callsToAction)
378
394
  },
379
395
  entryForFragment(id) {
380
- return this.topicEntries.find(entry => entry.titleId === id ||
396
+ return this.topicEntries.find(entry => entry.titleId === id || entry.legacyTitleId === id ||
381
397
  Object.entries(this.publicationHeadings.idPlan.idsBySourceKey).some(([key, target]) => key.startsWith(entry.key + ':') && target === id))
382
398
  },
383
399
  onFragmentChange() {
@@ -388,6 +404,10 @@ export default {
388
404
  try { id = decodeURIComponent(String(fragment || '').replace(/^#/, '')) } catch { return false }
389
405
  const entry = this.entryForFragment(id)
390
406
  if (!entry) return false
407
+ if (id === entry.legacyTitleId) {
408
+ id = entry.titleId
409
+ window.history.replaceState(window.history.state, '', `#${encodeURIComponent(id)}`)
410
+ }
391
411
  const epoch = ++this.fragmentEpoch
392
412
  this.selectedMessageId = entry.key
393
413
  this.expandedTopicId = entry.key
@@ -529,7 +549,13 @@ export default {
529
549
  margin: 0 0 space(1);
530
550
  border: 0;
531
551
  border-top: 1px solid currentColor;
532
- opacity: 0.25;
552
+ }
553
+
554
+ &__calls-to-action {
555
+ display: flex;
556
+ flex-wrap: wrap;
557
+ gap: space(0.25);
558
+ margin-bottom: space(1);
533
559
  }
534
560
 
535
561
  &__index ol {
@@ -4,6 +4,15 @@ import { createPublicationHeadingPrefix } from '../components/atoms/Asd20RichBod
4
4
  import { parseFragment } from 'parse5'
5
5
  import { createHandbookLinkResolver } from './handbookLinks'
6
6
 
7
+ function topicSlug(message) {
8
+ // CMS slugs survive translation; remove their publishing-date suffix.
9
+ const label = message.slug
10
+ ? String(message.slug).replace(/-\d{4}-\d{2}-\d{2}$/, '')
11
+ : message.heading || message.title || 'topic'
12
+ return String(label).normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
13
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'topic'
14
+ }
15
+
7
16
  export function documentImageUrls(html, baseUrl) {
8
17
  const urls = new Set()
9
18
  const walk = node => {
@@ -26,15 +35,33 @@ export function buildHandbookDocument({ messages = [], metaDescription = '', doc
26
35
  const ordinal = counts.get(identity) || 0
27
36
  counts.set(identity, ordinal + 1)
28
37
  const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
29
- return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
38
+ return { message, key, legacyTitleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
30
39
  })
40
+ const fixedIds = ['main', 'handbook-content', 'handbook-topics', ...reservedIds,
41
+ ...entries.flatMap(entry => [entry.legacyTitleId, entry.subtopicsId])]
42
+ const slugs = entries.map(entry => topicSlug(entry.message))
43
+ const candidates = new Set(slugs)
31
44
  const coverKey = createPublicationHeadingPrefix(documentId, 'cover')
32
45
  const bodies = entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key }))
33
46
  if (includeIntroductionBody) bodies.push({ bodyContent: messages[0]?.bodyContent, bodyJson: messages[0]?.bodyJson, headingIdPrefix: coverKey })
34
47
  const headingModels = buildPublicationHeadingModels({
35
48
  documentUrl,
36
49
  bodies,
37
- reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
50
+ reservedIds: fixedIds,
51
+ reservedIdsFactory: sourceIds => {
52
+ // Keep authored section bookmarks intact when a title has the same slug.
53
+ const used = new Set([...fixedIds, ...sourceIds])
54
+ entries.forEach((entry, index) => {
55
+ const slug = slugs[index]
56
+ let id = slug, suffix = 2
57
+ while (used.has(id)) {
58
+ do { id = `${slug}-${suffix++}` } while (candidates.has(id))
59
+ }
60
+ used.add(id)
61
+ entry.titleId = id
62
+ })
63
+ return entries.map(entry => entry.titleId)
64
+ },
38
65
  linkResolverFactory: idPlan => createHandbookLinkResolver(entries, documentUrl, idPlan),
39
66
  })
40
67
  const introduction = buildPublicationIntroduction(messages[0], metaDescription)
@@ -20,11 +20,11 @@ function canonicalLinks(message) {
20
20
  const detail = links.find(link => link?.type === 'Detail Link')
21
21
  // Match the website mapper's single selected Detail Link. The same field is
22
22
  // retained in the PDF source, keeping online/PDF behavior identical.
23
- return [message.detailLink || detail?.url].filter(Boolean)
23
+ return [message.detailLink || detail?.url, ...(Array.isArray(message.contentPageUrls) ? message.contentPageUrls : [])].filter(Boolean)
24
24
  }
25
25
 
26
- // Only an included topic's declared detail-page URL identifies its canonical
27
- // destination. Duplicate/unknown destinations remain ordinary links.
26
+ // Included topics are identified by declared detail links and CMS page/message
27
+ // relationships. Duplicate/unknown destinations remain ordinary links.
28
28
  export function createHandbookLinkResolver(entries, documentUrl, idPlan) {
29
29
  const destinations = new Map()
30
30
  const handbookPage = destination(documentUrl, documentUrl)?.key
@@ -40,7 +40,16 @@ export function createHandbookLinkResolver(entries, documentUrl, idPlan) {
40
40
  }
41
41
  }
42
42
  function resolve(value) {
43
+ // Existing bookmarked topic fragments remain valid after adopting slugs.
44
+ let legacyFragment = ''
45
+ if (typeof value === 'string' && value.trim().startsWith('#')) legacyFragment = value.trim().slice(1)
43
46
  const url = destination(value, documentUrl)
47
+ if (url?.key === handbookPage) legacyFragment = url.hash.slice(1)
48
+ if (legacyFragment) {
49
+ try { legacyFragment = decodeURIComponent(legacyFragment) } catch { return null }
50
+ const entry = entries.find(entry => entry.legacyTitleId === legacyFragment)
51
+ if (entry) return { href: `#${encodeURIComponent(entry.titleId)}`, targetId: entry.titleId }
52
+ }
44
53
  const topics = url && destinations.get(url.key)
45
54
  if (topics?.size !== 1) return null
46
55
  const entry = [...topics][0]
@@ -272,6 +272,14 @@ export default function mapPageQueryResultToPageTemplateProps(
272
272
  callsToAction,
273
273
  detailLink: detailLink ? detailLink.link.url : '',
274
274
  detailLinkLabel: detailLink ? detailLink.link.title : '',
275
+ // Page paths are relative to the current website. A message can be
276
+ // shared across organizations, so only retain this site's pages.
277
+ contentPageUrls: (m.message.pages || [])
278
+ .map(row => row.page)
279
+ .filter(page => page?.status === 'Published' &&
280
+ page.ownerOrganizationId === pageData.ownerOrganizationId)
281
+ .map(page => page.path?.path)
282
+ .filter(path => typeof path === 'string' && path.startsWith('/') && !path.startsWith('//')),
275
283
  }
276
284
  }),
277
285
  people: pageData.people,