@asd20/ui-next 2.31.4 → 2.31.6

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.31.6](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.5...ui-next-v2.31.6) (2026-10-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * revise PDF handbook footers; rewrite links on the handbook template to link to route to handbook content ([365a527](https://github.com/academydistrict20/asd20-ui-next/commit/365a527082a05a5746fd75b3ebf458a5e4f11d46))
9
+
10
+ ## [2.31.5](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.4...ui-next-v2.31.5) (2026-10-09)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * add specific cover sheet for each handbook ([6c1f423](https://github.com/academydistrict20/asd20-ui-next/commit/6c1f423c06be9caa2443eee0c6d74d975416cc5a))
16
+
3
17
  ## [2.31.4](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.3...ui-next-v2.31.4) (2026-10-09)
4
18
 
5
19
 
package/helpers.js CHANGED
@@ -60,3 +60,4 @@ export { default as queryPeople } from './src/helpers/queryPeople.js'
60
60
  export { default as sendEmail } from './src/helpers/sendEmail.js'
61
61
  export { default as sortOrgsByLevel } from './src/helpers/sortOrgsByLevel.js'
62
62
  export { buildHandbookDocument, documentImageUrls } from './src/helpers/handbookDocument.js'
63
+ export { handbookSchoolYear, isDistrictHandbook, handbookCoverTitle, handbookFooterTitle, handbookDownloadFilename } from './src/helpers/handbookPresentation.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.31.4",
3
+ "version": "2.31.6",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -89,6 +89,7 @@
89
89
  :key="cta.label"
90
90
  :label="cta.label"
91
91
  :link="cta.url"
92
+ :data-asd20-handbook-link="cta.handbookTargetId || undefined"
92
93
  bordered
93
94
  transparent
94
95
  />
@@ -124,12 +124,19 @@ export function createHeadingSession(inputContext = {}) {
124
124
  }
125
125
 
126
126
  function href(value, sourcePath) {
127
+ return link(value, sourcePath).href
128
+ }
129
+
130
+ function link(value, sourcePath, { download = false } = {}) {
131
+ if (download) return { href: value }
132
+ const handbook = !download && context.idPlan?.linkResolver?.resolve(value)
133
+ if (handbook) return { href: handbook.href, handbookTargetId: handbook.targetId }
127
134
  const target = fragmentTarget(value, context.documentUrl || context.idPlan?.documentUrl)
128
- if (target === null) return value
135
+ if (target === null) return { href: value }
129
136
  const sourceKey = `${prefix}:${sourcePath}`
130
137
  references.push({ target, prefix, sourceKey })
131
138
  const mapped = context.idPlan?.fragmentsByTopic[prefix]?.[target]
132
- return mapped ? `#${encodeURIComponent(mapped)}` : value
139
+ return { href: mapped ? `#${encodeURIComponent(mapped)}` : value }
133
140
  }
134
141
 
135
142
  function html(value, fieldPath, renderPlaceholder = null, accordionItemId = null) {
@@ -166,8 +173,15 @@ export function createHeadingSession(inputContext = {}) {
166
173
  `${prefix}-html-${fieldPath}-element-${path}-name`)
167
174
  setAttribute(node, 'name', allocated.id)
168
175
  }
169
- const link = node.tagName === 'a' ? getAttribute(node, 'href') : ''
170
- if (link) setAttribute(node, 'href', href(link, `html-${fieldPath}-element-${path}-href`))
176
+ const hrefValue = node.tagName === 'a' ? getAttribute(node, 'href') : ''
177
+ if (hrefValue) {
178
+ const resolved = link(hrefValue, `html-${fieldPath}-element-${path}-href`, { download: node.attrs.some(attr => attr.name === 'download') })
179
+ setAttribute(node, 'href', resolved.href)
180
+ if (resolved.handbookTargetId) {
181
+ node.attrs = node.attrs.filter(attr => attr.name !== 'target')
182
+ setAttribute(node, 'data-asd20-handbook-link', resolved.handbookTargetId)
183
+ }
184
+ }
171
185
  const blockType = getAttribute(node, 'data-cc-block')
172
186
  if (blockType) {
173
187
  const nonempty = (node.childNodes || []).some(child =>
@@ -196,7 +210,7 @@ export function createHeadingSession(inputContext = {}) {
196
210
  return { html: serialize(fragment), nodes }
197
211
  }
198
212
 
199
- return { context, entries, headings, diagnostics, references, report, anchor, heading, href, html, scope }
213
+ return { context, entries, headings, diagnostics, references, report, anchor, heading, href, link, html, scope }
200
214
  }
201
215
 
202
216
  export function buildHeadingIdPlan(sessions, reservedIds = []) {
@@ -712,9 +712,10 @@ function renderMarkedText(node, context, path) {
712
712
  else if (mark.type === 'subscript') html = `<sub>${html}</sub>`
713
713
  else if (mark.type === 'superscript') html = `<sup>${html}</sup>`
714
714
  else if (mark.type === 'link') {
715
- html = `<a${attribute('href', context ? context.href(mark.attrs.href, `${path}-link`) : mark.attrs.href)}${attribute(
715
+ const link = context ? context.link(mark.attrs.href, `${path}-link`) : { href: mark.attrs.href }
716
+ html = `<a${attribute('href', link.href)}${attribute('data-asd20-handbook-link', link.handbookTargetId)}${attribute(
716
717
  'target',
717
- mark.attrs.target
718
+ link.handbookTargetId ? null : mark.attrs.target
718
719
  )}${attribute('rel', mark.attrs.rel)}${attribute(
719
720
  'class',
720
721
  mark.attrs.class
@@ -1115,7 +1116,7 @@ export function buildRichBodyRenderModel(input = {}) {
1115
1116
  }
1116
1117
 
1117
1118
  // This prepass is deliberately independent of Vue instances and mounted topics.
1118
- export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], documentUrl } = {}) {
1119
+ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], documentUrl, linkResolverFactory } = {}) {
1119
1120
  const prefixes = new Set()
1120
1121
  const prepared = bodies.map(body => {
1121
1122
  const prefix = body.headingIdPrefix
@@ -1133,7 +1134,10 @@ export function buildPublicationHeadingModels({ bodies = [], reservedIds = [], d
1133
1134
  render(session)
1134
1135
  return { context, render, session }
1135
1136
  })
1136
- const idPlan = buildHeadingIdPlan(prepared.map(row => row.session), reservedIds)
1137
+ const headingPlan = buildHeadingIdPlan(prepared.map(row => row.session), reservedIds)
1138
+ const idPlan = linkResolverFactory
1139
+ ? Object.freeze({ ...headingPlan, linkResolver: linkResolverFactory(headingPlan) })
1140
+ : headingPlan
1137
1141
  const models = prepared.map(row => finalizeContextualModel(row.render, row.context, idPlan))
1138
1142
  return {
1139
1143
  idPlan, models,
@@ -3,19 +3,27 @@
3
3
  class="asd20-handbook-document"
4
4
  :data-asd20-document-root="documentId"
5
5
  >
6
- <header>
7
- <h1>{{ title }}</h1>
8
- <div
9
- v-if="document.introduction.lead"
10
- class="asd20-page-header__lead"
11
- v-html="document.introduction.lead"
12
- />
6
+ <header class="asd20-handbook-document__cover">
7
+ <div class="asd20-handbook-document__cover-heading">
8
+ <h1>
9
+ <span class="asd20-handbook-document__organization">{{ coverTitle.organizationTitle }}</span>{{ ' ' }}
10
+ <span class="asd20-handbook-document__title">{{ coverTitle.handbookTitle }}</span>
11
+ </h1>
12
+ <img
13
+ v-if="organization.logoImageUrl"
14
+ class="asd20-handbook-document__logo"
15
+ :src="organization.logoImageUrl"
16
+ :alt="`${coverTitle.organizationTitle} logo`"
17
+ />
18
+ </div>
19
+ <div class="asd20-handbook-document__cover-body">
20
+ <asd20-messaging
21
+ :body-content="messages[0]?.bodyContent || ''"
22
+ :body-json="messages[0]?.bodyJson || ''"
23
+ :heading-id-prefix="document.coverKey"
24
+ />
25
+ </div>
13
26
  </header>
14
- <asd20-print-header
15
- :organization-title="organization.title || ''"
16
- :canonical-url="documentUrl"
17
- v-bind="printMetadata"
18
- />
19
27
  <asd20-print-contents :headings="document.headings" />
20
28
  <main>
21
29
  <article
@@ -31,10 +39,10 @@
31
39
  <div
32
40
  v-if="entry.message.shortDescription || entry.message.summary"
33
41
  class="asd20-template-handbook__summary"
34
- v-html="entry.message.shortDescription || entry.message.summary"
42
+ v-html="document.linkResolver.html(entry.message.shortDescription || entry.message.summary)"
35
43
  />
36
44
  <asd20-messaging
37
- v-bind="entry.message"
45
+ v-bind="{ ...entry.message, callsToAction: document.linkResolver.callsToAction(entry.message.callsToAction) }"
38
46
  heading=""
39
47
  short-description=""
40
48
  detail-link=""
@@ -48,14 +56,14 @@
48
56
 
49
57
  <script>
50
58
  import Asd20Messaging from '../../atoms/Asd20Messaging'
51
- import Asd20PrintHeader from '../../molecules/Asd20PrintHeader'
52
59
  import Asd20PrintContents from '../../molecules/Asd20PrintContents'
53
60
  import { buildHandbookDocument } from '../../../helpers/handbookDocument'
61
+ import { handbookCoverTitle } from '../../../helpers/handbookPresentation'
54
62
  import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
55
63
 
56
64
  export default {
57
65
  name: 'Asd20HandbookDocument',
58
- components: { Asd20Messaging, Asd20PrintHeader, Asd20PrintContents },
66
+ components: { Asd20Messaging, Asd20PrintContents },
59
67
  provide() { return { [publicationHeadingPlanKey]: () => this.document.headingModels.idPlan } },
60
68
  props: {
61
69
  title: { type: String, required: true },
@@ -65,9 +73,14 @@ export default {
65
73
  documentId: { type: String, required: true },
66
74
  documentUrl: { type: String, required: true },
67
75
  printMetadata: { type: Object, default: () => ({}) },
76
+ includeIntroductionBody: { type: Boolean, default: true },
77
+ schoolYear: { type: String, default: '' },
68
78
  },
69
79
  computed: {
70
80
  document() { return buildHandbookDocument(this.$props) },
81
+ coverTitle() {
82
+ return handbookCoverTitle(this.organization, this.document.introduction.heading || this.title, this.printMetadata.generatedAt || new Date().toISOString())
83
+ },
71
84
  },
72
85
  }
73
86
  </script>
@@ -81,5 +94,16 @@ export default {
81
94
  h3 { font-size: 13pt; }
82
95
  h4, h5, h6 { font-size: 11pt; }
83
96
  a { color: #111; }
97
+ &__cover { break-after: page; padding-top: 0.35in; }
98
+ &__cover-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.3in; }
99
+ &__cover h1 { flex: 1; font-size: 24pt; line-height: 1.3; margin: 0; color: #1e477d; }
100
+ &__organization, &__title { display: block; }
101
+ &__title { margin-top: 0.15in; font-size: 19pt; }
102
+ &__cover &__logo { width: 1.45in; max-width: 1.45in; max-height: 1.65in; object-fit: contain; }
103
+ &__cover-body { margin-top: 0.75in; }
104
+ &__cover-body .asd20-messaging { padding: 0; margin: 0; }
105
+ &__cover-body .asd20-messaging__body-content { font-size: 10pt; line-height: 1.5; }
106
+ &__cover-body p { margin: 0 0 0.18in; }
107
+ &__cover-body img { margin-top: 0.2in; }
84
108
  }
85
109
  </style>
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  class="asd20-template-handbook"
4
4
  :data-asd20-document-root="documentPrint ? publicationDocumentId : undefined"
5
+ @click.capture="onHandbookLinkClick"
5
6
  >
6
7
  <asd20-skip-to to="#handbook-content" />
7
8
 
@@ -27,7 +28,7 @@
27
28
  />
28
29
 
29
30
  <asd20-page-header
30
- :heading="title"
31
+ :heading="introduction.heading || title"
31
32
  :lead="handbookLead"
32
33
  :organization="organization"
33
34
  :organization-options="organizationOptions"
@@ -231,7 +232,7 @@
231
232
  class="asd20-template-handbook__summary-divider"
232
233
  />
233
234
  <asd20-messaging
234
- v-bind="entry.message"
235
+ v-bind="topicMessage(entry.message)"
235
236
  heading=""
236
237
  short-description=""
237
238
  detail-link=""
@@ -271,6 +272,7 @@ import Asd20PrintHeader from '../../molecules/Asd20PrintHeader'
271
272
  import Asd20PrintContents from '../../molecules/Asd20PrintContents'
272
273
  import { buildHandbookDocument } from '../../../helpers/handbookDocument'
273
274
  import { installDocumentPrintLifecycle } from '../../../helpers/documentPrint'
275
+ import { shouldHandleClientRoutingEvent } from '../../../helpers/linkPolicy'
274
276
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
275
277
  import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
276
278
 
@@ -307,19 +309,24 @@ export default {
307
309
  expandedTopicId: null,
308
310
  topicsOpen: false,
309
311
  handbookHelpOpen: false,
312
+ fragmentEpoch: 0,
310
313
  }),
311
314
  computed: {
312
315
  handbookDocument() {
313
316
  return buildHandbookDocument({
314
317
  messages: this.messages, metaDescription: this.metaDescription,
315
- documentId: this.publicationDocumentId, documentUrl: this.publicationDocumentUrl,
318
+ documentId: this.publicationDocumentId, documentUrl: this.handbookDocumentUrl,
316
319
  reservedIds: this.publicationReservedIds,
317
320
  })
318
321
  },
319
322
  introduction() { return this.handbookDocument.introduction },
320
323
  publicationDiagnostics() { return this.handbookDocument.diagnostics },
321
324
  printHeadings() { return this.handbookDocument.headings },
322
- handbookLead() { return this.introduction.lead },
325
+ handbookDocumentUrl() {
326
+ if (this.publicationDocumentUrl) return this.publicationDocumentUrl
327
+ try { return new URL(this.path || '/', this.organization?.canonicalWebsite || this.organization?.website).href } catch { return '' }
328
+ },
329
+ handbookLead() { return this.handbookDocument.linkResolver.html(this.introduction.lead) },
323
330
  topicMessages() { return this.messages.slice(1) },
324
331
  topicEntries() { return this.handbookDocument.entries },
325
332
  publicationHeadings() { return this.handbookDocument.headingModels },
@@ -333,18 +340,93 @@ export default {
333
340
  },
334
341
  watch: {
335
342
  documentPrint() { this.updatePrintLifecycle() },
336
- messages: { deep: true, handler() { this.updatePrintLifecycle() } },
343
+ messages: { deep: true, handler() { this.updatePrintLifecycle(); this.onFragmentChange() } },
337
344
  publicationDocumentId() { this.updatePrintLifecycle() },
338
345
  },
339
- mounted() { this.updatePrintLifecycle() },
340
- beforeUnmount() { this.stopPrintLifecycle?.() },
346
+ mounted() {
347
+ this.updatePrintLifecycle()
348
+ window.addEventListener('hashchange', this.onFragmentChange)
349
+ window.addEventListener('popstate', this.onFragmentChange)
350
+ this.onFragmentChange()
351
+ },
352
+ beforeUnmount() {
353
+ this.fragmentEpoch++
354
+ window.removeEventListener('hashchange', this.onFragmentChange)
355
+ window.removeEventListener('popstate', this.onFragmentChange)
356
+ this.stopPrintLifecycle?.()
357
+ },
341
358
  methods: {
342
359
  updatePrintLifecycle() {
343
360
  this.stopPrintLifecycle?.()
344
361
  this.stopPrintLifecycle = this.documentPrint ? installDocumentPrintLifecycle(this.$el) : null
345
362
  },
346
363
  topicSummary(message) {
347
- return message.shortDescription || message.summary || ''
364
+ return this.handbookDocument.linkResolver.html(message.shortDescription || message.summary || '')
365
+ },
366
+ topicMessage(message) {
367
+ return { ...message, callsToAction: this.handbookDocument.linkResolver.callsToAction(message.callsToAction) }
368
+ },
369
+ entryForFragment(id) {
370
+ return this.topicEntries.find(entry => entry.titleId === id ||
371
+ Object.entries(this.publicationHeadings.idPlan.idsBySourceKey).some(([key, target]) => key.startsWith(entry.key + ':') && target === id))
372
+ },
373
+ onFragmentChange() {
374
+ if (typeof window !== 'undefined') this.activateHandbookFragment(window.location.hash)
375
+ },
376
+ async activateHandbookFragment(fragment) {
377
+ let id
378
+ try { id = decodeURIComponent(String(fragment || '').replace(/^#/, '')) } catch { return false }
379
+ const entry = this.entryForFragment(id)
380
+ if (!entry) return false
381
+ const epoch = ++this.fragmentEpoch
382
+ this.selectedMessageId = entry.key
383
+ this.expandedTopicId = entry.key
384
+ this.topicsOpen = false
385
+ await this.$nextTick()
386
+ await this.$nextTick()
387
+ if (epoch !== this.fragmentEpoch) return false
388
+ const article = this.topicArticle(entry.key)
389
+ const target = Array.from(article?.querySelectorAll('[id]') || []).find(element => element.id === id)
390
+ if (!target) return false
391
+ const accordions = []
392
+ for (let element = target; element && element !== article; element = element.parentElement) {
393
+ if (element.classList.contains('asd20-accordion')) accordions.unshift(element)
394
+ }
395
+ for (const accordion of accordions) {
396
+ const toggle = Array.from(accordion.querySelectorAll('[aria-expanded="false"][aria-controls]'))
397
+ .find(button => button.closest('.asd20-accordion') === accordion)
398
+ toggle?.click()
399
+ await this.$nextTick()
400
+ if (epoch !== this.fragmentEpoch) return false
401
+ }
402
+ target.scrollIntoView({ block: 'start' })
403
+ // A followed content link moves keyboard focus to its destination.
404
+ if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1')
405
+ target.focus({ preventScroll: true })
406
+ return true
407
+ },
408
+ onHandbookLinkClick(event) {
409
+ const link = event.target?.closest?.('a[href]')
410
+ if (!link || !this.$el.contains(link) || link.hasAttribute('download') ||
411
+ (link.getAttribute('target') && link.getAttribute('target').toLowerCase() !== '_self') ||
412
+ !shouldHandleClientRoutingEvent(event)) return
413
+ const href = link.getAttribute('href')
414
+ if (!href?.startsWith('#')) return
415
+ let id
416
+ try { id = decodeURIComponent(href.slice(1)) } catch { return }
417
+ const entry = this.entryForFragment(id)
418
+ if (!entry) return
419
+ event.preventDefault()
420
+ if (window.location.hash !== href || this.activeEntry?.key !== entry.key) {
421
+ let currentId
422
+ try { currentId = decodeURIComponent(window.location.hash.slice(1)) } catch { currentId = '' }
423
+ // Preserve the currently visible topic as the Back destination, even
424
+ // when it was selected from the index before the first content link.
425
+ if (this.activeEntry && this.entryForFragment(currentId)?.key !== this.activeEntry.key)
426
+ window.history.replaceState(window.history.state, '', `#${encodeURIComponent(this.activeEntry.titleId)}`)
427
+ window.history.pushState(window.history.state, '', href)
428
+ }
429
+ this.activateHandbookFragment(href)
348
430
  },
349
431
  topicArticle(topicKey) {
350
432
  return Array.from(this.$el.querySelectorAll('.asd20-template-handbook__topic'))
@@ -359,6 +441,7 @@ export default {
359
441
  this.expandedTopicId = this.expandedTopicId === messageId ? null : messageId
360
442
  },
361
443
  async selectTopic(messageId) {
444
+ this.fragmentEpoch++
362
445
  this.selectedMessageId = messageId
363
446
  this.expandedTopicId = this.subtopicsFor(messageId).length ? messageId : null
364
447
  this.topicsOpen = false
@@ -366,6 +449,7 @@ export default {
366
449
  this.topicArticle(messageId)?.scrollIntoView({ block: 'start' })
367
450
  },
368
451
  async selectSubtopic(messageId, headingId) {
452
+ this.fragmentEpoch++
369
453
  this.selectedMessageId = messageId
370
454
  this.expandedTopicId = messageId
371
455
  this.topicsOpen = false
@@ -2,6 +2,7 @@ import { buildPublicationIntroduction } from './publicationIntroduction'
2
2
  import { buildPublicationHeadingModels } from '../components/atoms/Asd20RichBodyContent/messageDocumentRenderer'
3
3
  import { createPublicationHeadingPrefix } from '../components/atoms/Asd20RichBodyContent/headingContext'
4
4
  import { parseFragment } from 'parse5'
5
+ import { createHandbookLinkResolver } from './handbookLinks'
5
6
 
6
7
  export function documentImageUrls(html, baseUrl) {
7
8
  const urls = new Set()
@@ -18,7 +19,7 @@ export function documentImageUrls(html, baseUrl) {
18
19
 
19
20
  // The online handbook and the isolated PDF view share ordering, exclusions and
20
21
  // heading planning. Rendering a document never changes the authored messages.
21
- export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [] } = {}) {
22
+ export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [], includeIntroductionBody = false } = {}) {
22
23
  const counts = new Map()
23
24
  const entries = messages.slice(1).map((message, index) => {
24
25
  const identity = String(message.id ?? `position-${index}`)
@@ -27,18 +28,27 @@ export function buildHandbookDocument({ messages = [], metaDescription = '', doc
27
28
  const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
28
29
  return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
29
30
  })
31
+ const coverKey = createPublicationHeadingPrefix(documentId, 'cover')
32
+ const bodies = entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key }))
33
+ if (includeIntroductionBody) bodies.push({ bodyContent: messages[0]?.bodyContent, bodyJson: messages[0]?.bodyJson, headingIdPrefix: coverKey })
30
34
  const headingModels = buildPublicationHeadingModels({
31
35
  documentUrl,
32
- bodies: entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key })),
36
+ bodies,
33
37
  reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
38
+ linkResolverFactory: idPlan => createHandbookLinkResolver(entries, documentUrl, idPlan),
34
39
  })
35
40
  const introduction = buildPublicationIntroduction(messages[0], metaDescription)
41
+ const coverBody = includeIntroductionBody ? headingModels.models[entries.length] : null
36
42
  return {
37
- entries, introduction, headingModels,
43
+ entries, introduction, headingModels, linkResolver: headingModels.idPlan.linkResolver,
44
+ coverKey, coverBody,
38
45
  headings: entries.flatMap((entry, index) => [
39
46
  { id: entry.titleId, label: entry.message.heading || entry.message.title, authoredLevel: 2, renderedLevel: 2 },
40
47
  ...(headingModels.models[index]?.headings || []),
41
48
  ]),
42
- diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics],
49
+ diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics,
50
+ ...(coverBody?.status === 'unsupported' ? [{ code: 'introduction-body-unsupported' }] : []),
51
+ ...(coverBody?.headings.some(heading => heading.renderedLevel === 1) ? [{ code: 'introduction-body-h1' }] : []),
52
+ ],
43
53
  }
44
54
  }
@@ -0,0 +1,82 @@
1
+ import { parseFragment, serialize } from 'parse5'
2
+
3
+ const fallbackBase = 'https://handbook.invalid/'
4
+ const filePath = /\/[^/]+\.[a-z0-9]{1,8}$/i
5
+
6
+ function destination(value, documentUrl) {
7
+ if (typeof value !== 'string' || !value.trim() || value.trim().startsWith('#')) return null
8
+ try {
9
+ const url = new URL(value, documentUrl || fallbackBase)
10
+ const path = url.pathname.replace(/\/+$/, '') || '/'
11
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || filePath.test(path)) return null
12
+ // The two district root hostnames are the same published website.
13
+ if (url.hostname === 'asd20.org') url.hostname = 'www.asd20.org'
14
+ return { key: `${url.origin}${path}${url.search}`, hash: url.hash }
15
+ } catch { return null }
16
+ }
17
+
18
+ function canonicalLinks(message) {
19
+ const links = (Array.isArray(message.links) ? message.links : []).map(row => row?.link || row)
20
+ const detail = links.find(link => link?.type === 'Detail Link')
21
+ // Match the website mapper's single selected Detail Link. The same field is
22
+ // retained in the PDF source, keeping online/PDF behavior identical.
23
+ return [message.detailLink || detail?.url].filter(Boolean)
24
+ }
25
+
26
+ // Only an included topic's declared detail-page URL identifies its canonical
27
+ // destination. Duplicate/unknown destinations remain ordinary links.
28
+ export function createHandbookLinkResolver(entries, documentUrl, idPlan) {
29
+ const destinations = new Map()
30
+ const handbookPage = destination(documentUrl, documentUrl)?.key
31
+ const ownedIds = new Map(entries.map(entry => [entry.key, new Set(
32
+ Object.entries(idPlan.idsBySourceKey).filter(([key]) => key.startsWith(entry.key + ':')).map(([, id]) => id)
33
+ )]))
34
+ for (const entry of entries) {
35
+ for (const value of canonicalLinks(entry.message)) {
36
+ const url = destination(value, documentUrl)
37
+ if (!url || url.key === handbookPage) continue
38
+ const topics = destinations.get(url.key) || new Set()
39
+ topics.add(entry); destinations.set(url.key, topics)
40
+ }
41
+ }
42
+ function resolve(value) {
43
+ const url = destination(value, documentUrl)
44
+ const topics = url && destinations.get(url.key)
45
+ if (topics?.size !== 1) return null
46
+ const entry = [...topics][0]
47
+ let id = entry.titleId
48
+ if (url.hash && url.hash !== '#') {
49
+ let fragment
50
+ try { fragment = decodeURIComponent(url.hash.slice(1)) } catch { return null }
51
+ if (idPlan.diagnostics.some(row => row.code === 'ambiguous-anchor' && row.sourceKey.startsWith(entry.key + ':') && row.detail === fragment)) return null
52
+ id = idPlan.fragmentsByTopic[entry.key]?.[fragment] || fragment
53
+ if (!ownedIds.get(entry.key).has(id)) return null
54
+ }
55
+ return { href: `#${encodeURIComponent(id)}`, targetId: id }
56
+ }
57
+ function html(value) {
58
+ if (!value || !/<a[\s>]/i.test(value)) return value || ''
59
+ const fragment = parseFragment(value)
60
+ const walk = node => {
61
+ if (node.tagName === 'a' && !node.attrs.some(attr => attr.name === 'download')) {
62
+ const href = node.attrs.find(attr => attr.name === 'href')
63
+ const link = href && resolve(href.value)
64
+ if (link) {
65
+ href.value = link.href
66
+ node.attrs = node.attrs.filter(attr => !['target', 'data-asd20-handbook-link'].includes(attr.name))
67
+ node.attrs.push({ name: 'data-asd20-handbook-link', value: link.targetId })
68
+ }
69
+ }
70
+ for (const child of node.childNodes || []) walk(child)
71
+ }
72
+ walk(fragment)
73
+ return serialize(fragment)
74
+ }
75
+ function callsToAction(items = []) {
76
+ return (Array.isArray(items) ? items : []).map(item => {
77
+ const link = item && resolve(item.url)
78
+ return link ? { ...item, url: link.href, handbookTargetId: link.targetId } : item
79
+ })
80
+ }
81
+ return Object.freeze({ resolve, html, callsToAction })
82
+ }
@@ -0,0 +1,73 @@
1
+ // Shared website/PDF presentation rules. The renderer copy is generated by
2
+ // scripts/sync-handbook-presentation.mjs.
3
+ const districtId = '26eaf390-d8ab-11e9-a3a8-5de5bba4f125'
4
+
5
+ function generationDateParts(generatedAt) {
6
+ if (!Number.isFinite(Date.parse(generatedAt)))
7
+ throw new Error('Invalid PDF generation date.')
8
+ return Object.fromEntries(
9
+ new Intl.DateTimeFormat('en-US', {
10
+ timeZone: 'America/Denver',
11
+ year: 'numeric',
12
+ month: '2-digit',
13
+ day: '2-digit'
14
+ })
15
+ .formatToParts(new Date(generatedAt))
16
+ .map((part) => [part.type, part.value])
17
+ )
18
+ }
19
+
20
+ export function handbookSchoolYear(generatedAt) {
21
+ const { year, month } = generationDateParts(generatedAt)
22
+ const start = Number(year) - (Number(month) < 7 ? 1 : 0)
23
+ return `${start}-${String(start + 1).slice(-2)}`
24
+ }
25
+
26
+ export function isDistrictHandbook(organization = {}) {
27
+ return (
28
+ organization.id === districtId ||
29
+ (!organization.id && organization.abbreviation === 'ASD20')
30
+ )
31
+ }
32
+
33
+ function handbookTitles(organization, firstMessageTitle) {
34
+ const organizationTitle = isDistrictHandbook(organization)
35
+ ? 'Academy District 20'
36
+ : organization.title || organization.name || 'School'
37
+ const handbookTitle = isDistrictHandbook(organization)
38
+ ? firstMessageTitle || 'School Handbook'
39
+ : 'School Handbook'
40
+ return { organizationTitle, handbookTitle }
41
+ }
42
+
43
+ export function handbookCoverTitle(
44
+ organization = {},
45
+ firstMessageTitle = '',
46
+ generatedAt
47
+ ) {
48
+ const { organizationTitle, handbookTitle } = handbookTitles(organization, firstMessageTitle)
49
+ return { organizationTitle, handbookTitle: `${handbookSchoolYear(generatedAt)} ${handbookTitle}` }
50
+ }
51
+
52
+ export function handbookFooterTitle(organization = {}, firstMessageTitle = '') {
53
+ const { organizationTitle, handbookTitle } = handbookTitles(organization, firstMessageTitle)
54
+ return `${organizationTitle} ${handbookTitle}`
55
+ }
56
+
57
+ export function handbookDownloadFilename(
58
+ organization = {},
59
+ generatedAt,
60
+ firstMessageTitle = ''
61
+ ) {
62
+ const safe = (value) =>
63
+ String(value || '')
64
+ .replace(/[^a-zA-Z0-9_. ()-]/g, '')
65
+ .trim()
66
+ .slice(0, 160)
67
+ const abbreviation = safe(organization.abbreviation).slice(0, 80)
68
+ const { year, month, day } = generationDateParts(generatedAt)
69
+ const title = isDistrictHandbook(organization)
70
+ ? `ASD20 ${safe(firstMessageTitle) || 'School Handbook'}`
71
+ : `${abbreviation ? abbreviation + ' ' : ''}School Handbook`
72
+ return `${title} - ${year}-${month}-${day}.pdf`
73
+ }
@@ -11,31 +11,16 @@ function nonemptyHtml(html) {
11
11
  return (parseFragment(String(html || '')).childNodes || []).some(visit)
12
12
  }
13
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
14
  export function buildPublicationIntroduction(firstMessage, metaDescription = '') {
30
15
  const message = firstMessage || {}
31
16
  const lead = message.shortDescription || message.summary || metaDescription || ''
32
- const excludedFields = ['heading', 'title', 'bodyContent', 'bodyJson', 'longDescription', 'images', 'callsToAction', 'detailLink', 'detailLinkLabel']
17
+ // First-message body/media belong to the PDF cover, never the online header.
18
+ const excludedFields = ['bodyContent', 'bodyJson', 'longDescription', 'images', 'callsToAction', 'detailLink', 'detailLinkLabel']
33
19
  const unmappedFields = []
34
- for (const field of ['bodyContent', 'longDescription']) {
20
+ for (const field of ['longDescription']) {
35
21
  if (nonemptyHtml(message[field])) unmappedFields.push(field)
36
22
  }
37
- if (nonemptyJson(message.bodyJson)) unmappedFields.push('bodyJson')
38
- for (const field of ['images', 'callsToAction']) {
23
+ for (const field of ['callsToAction']) {
39
24
  if (message[field]?.length) unmappedFields.push(field)
40
25
  }
41
26
  // Detail links direct readers from wayfinding messages to detailed pages.
@@ -44,7 +29,7 @@ export function buildPublicationIntroduction(firstMessage, metaDescription = '')
44
29
  if (message.links?.some(item => (item?.link ?? item)?.type !== 'Detail Link')) unmappedFields.push('links')
45
30
  if (message.shortDescription && nonemptyHtml(message.summary) && message.summary !== lead) unmappedFields.push('summary')
46
31
  return {
47
- messageId: message.id ?? null, mode: 'first-summary-lead', lead,
32
+ messageId: message.id ?? null, mode: 'first-message-header', heading: message.heading || message.title || '', lead,
48
33
  detailLinkPolicy: 'omit-wayfinding',
49
34
  leadSource: message.shortDescription || message.summary ? 'summary' : metaDescription ? 'metaDescription' : 'none',
50
35
  excludedFields: [...excludedFields, 'links', ...(message.shortDescription ? ['summary'] : [])],