@asd20/ui-next 2.31.3 → 2.31.5

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.5](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.4...ui-next-v2.31.5) (2026-10-09)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add specific cover sheet for each handbook ([6c1f423](https://github.com/academydistrict20/asd20-ui-next/commit/6c1f423c06be9caa2443eee0c6d74d975416cc5a))
9
+
10
+ ## [2.31.4](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.3...ui-next-v2.31.4) (2026-10-09)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * remove handbook download statistics; add error modal; allow school named files for handbooks ([d81ded2](https://github.com/academydistrict20/asd20-ui-next/commit/d81ded2203f8e8591dabfb2c2dfa4c7bd2b2e7c5))
16
+
3
17
  ## [2.31.3](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.31.2...ui-next-v2.31.3) (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, 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.3",
3
+ "version": "2.31.5",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -3,19 +3,31 @@
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"
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>
26
+ <asd20-print-header
27
+ :canonical-url="documentUrl"
28
+ v-bind="printMetadata"
12
29
  />
13
30
  </header>
14
- <asd20-print-header
15
- :organization-title="organization.title || ''"
16
- :canonical-url="documentUrl"
17
- v-bind="printMetadata"
18
- />
19
31
  <asd20-print-contents :headings="document.headings" />
20
32
  <main>
21
33
  <article
@@ -51,6 +63,7 @@ import Asd20Messaging from '../../atoms/Asd20Messaging'
51
63
  import Asd20PrintHeader from '../../molecules/Asd20PrintHeader'
52
64
  import Asd20PrintContents from '../../molecules/Asd20PrintContents'
53
65
  import { buildHandbookDocument } from '../../../helpers/handbookDocument'
66
+ import { handbookCoverTitle } from '../../../helpers/handbookPresentation'
54
67
  import { publicationHeadingPlanKey } from '../../atoms/Asd20RichBodyContent/headingContext'
55
68
 
56
69
  export default {
@@ -65,9 +78,14 @@ export default {
65
78
  documentId: { type: String, required: true },
66
79
  documentUrl: { type: String, required: true },
67
80
  printMetadata: { type: Object, default: () => ({}) },
81
+ includeIntroductionBody: { type: Boolean, default: true },
82
+ schoolYear: { type: String, default: '' },
68
83
  },
69
84
  computed: {
70
85
  document() { return buildHandbookDocument(this.$props) },
86
+ coverTitle() {
87
+ return handbookCoverTitle(this.organization, this.document.introduction.heading || this.title, this.printMetadata.generatedAt || new Date().toISOString())
88
+ },
71
89
  },
72
90
  }
73
91
  </script>
@@ -81,5 +99,18 @@ export default {
81
99
  h3 { font-size: 13pt; }
82
100
  h4, h5, h6 { font-size: 11pt; }
83
101
  a { color: #111; }
102
+ &__cover { break-after: page; padding-top: 0.35in; }
103
+ &__cover-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.3in; }
104
+ &__cover h1 { flex: 1; font-size: 24pt; line-height: 1.3; margin: 0; color: #1e477d; }
105
+ &__organization, &__title { display: block; }
106
+ &__title { margin-top: 0.15in; font-size: 19pt; }
107
+ &__cover &__logo { width: 1.45in; max-width: 1.45in; max-height: 1.65in; object-fit: contain; }
108
+ &__cover-body { margin-top: 0.75in; }
109
+ &__cover-body .asd20-messaging { padding: 0; margin: 0; }
110
+ &__cover-body .asd20-messaging__body-content { font-size: 10pt; line-height: 1.5; }
111
+ &__cover-body p { margin: 0 0 0.18in; }
112
+ &__cover-body img { margin-top: 0.2in; }
113
+ &__cover .asd20-print-header { margin-top: 0.35in; font-size: 8pt; color: #555; }
114
+ &__cover .asd20-print-header p { margin: 0.05in 0; }
84
115
  }
85
116
  </style>
@@ -27,7 +27,7 @@
27
27
  />
28
28
 
29
29
  <asd20-page-header
30
- :heading="title"
30
+ :heading="introduction.heading || title"
31
31
  :lead="handbookLead"
32
32
  :organization="organization"
33
33
  :organization-options="organizationOptions"
@@ -18,7 +18,7 @@ export function documentImageUrls(html, baseUrl) {
18
18
 
19
19
  // The online handbook and the isolated PDF view share ordering, exclusions and
20
20
  // heading planning. Rendering a document never changes the authored messages.
21
- export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [] } = {}) {
21
+ export function buildHandbookDocument({ messages = [], metaDescription = '', documentId = 'handbook', documentUrl = '', reservedIds = [], includeIntroductionBody = false } = {}) {
22
22
  const counts = new Map()
23
23
  const entries = messages.slice(1).map((message, index) => {
24
24
  const identity = String(message.id ?? `position-${index}`)
@@ -27,18 +27,26 @@ export function buildHandbookDocument({ messages = [], metaDescription = '', doc
27
27
  const key = createPublicationHeadingPrefix(documentId, JSON.stringify([identity, ordinal]))
28
28
  return { message, key, titleId: `${key}-title`, subtopicsId: `${key}-subtopics` }
29
29
  })
30
+ const coverKey = createPublicationHeadingPrefix(documentId, 'cover')
31
+ const bodies = entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key }))
32
+ if (includeIntroductionBody) bodies.push({ bodyContent: messages[0]?.bodyContent, bodyJson: messages[0]?.bodyJson, headingIdPrefix: coverKey })
30
33
  const headingModels = buildPublicationHeadingModels({
31
34
  documentUrl,
32
- bodies: entries.map(entry => ({ bodyContent: entry.message.bodyContent, bodyJson: entry.message.bodyJson, headingLevelOffset: 1, headingIdPrefix: entry.key })),
35
+ bodies,
33
36
  reservedIds: ['main', 'handbook-content', 'handbook-topics', ...reservedIds, ...entries.flatMap(entry => [entry.titleId, entry.subtopicsId])],
34
37
  })
35
38
  const introduction = buildPublicationIntroduction(messages[0], metaDescription)
39
+ const coverBody = includeIntroductionBody ? headingModels.models[entries.length] : null
36
40
  return {
37
41
  entries, introduction, headingModels,
42
+ coverKey, coverBody,
38
43
  headings: entries.flatMap((entry, index) => [
39
44
  { id: entry.titleId, label: entry.message.heading || entry.message.title, authoredLevel: 2, renderedLevel: 2 },
40
45
  ...(headingModels.models[index]?.headings || []),
41
46
  ]),
42
- diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics],
47
+ diagnostics: [...introduction.diagnostics, ...headingModels.headingDiagnostics,
48
+ ...(coverBody?.status === 'unsupported' ? [{ code: 'introduction-body-unsupported' }] : []),
49
+ ...(coverBody?.headings.some(heading => heading.renderedLevel === 1) ? [{ code: 'introduction-body-h1' }] : []),
50
+ ],
43
51
  }
44
52
  }
@@ -0,0 +1,66 @@
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
+ export function handbookCoverTitle(
34
+ organization = {},
35
+ firstMessageTitle = '',
36
+ generatedAt
37
+ ) {
38
+ const organizationTitle = isDistrictHandbook(organization)
39
+ ? 'Academy District 20'
40
+ : organization.title || organization.name || 'School'
41
+ const handbookTitle = isDistrictHandbook(organization)
42
+ ? firstMessageTitle || 'School Handbook'
43
+ : 'School Handbook'
44
+ return {
45
+ organizationTitle,
46
+ handbookTitle: `${handbookSchoolYear(generatedAt)} ${handbookTitle}`
47
+ }
48
+ }
49
+
50
+ export function handbookDownloadFilename(
51
+ organization = {},
52
+ generatedAt,
53
+ firstMessageTitle = ''
54
+ ) {
55
+ const safe = (value) =>
56
+ String(value || '')
57
+ .replace(/[^a-zA-Z0-9_. ()-]/g, '')
58
+ .trim()
59
+ .slice(0, 160)
60
+ const abbreviation = safe(organization.abbreviation).slice(0, 80)
61
+ const { year, month, day } = generationDateParts(generatedAt)
62
+ const title = isDistrictHandbook(organization)
63
+ ? `ASD20 ${safe(firstMessageTitle) || 'School Handbook'}`
64
+ : `${abbreviation ? abbreviation + ' ' : ''}School Handbook`
65
+ return `${title} - ${year}-${month}-${day}.pdf`
66
+ }
@@ -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'] : [])],