@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 +14 -0
- package/helpers.js +1 -0
- package/package.json +1 -1
- package/src/components/atoms/Asd20Messaging/index.vue +1 -0
- package/src/components/atoms/Asd20RichBodyContent/headingContext.js +19 -5
- package/src/components/atoms/Asd20RichBodyContent/messageDocumentRenderer.js +8 -4
- package/src/components/templates/Asd20HandbookDocument/index.vue +40 -16
- package/src/components/templates/Asd20HandbookTemplate/index.vue +92 -8
- package/src/helpers/handbookDocument.js +14 -4
- package/src/helpers/handbookLinks.js +82 -0
- package/src/helpers/handbookPresentation.js +73 -0
- package/src/helpers/publicationIntroduction.js +5 -20
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
|
@@ -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
|
|
170
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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() {
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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 ['
|
|
20
|
+
for (const field of ['longDescription']) {
|
|
35
21
|
if (nonemptyHtml(message[field])) unmappedFields.push(field)
|
|
36
22
|
}
|
|
37
|
-
|
|
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-
|
|
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'] : [])],
|