@undrr/undrr-mangrove 2.0.0 → 2.1.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/README.md +9 -5
- package/components/BookCard.js +2 -2
- package/components/Breadcrumbs.js +2 -2
- package/components/Checkbox.js +2 -2
- package/components/Chips.js +2 -2
- package/components/CopyButton.js +2 -2
- package/components/CtaButton.js +2 -2
- package/components/Drawer.js +3 -3
- package/components/Drawer.js.LICENSE.txt +3 -1
- package/components/EmbedContainer.js +2 -2
- package/components/Footer.js +2 -2
- package/components/FormAction.js +2 -2
- package/components/FormErrorSummary.js +2 -2
- package/components/FormGroup.js +2 -2
- package/components/FullWidth.js +2 -2
- package/components/Gallery.js +3 -3
- package/components/Hero.js +3 -3
- package/components/HighlightBox.js +2 -2
- package/components/HorizontalBookCard.js +3 -3
- package/components/HorizontalBookCard.js.LICENSE.txt +3 -1
- package/components/HorizontalCard.js +3 -3
- package/components/HorizontalCard.js.LICENSE.txt +3 -1
- package/components/IconCard.js +3 -3
- package/components/IconCard.js.LICENSE.txt +3 -1
- package/components/Legend.js +2 -2
- package/components/Loader.js +3 -3
- package/components/MegaMenu.js +3 -3
- package/components/Notice.js +2 -2
- package/components/PageHeader.js +2 -2
- package/components/Pager.js +2 -2
- package/components/ProgressBar.js +9 -0
- package/components/ProgressBar.js.LICENSE.txt +9 -0
- package/components/QuoteHighlight.js +3 -3
- package/components/QuoteHighlight.js.LICENSE.txt +3 -1
- package/components/Radio.js +2 -2
- package/components/Range.js +2 -2
- package/components/ScrollContainer.js +2 -2
- package/components/SegmentedControl.js +2 -2
- package/components/Select.js +2 -2
- package/components/ServiceNotice.js +2 -2
- package/components/ShareButtons.js +3 -3
- package/components/ShowMore.js +3 -3
- package/components/SkipLink.js +2 -2
- package/components/StatsCard.js +3 -3
- package/components/StatsCard.js.LICENSE.txt +3 -1
- package/components/SyndicationSearchWidget.js +3 -3
- package/components/Tab.js +2 -2
- package/components/TextCta.js +3 -3
- package/components/TextCta.js.LICENSE.txt +3 -1
- package/components/TextInput.js +2 -2
- package/components/Textarea.js +2 -2
- package/components/Tree.js +2 -2
- package/components/UserFeedback.js +2 -2
- package/components/VerticalCard.js +3 -3
- package/components/VerticalCard.js.LICENSE.txt +3 -1
- package/components/hydrate.js +3 -3
- package/css/style-all.css +147 -128
- package/css/style-all.css.map +1 -1
- package/css/style-delta.css +147 -128
- package/css/style-delta.css.map +1 -1
- package/css/style-gutenberg.css +12 -12
- package/css/style-gutenberg.css.map +1 -1
- package/css/style-irp.css +147 -128
- package/css/style-irp.css.map +1 -1
- package/css/style-mcr.css +147 -128
- package/css/style-mcr.css.map +1 -1
- package/css/style-preventionweb.css +147 -128
- package/css/style-preventionweb.css.map +1 -1
- package/css/style.css +147 -128
- package/css/style.css.map +1 -1
- package/docs/EDITORIAL-MANUAL.md +193 -0
- package/docs/llms-editorial-manual.txt +200 -0
- package/docs/llms-motion.txt +125 -0
- package/docs/llms-page-building.txt +1140 -0
- package/docs/llms-search-widget.txt +765 -0
- package/error-pages/401.html +1 -1
- package/error-pages/403.html +1 -1
- package/error-pages/404.html +1 -1
- package/error-pages/429.html +1 -1
- package/error-pages/500.html +1 -1
- package/error-pages/502.html +1 -1
- package/error-pages/503.html +1 -1
- package/error-pages/504.html +1 -1
- package/error-pages/5xx.html +1 -1
- package/error-pages/challenge.html +1 -1
- package/error-pages/managed-challenge.html +1 -1
- package/js/cite-this.js +994 -0
- package/js/on-this-page-nav.js +80 -1
- package/js/show-more.js +149 -21
- package/package.json +6 -1
- package/scss/Atom/BaseTypography/Abbr/abbr.scss +1 -1
- package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +1 -1
- package/scss/Atom/BaseTypography/Cite/cite.scss +1 -1
- package/scss/Atom/BaseTypography/Code/code.scss +1 -1
- package/scss/Atom/BaseTypography/Mark/mark.scss +1 -1
- package/scss/Atom/BaseTypography/Quotation/quotation.scss +1 -1
- package/scss/Atom/BaseTypography/Small/small.scss +1 -1
- package/scss/Atom/Colors/color.scss +1 -1
- package/scss/Atom/EmptyState/empty-state.scss +1 -1
- package/scss/Atom/Icons/_icon-definitions.scss +35 -3
- package/scss/Atom/Icons/icons.scss +1 -1
- package/scss/Atom/Images/AuthorImage/author-image.scss +10 -3
- package/scss/Atom/Images/Image/image.scss +1 -1
- package/scss/Atom/Images/ImageCaptionCredit/image-caption-credit.scss +7 -2
- package/scss/Atom/Images/ImageCredit/image-credit.scss +1 -1
- package/scss/Atom/Layout/Container/container.scss +1 -1
- package/scss/Atom/Layout/Grid/grid.scss +1 -1
- package/scss/Atom/Layout/Spacing/spacing.scss +1 -1
- package/scss/Atom/Logo/logo.scss +1 -1
- package/scss/Atom/Navigation/Breadcrumb/breadcrumb.scss +1 -1
- package/scss/Atom/Navigation/MenuItems/menu-items.scss +1 -1
- package/scss/Atom/ReachElement/Details/details.scss +5 -3
- package/scss/Atom/ReachElement/Figcaption/figcaption.scss +1 -1
- package/scss/Atom/StatusLabel/status-label.scss +1 -1
- package/scss/Atom/Table/table.scss +1 -1
- package/scss/Atom/Tag/tag.scss +1 -1
- package/scss/Atom/Video/video.scss +1 -1
- package/scss/Components/Boilerplate/boilerplate.scss +1 -1
- package/scss/Components/Breadcrumbs/breadcrumbs.scss +1 -1
- package/scss/Components/Buttons/Chips/chips.scss +1 -1
- package/scss/Components/Buttons/CopyButton/copy-button.scss +1 -1
- package/scss/Components/Buttons/CtaButton/buttons.scss +1 -1
- package/scss/Components/Buttons/CtaButton/cta-button.scss +1 -1
- package/scss/Components/Buttons/ShareButtons/share-buttons.scss +90 -1
- package/scss/Components/Cards/Card/card.scss +9 -3
- package/scss/Components/Cards/StatsCard/stats-card.scss +9 -2
- package/scss/Components/CiteThis/cite-this.scss +339 -0
- package/scss/Components/CookieConsentBanner/cookie-consent-banner.scss +1 -1
- package/scss/Components/DataViz/Legend/legend.scss +1 -1
- package/scss/Components/ErrorPages/error-pages.scss +1 -1
- package/scss/Components/Footer/footer.scss +1 -1
- package/scss/Components/Forms/Checkbox/checkbox.scss +1 -1
- package/scss/Components/Forms/FormAction/form-action.scss +1 -1
- package/scss/Components/Forms/FormGroup/form-group.scss +1 -1
- package/scss/Components/Forms/InputFields/input-fields.scss +1 -1
- package/scss/Components/Forms/Radio/radio.scss +1 -1
- package/scss/Components/Forms/Range/range.scss +1 -1
- package/scss/Components/Forms/SegmentedControl/segmented-control.scss +1 -1
- package/scss/Components/Forms/Select/select.scss +1 -1
- package/scss/Components/Forms/TextInput/text-input.scss +1 -1
- package/scss/Components/Forms/Textarea/textarea.scss +1 -1
- package/scss/Components/Forms/_form-base.scss +1 -1
- package/scss/Components/Gallery/gallery.scss +41 -16
- package/scss/Components/Hero/hero.scss +166 -1
- package/scss/Components/HighlightBox/highlight-box.scss +1 -1
- package/scss/Components/MegaMenu/mega-menu.scss +43 -122
- package/scss/Components/MegaMenu/megamenu.scss +1 -1
- package/scss/Components/Navigation/Drawer/drawer.scss +11 -1
- package/scss/Components/Navigation/Tree/tree.scss +1 -1
- package/scss/Components/Notice/notice.scss +1 -1
- package/scss/Components/OnThisPageNav/on-this-page-nav.scss +109 -4
- package/scss/Components/PageHeader/page-header.scss +1 -1
- package/scss/Components/Pager/pager.scss +1 -1
- package/scss/Components/PreviewAccess/preview-access.scss +1 -1
- package/scss/Components/ProgressBar/progress-bar.scss +215 -0
- package/scss/Components/QuoteHighlight/quote-highlight.scss +1 -1
- package/scss/Components/ScrollContainer/scroll-container.scss +1 -1
- package/scss/Components/Snackbar/snackbar.scss +1 -1
- package/scss/Components/SyndicationSearchWidget/_deprecated-teaser-hide.scss +1 -1
- package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +253 -128
- package/scss/Components/Tab/tab.scss +3 -2
- package/scss/Components/TableOfContents/table-of-contents.scss +1 -1
- package/scss/Components/TextCta/text-cta.scss +1 -1
- package/scss/Components/TextCta/textcta.scss +1 -1
- package/scss/Components/UserFeedback/user-feedback.scss +1 -1
- package/scss/Molecules/ImageCaption/image-caption.scss +17 -3
- package/scss/Molecules/SectionHeader/section-header.scss +1 -1
- package/scss/Molecules/SectionHeader/sectionheader.scss +1 -1
- package/scss/Patterns/HubHeader/hub-header.scss +1 -1
- package/scss/Patterns/RichContent/rich-content.scss +177 -0
- package/scss/Patterns/_shared/pattern-demo.scss +6 -1
- package/scss/Utilities/EmbedContainer/embed-container.scss +1 -1
- package/scss/Utilities/FullWidth/full-width.scss +1 -1
- package/scss/Utilities/Loader/loader.scss +133 -12
- package/scss/Utilities/Normalize/normalize.scss +1 -1
- package/scss/Utilities/PagePatterns/page-patterns.scss +1 -1
- package/scss/Utilities/ShowMore/show-more.scss +7 -1
- package/scss/Utilities/SkipLink/skip-link.scss +1 -1
- package/scss/assets/scss/_components.scss +5 -0
- package/scss/assets/scss/_foundational.scss +23 -8
- package/scss/assets/scss/_mixins.scss +5 -0
- package/scss/assets/scss/_variables.scss +2 -2
package/js/cite-this.js
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla citation dialog for server-rendered pages.
|
|
3
|
+
* The host owns editorial approval and analytics; this module only resolves,
|
|
4
|
+
* displays, copies, and announces a citation.
|
|
5
|
+
*/
|
|
6
|
+
const WORK_TYPES = new Set([
|
|
7
|
+
'Article',
|
|
8
|
+
'Book',
|
|
9
|
+
'Chapter',
|
|
10
|
+
'CreativeWork',
|
|
11
|
+
'NewsArticle',
|
|
12
|
+
'Report',
|
|
13
|
+
'ScholarlyArticle',
|
|
14
|
+
'TechArticle',
|
|
15
|
+
'WebPage',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const instances = new WeakMap();
|
|
19
|
+
let nextDialogId = 0;
|
|
20
|
+
|
|
21
|
+
// Built-in interface text for the UN official languages and Japanese. Data
|
|
22
|
+
// attributes still override any string, for other languages or house style.
|
|
23
|
+
// Title quotes and separators follow each language's citation conventions.
|
|
24
|
+
const STRINGS = {
|
|
25
|
+
en: {
|
|
26
|
+
label: 'Cite this',
|
|
27
|
+
copy: 'Copy citation',
|
|
28
|
+
copied: 'Citation copied',
|
|
29
|
+
failed: 'Copy failed. Select the citation and copy it manually.',
|
|
30
|
+
copyUrl: 'Copy citation URL',
|
|
31
|
+
urlCopied: 'URL copied',
|
|
32
|
+
urlFailed:
|
|
33
|
+
'Copy failed. Select the URL in the citation and copy it manually.',
|
|
34
|
+
close: 'Close',
|
|
35
|
+
more: 'All citation options',
|
|
36
|
+
ris: 'Download RIS',
|
|
37
|
+
bibtex: 'Download BibTeX',
|
|
38
|
+
accessed: 'accessed {date}',
|
|
39
|
+
quoteOpen: '“',
|
|
40
|
+
quoteClose: '”',
|
|
41
|
+
separator: ', ',
|
|
42
|
+
},
|
|
43
|
+
ar: {
|
|
44
|
+
label: 'الاستشهاد بهذا المحتوى',
|
|
45
|
+
copy: 'نسخ الاستشهاد',
|
|
46
|
+
copied: 'تم نسخ الاستشهاد',
|
|
47
|
+
failed: 'تعذّر النسخ. حدّد الاستشهاد وانسخه يدويًا.',
|
|
48
|
+
copyUrl: 'نسخ رابط الاستشهاد',
|
|
49
|
+
urlCopied: 'تم نسخ الرابط',
|
|
50
|
+
urlFailed: 'تعذّر النسخ. حدّد الرابط في الاستشهاد وانسخه يدويًا.',
|
|
51
|
+
close: 'إغلاق',
|
|
52
|
+
more: 'جميع خيارات الاستشهاد',
|
|
53
|
+
ris: 'تنزيل RIS',
|
|
54
|
+
bibtex: 'تنزيل BibTeX',
|
|
55
|
+
accessed: 'تاريخ الاطلاع: {date}',
|
|
56
|
+
quoteOpen: '“',
|
|
57
|
+
quoteClose: '”',
|
|
58
|
+
separator: '، ',
|
|
59
|
+
},
|
|
60
|
+
es: {
|
|
61
|
+
label: 'Citar',
|
|
62
|
+
copy: 'Copiar la cita',
|
|
63
|
+
copied: 'Cita copiada',
|
|
64
|
+
failed: 'No se pudo copiar. Seleccione la cita y cópiela manualmente.',
|
|
65
|
+
copyUrl: 'Copiar la URL de la cita',
|
|
66
|
+
urlCopied: 'URL copiada',
|
|
67
|
+
urlFailed:
|
|
68
|
+
'No se pudo copiar. Seleccione la URL en la cita y cópiela manualmente.',
|
|
69
|
+
close: 'Cerrar',
|
|
70
|
+
more: 'Todas las opciones de cita',
|
|
71
|
+
ris: 'Descargar RIS',
|
|
72
|
+
bibtex: 'Descargar BibTeX',
|
|
73
|
+
accessed: 'consultado el {date}',
|
|
74
|
+
quoteOpen: '“',
|
|
75
|
+
quoteClose: '”',
|
|
76
|
+
separator: ', ',
|
|
77
|
+
},
|
|
78
|
+
fr: {
|
|
79
|
+
label: 'Citer',
|
|
80
|
+
copy: 'Copier la référence',
|
|
81
|
+
copied: 'Référence copiée',
|
|
82
|
+
failed:
|
|
83
|
+
'La copie a échoué. Sélectionnez la référence et copiez-la manuellement.',
|
|
84
|
+
copyUrl: 'Copier l’URL de la citation',
|
|
85
|
+
urlCopied: 'URL copiée',
|
|
86
|
+
urlFailed:
|
|
87
|
+
'La copie a échoué. Sélectionnez l’URL dans la référence et copiez-la manuellement.',
|
|
88
|
+
close: 'Fermer',
|
|
89
|
+
more: 'Toutes les options de citation',
|
|
90
|
+
ris: 'Télécharger RIS',
|
|
91
|
+
bibtex: 'Télécharger BibTeX',
|
|
92
|
+
accessed: 'consulté le {date}',
|
|
93
|
+
quoteOpen: '«\u202F',
|
|
94
|
+
quoteClose: '\u202F»',
|
|
95
|
+
separator: ', ',
|
|
96
|
+
},
|
|
97
|
+
ja: {
|
|
98
|
+
label: '引用する',
|
|
99
|
+
copy: '引用をコピー',
|
|
100
|
+
copied: '引用をコピーしました',
|
|
101
|
+
failed: 'コピーできませんでした。引用を選択して手動でコピーしてください。',
|
|
102
|
+
copyUrl: '引用URLをコピー',
|
|
103
|
+
urlCopied: 'URLをコピーしました',
|
|
104
|
+
urlFailed:
|
|
105
|
+
'コピーできませんでした。引用内のURLを選択して手動でコピーしてください。',
|
|
106
|
+
close: '閉じる',
|
|
107
|
+
more: 'すべての引用オプション',
|
|
108
|
+
ris: 'RISをダウンロード',
|
|
109
|
+
bibtex: 'BibTeXをダウンロード',
|
|
110
|
+
accessed: '閲覧日:{date}',
|
|
111
|
+
quoteOpen: '“',
|
|
112
|
+
quoteClose: '”',
|
|
113
|
+
separator: ', ',
|
|
114
|
+
},
|
|
115
|
+
ru: {
|
|
116
|
+
label: 'Как цитировать',
|
|
117
|
+
copy: 'Копировать библиографическую ссылку',
|
|
118
|
+
copied: 'Библиографическая ссылка скопирована',
|
|
119
|
+
failed:
|
|
120
|
+
'Не удалось скопировать. Выделите библиографическую ссылку и скопируйте её вручную.',
|
|
121
|
+
copyUrl: 'Копировать URL-адрес',
|
|
122
|
+
urlCopied: 'URL-адрес скопирован',
|
|
123
|
+
urlFailed:
|
|
124
|
+
'Не удалось скопировать. Выделите URL-адрес в библиографической ссылке и скопируйте его вручную.',
|
|
125
|
+
close: 'Закрыть',
|
|
126
|
+
more: 'Все варианты цитирования',
|
|
127
|
+
ris: 'Скачать RIS',
|
|
128
|
+
bibtex: 'Скачать BibTeX',
|
|
129
|
+
accessed: 'дата обращения: {date}',
|
|
130
|
+
quoteOpen: '«',
|
|
131
|
+
quoteClose: '»',
|
|
132
|
+
separator: ', ',
|
|
133
|
+
},
|
|
134
|
+
zh: {
|
|
135
|
+
label: '引用',
|
|
136
|
+
copy: '复制引用',
|
|
137
|
+
copied: '引用已复制',
|
|
138
|
+
failed: '复制失败。请选中引用内容并手动复制。',
|
|
139
|
+
copyUrl: '复制引用网址',
|
|
140
|
+
urlCopied: '网址已复制',
|
|
141
|
+
urlFailed: '复制失败。请选中引用中的网址并手动复制。',
|
|
142
|
+
close: '关闭',
|
|
143
|
+
more: '所有引用选项',
|
|
144
|
+
ris: '下载 RIS',
|
|
145
|
+
bibtex: '下载 BibTeX',
|
|
146
|
+
accessed: '访问日期:{date}',
|
|
147
|
+
quoteOpen: '《',
|
|
148
|
+
quoteClose: '》',
|
|
149
|
+
separator: ', ',
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function languageOf(element) {
|
|
154
|
+
return (
|
|
155
|
+
element.closest('[lang]')?.getAttribute('lang') ||
|
|
156
|
+
element.ownerDocument.documentElement.lang ||
|
|
157
|
+
'en'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Built-in interface text for a language tag, falling back to English. */
|
|
162
|
+
export function citeThisStrings(lang = 'en') {
|
|
163
|
+
const primary = String(lang).toLowerCase().split(/[-_]/)[0];
|
|
164
|
+
return STRINGS[primary] || STRINGS.en;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function firstText(value) {
|
|
168
|
+
if (Array.isArray(value))
|
|
169
|
+
return value.map(firstText).filter(Boolean).join(', ');
|
|
170
|
+
if (typeof value === 'string') return value.trim();
|
|
171
|
+
if (value && typeof value === 'object') {
|
|
172
|
+
return firstText(value.name || value.legalName);
|
|
173
|
+
}
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cleanUrl(value, base, stripQuery = false) {
|
|
178
|
+
try {
|
|
179
|
+
const url = new URL(value, base);
|
|
180
|
+
if (!['https:', 'http:'].includes(url.protocol)) return '';
|
|
181
|
+
url.hash = '';
|
|
182
|
+
if (stripQuery) url.search = '';
|
|
183
|
+
return url.href.replace(/\/$/, '');
|
|
184
|
+
} catch {
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canonicalUrl(doc) {
|
|
190
|
+
const canonical = doc.querySelector('link[rel="canonical"]')?.href;
|
|
191
|
+
return cleanUrl(
|
|
192
|
+
canonical || doc.location.href,
|
|
193
|
+
doc.location.href,
|
|
194
|
+
!canonical
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function workTypes(node) {
|
|
199
|
+
return [node?.['@type']]
|
|
200
|
+
.flat()
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map(type => String(type).split(/[\/#]/).pop());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function schemaNodes(value) {
|
|
206
|
+
if (Array.isArray(value)) return value.flatMap(schemaNodes);
|
|
207
|
+
if (!value || typeof value !== 'object') return [];
|
|
208
|
+
return [
|
|
209
|
+
value,
|
|
210
|
+
...schemaNodes(value['@graph']),
|
|
211
|
+
...schemaNodes(value.mainEntity),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function referenceUrl(value) {
|
|
216
|
+
if (typeof value === 'string') return value;
|
|
217
|
+
return value?.['@id'] || value?.url || '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function schemaWork(doc, canonical) {
|
|
221
|
+
const base = doc.location.href;
|
|
222
|
+
const candidates = [];
|
|
223
|
+
for (const script of doc.querySelectorAll(
|
|
224
|
+
'script[type="application/ld+json"]'
|
|
225
|
+
)) {
|
|
226
|
+
try {
|
|
227
|
+
candidates.push(...schemaNodes(JSON.parse(script.textContent)));
|
|
228
|
+
} catch {
|
|
229
|
+
// Malformed JSON-LD must not prevent the page citation fallback.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const matches = candidates.filter(node => {
|
|
233
|
+
if (!workTypes(node).some(type => WORK_TYPES.has(type))) return false;
|
|
234
|
+
return [node.mainEntityOfPage, node.url, node['@id']].some(
|
|
235
|
+
value => cleanUrl(referenceUrl(value), base) === canonical
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
// The WebPage may identify its primary work through mainEntity instead of
|
|
239
|
+
// repeating mainEntityOfPage on that work.
|
|
240
|
+
const page = matches.find(node => workTypes(node).includes('WebPage'));
|
|
241
|
+
const mainEntity = schemaNodes(page?.mainEntity).find(node =>
|
|
242
|
+
workTypes(node).some(type => WORK_TYPES.has(type) && type !== 'WebPage')
|
|
243
|
+
);
|
|
244
|
+
// A matching work is preferable to a matching WebPage wrapper.
|
|
245
|
+
return (
|
|
246
|
+
matches.find(node => !workTypes(node).includes('WebPage')) ||
|
|
247
|
+
mainEntity ||
|
|
248
|
+
matches[0]
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function personName(value) {
|
|
253
|
+
if (value?.familyName && value?.givenName) {
|
|
254
|
+
return `${firstText(value.familyName)}, ${firstText(value.givenName)}`;
|
|
255
|
+
}
|
|
256
|
+
return firstText(value);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function workType(configured, work) {
|
|
260
|
+
const type = configured?.trim().toLowerCase();
|
|
261
|
+
if (['book', 'report', 'webpage'].includes(type)) return type;
|
|
262
|
+
const types = workTypes(work);
|
|
263
|
+
if (types.includes('Book')) return 'book';
|
|
264
|
+
if (types.includes('Report')) return 'report';
|
|
265
|
+
return 'webpage';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A key that survives title and URL edits, so analytics can count one work
|
|
269
|
+
// across the pages that cite it. The URL is only the last resort.
|
|
270
|
+
function workIdentifier(data, work, url, canonical, base) {
|
|
271
|
+
const schemaId = [work?.identifier]
|
|
272
|
+
.flat()
|
|
273
|
+
.map(value => {
|
|
274
|
+
const id = value && typeof value === 'object' ? value.value : value;
|
|
275
|
+
return typeof id === 'number' ? String(id) : firstText(id);
|
|
276
|
+
})
|
|
277
|
+
.find(Boolean);
|
|
278
|
+
const provided = data.mgCiteThisId?.trim() || schemaId;
|
|
279
|
+
if (provided) return provided;
|
|
280
|
+
const candidate = nodeId(work?.['@id'], [url, canonical], base) || url;
|
|
281
|
+
// A query can identify a distinct work or contain private data. Do not
|
|
282
|
+
// forward it automatically, or strip it and merge unrelated works. The
|
|
283
|
+
// host can supply an approved stable identifier for per-work analytics.
|
|
284
|
+
return new URL(candidate, base).search ? null : candidate;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// An @id is only stable when it is not the page or work URL plus a fragment,
|
|
288
|
+
// which would change with the URL or, if relative, repeat on every page.
|
|
289
|
+
function nodeId(value, urls, base) {
|
|
290
|
+
if (typeof value !== 'string' || !value.trim()) return '';
|
|
291
|
+
let resolved;
|
|
292
|
+
try {
|
|
293
|
+
resolved = new URL(value.trim(), base);
|
|
294
|
+
} catch {
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
const withoutHash = cleanUrl(resolved.href, base);
|
|
298
|
+
if (!withoutHash || urls.includes(withoutHash)) return '';
|
|
299
|
+
if (cleanUrl(resolved.href, base, true) === cleanUrl(base, base, true))
|
|
300
|
+
return '';
|
|
301
|
+
return resolved.href;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function publicationYear(value) {
|
|
305
|
+
const match = String(value || '').match(/^(\d{4})(?:-|$)/);
|
|
306
|
+
return match ? match[1] : '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function formatCitation(
|
|
310
|
+
{ author, title, publisher, year, url, accessed },
|
|
311
|
+
strings = STRINGS.en
|
|
312
|
+
) {
|
|
313
|
+
const sep = strings.separator;
|
|
314
|
+
const lead = author ? `${author}${year ? ` (${year})` : ''}${sep}` : '';
|
|
315
|
+
const source = publisher && publisher !== author ? `${sep}${publisher}` : '';
|
|
316
|
+
const date = !author && year ? ` (${year})` : '';
|
|
317
|
+
const access = year
|
|
318
|
+
? ''
|
|
319
|
+
: `${sep}${strings.accessed.replace('{date}', accessed)}`;
|
|
320
|
+
const start = lead.length + strings.quoteOpen.length;
|
|
321
|
+
return {
|
|
322
|
+
text: `${lead}${strings.quoteOpen}${title}${strings.quoteClose}${source}${date}${access}${sep}${url}.`,
|
|
323
|
+
titleRange: [start, start + title.length],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Resolve a citation, in editorial → configured → matching JSON-LD → page order. */
|
|
328
|
+
export function resolveCitation(element, now = new Date()) {
|
|
329
|
+
const doc = element.ownerDocument;
|
|
330
|
+
const canonical = canonicalUrl(doc);
|
|
331
|
+
const data = element.dataset;
|
|
332
|
+
const explicit = data.mgCiteThisCitation?.trim();
|
|
333
|
+
const configured = [
|
|
334
|
+
data.mgCiteThisAuthor,
|
|
335
|
+
data.mgCiteThisPublisher,
|
|
336
|
+
data.mgCiteThisYear,
|
|
337
|
+
data.mgCiteThisTitle,
|
|
338
|
+
data.mgCiteThisUrl,
|
|
339
|
+
].some(Boolean);
|
|
340
|
+
const work = schemaWork(doc, canonical);
|
|
341
|
+
const title =
|
|
342
|
+
data.mgCiteThisTitle?.trim() ||
|
|
343
|
+
firstText(work?.headline || work?.name) ||
|
|
344
|
+
doc.title?.trim() ||
|
|
345
|
+
doc.querySelector('meta[property="og:title"]')?.content?.trim() ||
|
|
346
|
+
doc.querySelector('h1')?.textContent?.trim() ||
|
|
347
|
+
'Untitled page';
|
|
348
|
+
const site =
|
|
349
|
+
data.mgCiteThisSite?.trim() ||
|
|
350
|
+
doc.querySelector('meta[property="og:site_name"]')?.content?.trim() ||
|
|
351
|
+
new URL(canonical).hostname.replace(/^www\./, '');
|
|
352
|
+
const authors = data.mgCiteThisAuthor?.trim()
|
|
353
|
+
? [data.mgCiteThisAuthor.trim()]
|
|
354
|
+
: [work?.author].flat().map(personName).filter(Boolean);
|
|
355
|
+
const author = data.mgCiteThisAuthor?.trim() || firstText(work?.author);
|
|
356
|
+
const publisher =
|
|
357
|
+
data.mgCiteThisPublisher?.trim() || firstText(work?.publisher) || site;
|
|
358
|
+
const year = publicationYear(data.mgCiteThisYear || work?.datePublished);
|
|
359
|
+
const url =
|
|
360
|
+
cleanUrl(data.mgCiteThisUrl || work?.url || canonical, doc.location.href) ||
|
|
361
|
+
canonical;
|
|
362
|
+
const pageLang = languageOf(element);
|
|
363
|
+
const locale = pageLang === 'en' ? 'en-GB' : pageLang;
|
|
364
|
+
const accessed = new Intl.DateTimeFormat(locale, {
|
|
365
|
+
day: 'numeric',
|
|
366
|
+
month: 'long',
|
|
367
|
+
year: 'numeric',
|
|
368
|
+
numberingSystem: 'latn',
|
|
369
|
+
}).format(now);
|
|
370
|
+
const fields = {
|
|
371
|
+
type: workType(data.mgCiteThisType, work),
|
|
372
|
+
authors,
|
|
373
|
+
title,
|
|
374
|
+
publisher,
|
|
375
|
+
year,
|
|
376
|
+
url,
|
|
377
|
+
accessed: now,
|
|
378
|
+
};
|
|
379
|
+
// An editorial citation describes the work in words Mangrove cannot parse,
|
|
380
|
+
// so exports need configured fields rather than page guesses.
|
|
381
|
+
const id = workIdentifier(data, work, url, canonical, doc.location.href);
|
|
382
|
+
if (explicit) {
|
|
383
|
+
return {
|
|
384
|
+
text: explicit,
|
|
385
|
+
source: 'provided',
|
|
386
|
+
url,
|
|
387
|
+
id,
|
|
388
|
+
fields: configured ? fields : null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const { text, titleRange } = formatCitation(
|
|
392
|
+
{ author, title, publisher, year, url, accessed },
|
|
393
|
+
citeThisStrings(pageLang)
|
|
394
|
+
);
|
|
395
|
+
return {
|
|
396
|
+
text,
|
|
397
|
+
titleRange,
|
|
398
|
+
source: configured ? 'metadata' : work ? 'schema' : 'page',
|
|
399
|
+
url,
|
|
400
|
+
id,
|
|
401
|
+
fields,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const EXPORT_FORMATS = {
|
|
406
|
+
ris: {
|
|
407
|
+
extension: 'ris',
|
|
408
|
+
mime: 'application/x-research-info-systems',
|
|
409
|
+
build: toRis,
|
|
410
|
+
},
|
|
411
|
+
bibtex: {
|
|
412
|
+
extension: 'bib',
|
|
413
|
+
mime: 'application/x-bibtex',
|
|
414
|
+
build: toBibtex,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// A line break inside a field would end an RIS tag or BibTeX value early.
|
|
419
|
+
function oneLine(value) {
|
|
420
|
+
return String(value ?? '')
|
|
421
|
+
.replace(/\s*[\r\n]+\s*/g, ' ')
|
|
422
|
+
.trim();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isoDate(date) {
|
|
426
|
+
return date.toISOString().slice(0, 10);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** RIS record for EndNote, Zotero and Mendeley. */
|
|
430
|
+
export function toRis(fields) {
|
|
431
|
+
const type = { book: 'BOOK', report: 'RPRT', webpage: 'ELEC' }[fields.type];
|
|
432
|
+
const lines = [
|
|
433
|
+
['TY', type],
|
|
434
|
+
...fields.authors.map(name => ['AU', name]),
|
|
435
|
+
['TI', fields.title],
|
|
436
|
+
['PY', fields.year],
|
|
437
|
+
['PB', fields.publisher],
|
|
438
|
+
['UR', fields.url],
|
|
439
|
+
['Y2', isoDate(fields.accessed).replaceAll('-', '/')],
|
|
440
|
+
]
|
|
441
|
+
.map(([tag, value]) => [tag, oneLine(value)])
|
|
442
|
+
.filter(([, value]) => value);
|
|
443
|
+
return `${[...lines, ['ER', '']]
|
|
444
|
+
.map(([tag, value]) => `${tag} - ${value}`)
|
|
445
|
+
.join('\r\n')}\r\n`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const BIBTEX_ESCAPES = {
|
|
449
|
+
'\\': '\\textbackslash{}',
|
|
450
|
+
'~': '\\textasciitilde{}',
|
|
451
|
+
'^': '\\textasciicircum{}',
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
function bibtexText(value) {
|
|
455
|
+
return oneLine(value).replace(
|
|
456
|
+
/[\\~^{}&%$#_]/g,
|
|
457
|
+
char => BIBTEX_ESCAPES[char] || `\\${char}`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Organisations are braced so BibTeX does not split them into personal names;
|
|
462
|
+
// "Family, Given" names come only from structured Schema.org people.
|
|
463
|
+
function bibtexName(name) {
|
|
464
|
+
return /^[^,]+, [^,]+$/.test(name)
|
|
465
|
+
? bibtexText(name)
|
|
466
|
+
: `{${bibtexText(name)}}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** BibTeX entry; `urldate` is read by biblatex and ignored by classic BibTeX. */
|
|
470
|
+
export function toBibtex(fields) {
|
|
471
|
+
const entry = { book: 'book', report: 'techreport', webpage: 'misc' }[
|
|
472
|
+
fields.type
|
|
473
|
+
];
|
|
474
|
+
const key =
|
|
475
|
+
[fields.authors[0], fields.year, fields.title]
|
|
476
|
+
.filter(Boolean)
|
|
477
|
+
.map(part =>
|
|
478
|
+
part
|
|
479
|
+
.normalize('NFKD')
|
|
480
|
+
.replace(/[^A-Za-z0-9\s]/g, '')
|
|
481
|
+
.trim()
|
|
482
|
+
.split(/\s+/)[0]
|
|
483
|
+
?.toLowerCase()
|
|
484
|
+
)
|
|
485
|
+
.filter(Boolean)
|
|
486
|
+
.join('') || 'citation';
|
|
487
|
+
const publisherField = fields.type === 'report' ? 'institution' : 'publisher';
|
|
488
|
+
const lines = [
|
|
489
|
+
['author', fields.authors.map(bibtexName).join(' and ')],
|
|
490
|
+
['title', `{${bibtexText(fields.title)}}`],
|
|
491
|
+
[publisherField, fields.publisher && bibtexText(fields.publisher)],
|
|
492
|
+
['year', oneLine(fields.year)],
|
|
493
|
+
// Braces would close the field early; percent-encoding keeps the URL valid.
|
|
494
|
+
[
|
|
495
|
+
'url',
|
|
496
|
+
oneLine(fields.url).replace(
|
|
497
|
+
/[{}]/g,
|
|
498
|
+
char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
499
|
+
),
|
|
500
|
+
],
|
|
501
|
+
['urldate', isoDate(fields.accessed)],
|
|
502
|
+
].filter(([, value]) => value);
|
|
503
|
+
return `@${entry}{${key},\n${lines
|
|
504
|
+
.map(([name, value]) => ` ${name} = {${value}}`)
|
|
505
|
+
.join(',\n')}\n}\n`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Both formats are offered unless the site lists its own or sets "none".
|
|
509
|
+
function exportFormats(element, citation) {
|
|
510
|
+
if (!citation.fields) return [];
|
|
511
|
+
return (element.dataset.mgCiteThisExport ?? 'ris bibtex')
|
|
512
|
+
.toLowerCase()
|
|
513
|
+
.split(/[\s,]+/)
|
|
514
|
+
.filter(
|
|
515
|
+
(name, index, all) => EXPORT_FORMATS[name] && all.indexOf(name) === index
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function fileName(title) {
|
|
520
|
+
return (
|
|
521
|
+
title
|
|
522
|
+
.normalize('NFKD')
|
|
523
|
+
.replace(/[^\w\s-]/g, '')
|
|
524
|
+
.trim()
|
|
525
|
+
.replace(/\s+/g, '-')
|
|
526
|
+
.toLowerCase()
|
|
527
|
+
.slice(0, 60) || 'citation'
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"]+/g;
|
|
532
|
+
|
|
533
|
+
// Sentence punctuation and closing quotes stay outside the link; a closing
|
|
534
|
+
// bracket stays inside when the URL opened it, as in Wikipedia titles.
|
|
535
|
+
function trimUrl(url) {
|
|
536
|
+
let href = url;
|
|
537
|
+
for (;;) {
|
|
538
|
+
const last = href.at(-1);
|
|
539
|
+
const unbalanced = (open, close) =>
|
|
540
|
+
href.split(close).length > href.split(open).length;
|
|
541
|
+
if (/[.,;:!?'"\u2019\u201d]/.test(last)) href = href.slice(0, -1);
|
|
542
|
+
else if (last === ')' && unbalanced('(', ')')) href = href.slice(0, -1);
|
|
543
|
+
else if (last === ']' && unbalanced('[', ']')) href = href.slice(0, -1);
|
|
544
|
+
else return href;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Links each URL in place while keeping the copied text identical to the
|
|
549
|
+
// rendered text. Trailing sentence punctuation stays outside the link.
|
|
550
|
+
// URLs and the title are isolated so that, in right-to-left text, a Latin
|
|
551
|
+
// title or URL does not pull the surrounding punctuation out of place.
|
|
552
|
+
function appendText(output, text, links) {
|
|
553
|
+
const doc = output.ownerDocument;
|
|
554
|
+
let index = 0;
|
|
555
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
556
|
+
const href = trimUrl(match[0]);
|
|
557
|
+
if (match.index > index) output.append(text.slice(index, match.index));
|
|
558
|
+
const url = doc.createElement(links ? 'a' : 'span');
|
|
559
|
+
if (links) url.href = href;
|
|
560
|
+
url.dir = 'ltr';
|
|
561
|
+
url.textContent = href;
|
|
562
|
+
output.append(url);
|
|
563
|
+
index = match.index + href.length;
|
|
564
|
+
}
|
|
565
|
+
if (index < text.length) output.append(text.slice(index));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderCitation(output, citation, links = true) {
|
|
569
|
+
const { text, titleRange } = citation;
|
|
570
|
+
output.replaceChildren();
|
|
571
|
+
if (!titleRange) {
|
|
572
|
+
appendText(output, text, links);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const [start, end] = titleRange;
|
|
576
|
+
const title = output.ownerDocument.createElement('bdi');
|
|
577
|
+
title.textContent = text.slice(start, end);
|
|
578
|
+
appendText(output, text.slice(0, start), links);
|
|
579
|
+
output.append(title);
|
|
580
|
+
appendText(output, text.slice(end), links);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function elementsIn(scope) {
|
|
584
|
+
if (!scope) return [];
|
|
585
|
+
if (!scope.querySelectorAll) return Array.from(scope).flatMap(elementsIn);
|
|
586
|
+
return [
|
|
587
|
+
...(scope.matches?.('[data-mg-cite-this]') ? [scope] : []),
|
|
588
|
+
...scope.querySelectorAll('[data-mg-cite-this]'),
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function emit(element, name, citation, extra = {}) {
|
|
593
|
+
element.dispatchEvent(
|
|
594
|
+
new CustomEvent(`mg-cite-this:${name}`, {
|
|
595
|
+
bubbles: true,
|
|
596
|
+
detail: {
|
|
597
|
+
source: citation.source,
|
|
598
|
+
id: citation.id,
|
|
599
|
+
url: cleanUrl(citation.url, element.ownerDocument.location.href, true),
|
|
600
|
+
pageUrl: canonicalUrl(element.ownerDocument).replace(/\?.*$/, ''),
|
|
601
|
+
layout: element.dataset.mgCiteThisLayout,
|
|
602
|
+
...extra,
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Fixed English labels for the UNDRR click tracker, which otherwise reports
|
|
609
|
+
// visible text: the citation itself, or a translated label.
|
|
610
|
+
const REGION = 'data-vf-google-analytics-region';
|
|
611
|
+
function track(node, action) {
|
|
612
|
+
node.dataset.vfAnalyticsLabel = `Cite this: ${action}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function createFallback(element) {
|
|
616
|
+
const doc = element.ownerDocument;
|
|
617
|
+
const t = citeThisStrings(languageOf(element));
|
|
618
|
+
const fallback = doc.createElement('details');
|
|
619
|
+
fallback.className = 'mg-cite-this__fallback';
|
|
620
|
+
const summary = doc.createElement('summary');
|
|
621
|
+
summary.className = 'mg-cite-this__summary';
|
|
622
|
+
summary.textContent = element.dataset.mgCiteThisLabel || t.label;
|
|
623
|
+
const content = doc.createElement('div');
|
|
624
|
+
content.className = 'mg-cite-this__content';
|
|
625
|
+
const output = doc.createElement('p');
|
|
626
|
+
output.className = 'mg-cite-this__text';
|
|
627
|
+
output.dataset.mgCiteThisText = '';
|
|
628
|
+
const actions = doc.createElement('div');
|
|
629
|
+
actions.className = 'mg-cite-this__actions';
|
|
630
|
+
const copy = doc.createElement('button');
|
|
631
|
+
copy.type = 'button';
|
|
632
|
+
copy.className = 'mg-cite-this__copy';
|
|
633
|
+
copy.dataset.mgCiteThisCopy = '';
|
|
634
|
+
copy.textContent = element.dataset.mgCiteThisCopyLabel || t.copy;
|
|
635
|
+
copy.hidden = true;
|
|
636
|
+
const status = doc.createElement('span');
|
|
637
|
+
status.className = 'mg-cite-this__status';
|
|
638
|
+
status.dataset.mgCiteThisStatus = '';
|
|
639
|
+
status.setAttribute('role', 'status');
|
|
640
|
+
status.setAttribute('aria-live', 'polite');
|
|
641
|
+
actions.append(copy, status);
|
|
642
|
+
content.append(output, actions);
|
|
643
|
+
fallback.append(summary, content);
|
|
644
|
+
element.append(fallback);
|
|
645
|
+
return fallback;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Initialise citation dialogs within a document or Drupal behaviour scope. */
|
|
649
|
+
export function mgCiteThis(scope = document) {
|
|
650
|
+
for (const element of elementsIn(scope)) {
|
|
651
|
+
// The ready flag also stops a second copy of this module, such as a
|
|
652
|
+
// versioned and a latest/ build on one page, from enhancing it twice.
|
|
653
|
+
if (instances.has(element) || 'mgCiteThisReady' in element.dataset)
|
|
654
|
+
continue;
|
|
655
|
+
const existing = element.querySelector('details.mg-cite-this__fallback');
|
|
656
|
+
const generated = !existing && element.childElementCount === 0;
|
|
657
|
+
const fallback = generated ? createFallback(element) : existing;
|
|
658
|
+
const output = fallback?.querySelector('[data-mg-cite-this-text]');
|
|
659
|
+
const summary = fallback?.querySelector('summary');
|
|
660
|
+
const content = fallback?.querySelector('.mg-cite-this__content');
|
|
661
|
+
if (!fallback || !summary || !content || !output) continue;
|
|
662
|
+
|
|
663
|
+
const doc = element.ownerDocument;
|
|
664
|
+
const t = citeThisStrings(languageOf(element));
|
|
665
|
+
const citation = resolveCitation(element);
|
|
666
|
+
renderCitation(output, citation);
|
|
667
|
+
element.dataset.mgCiteThisSource = citation.source;
|
|
668
|
+
const dialog = doc.createElement('dialog');
|
|
669
|
+
element.dataset.mgCiteThisReady = '';
|
|
670
|
+
const regionSet = !element.hasAttribute(REGION);
|
|
671
|
+
if (regionSet) element.setAttribute(REGION, 'cite-this');
|
|
672
|
+
if (typeof dialog.showModal !== 'function') {
|
|
673
|
+
// Without dialog support the disclosure stays, with copying enabled.
|
|
674
|
+
element.dataset.mgCiteThisLayout = 'disclosure';
|
|
675
|
+
const fallbackCopy = content.querySelector('[data-mg-cite-this-copy]');
|
|
676
|
+
const fallbackStatus = content.querySelector(
|
|
677
|
+
'[data-mg-cite-this-status]'
|
|
678
|
+
);
|
|
679
|
+
let fallbackTimer;
|
|
680
|
+
const onFallbackCopy = async () => {
|
|
681
|
+
let message;
|
|
682
|
+
try {
|
|
683
|
+
await navigator.clipboard.writeText(citation.text);
|
|
684
|
+
message = element.dataset.mgCiteThisCopiedLabel || t.copied;
|
|
685
|
+
emit(element, 'copy', citation, { placement: 'disclosure' });
|
|
686
|
+
} catch {
|
|
687
|
+
message = element.dataset.mgCiteThisFailedLabel || t.failed;
|
|
688
|
+
emit(element, 'copy-error', citation, {
|
|
689
|
+
target: 'citation',
|
|
690
|
+
placement: 'disclosure',
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (!fallbackStatus) return;
|
|
694
|
+
fallbackStatus.textContent = message;
|
|
695
|
+
clearTimeout(fallbackTimer);
|
|
696
|
+
fallbackTimer = setTimeout(() => {
|
|
697
|
+
fallbackStatus.textContent = '';
|
|
698
|
+
}, 3000);
|
|
699
|
+
};
|
|
700
|
+
if (fallbackCopy) {
|
|
701
|
+
track(fallbackCopy, 'Copy citation (disclosure)');
|
|
702
|
+
fallbackCopy.hidden = false;
|
|
703
|
+
fallbackCopy.addEventListener('click', onFallbackCopy);
|
|
704
|
+
}
|
|
705
|
+
instances.set(element, () => {
|
|
706
|
+
clearTimeout(fallbackTimer);
|
|
707
|
+
if (fallbackCopy) {
|
|
708
|
+
fallbackCopy.removeEventListener('click', onFallbackCopy);
|
|
709
|
+
fallbackCopy.hidden = true;
|
|
710
|
+
delete fallbackCopy.dataset.vfAnalyticsLabel;
|
|
711
|
+
}
|
|
712
|
+
if (regionSet) element.removeAttribute(REGION);
|
|
713
|
+
if (generated) fallback.remove();
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const trigger = doc.createElement('button');
|
|
719
|
+
trigger.type = 'button';
|
|
720
|
+
trigger.className = 'mg-cite-this__trigger';
|
|
721
|
+
trigger.textContent = summary.textContent.trim();
|
|
722
|
+
trigger.setAttribute('aria-haspopup', 'dialog');
|
|
723
|
+
|
|
724
|
+
const dialogId = `mg-cite-this-dialog-${++nextDialogId}`;
|
|
725
|
+
const title = doc.createElement('h2');
|
|
726
|
+
title.id = `${dialogId}-title`;
|
|
727
|
+
title.className = 'mg-cite-this__title';
|
|
728
|
+
title.tabIndex = -1;
|
|
729
|
+
title.textContent = trigger.textContent;
|
|
730
|
+
|
|
731
|
+
const close = doc.createElement('button');
|
|
732
|
+
close.type = 'button';
|
|
733
|
+
close.className = 'mg-icon-button mg-cite-this__close';
|
|
734
|
+
close.setAttribute(
|
|
735
|
+
'aria-label',
|
|
736
|
+
element.dataset.mgCiteThisCloseLabel || t.close
|
|
737
|
+
);
|
|
738
|
+
const closeIcon = doc.createElement('span');
|
|
739
|
+
closeIcon.className = 'mg-icon mg-icon-close';
|
|
740
|
+
closeIcon.setAttribute('aria-hidden', 'true');
|
|
741
|
+
close.append(closeIcon);
|
|
742
|
+
|
|
743
|
+
const header = doc.createElement('div');
|
|
744
|
+
header.className = 'mg-cite-this__dialog-header';
|
|
745
|
+
header.append(title, close);
|
|
746
|
+
|
|
747
|
+
const dialogContent = content.cloneNode(true);
|
|
748
|
+
const copy = dialogContent.querySelector('[data-mg-cite-this-copy]');
|
|
749
|
+
const status = dialogContent.querySelector('[data-mg-cite-this-status]');
|
|
750
|
+
if (copy) {
|
|
751
|
+
copy.hidden = false;
|
|
752
|
+
track(copy, 'Copy citation (dialog)');
|
|
753
|
+
}
|
|
754
|
+
for (const link of dialogContent.querySelectorAll(
|
|
755
|
+
'[data-mg-cite-this-text] a'
|
|
756
|
+
)) {
|
|
757
|
+
track(link, 'Citation link');
|
|
758
|
+
}
|
|
759
|
+
track(close, 'Close');
|
|
760
|
+
let copyUrl;
|
|
761
|
+
if (copy && element.dataset.mgCiteThisCopyUrl !== 'false') {
|
|
762
|
+
copyUrl = doc.createElement('button');
|
|
763
|
+
copyUrl.type = 'button';
|
|
764
|
+
copyUrl.className = 'mg-cite-this__copy mg-cite-this__copy-url';
|
|
765
|
+
copyUrl.textContent = element.dataset.mgCiteThisCopyUrlLabel || t.copyUrl;
|
|
766
|
+
track(copyUrl, 'Copy citation URL');
|
|
767
|
+
copy.after(copyUrl);
|
|
768
|
+
}
|
|
769
|
+
const exports = exportFormats(element, citation).map(format => {
|
|
770
|
+
const { extension, mime, build } = EXPORT_FORMATS[format];
|
|
771
|
+
const link = doc.createElement('a');
|
|
772
|
+
link.className = 'mg-cite-this__export';
|
|
773
|
+
link.href = `data:${mime};charset=utf-8,${encodeURIComponent(
|
|
774
|
+
build(citation.fields)
|
|
775
|
+
)}`;
|
|
776
|
+
link.download = `${fileName(citation.fields.title)}.${extension}`;
|
|
777
|
+
link.textContent =
|
|
778
|
+
element.dataset[
|
|
779
|
+
`mgCiteThis${format === 'ris' ? 'Ris' : 'Bibtex'}Label`
|
|
780
|
+
] || t[format];
|
|
781
|
+
link.dataset.mgCiteThisFormat = format;
|
|
782
|
+
track(link, `Download ${format === 'ris' ? 'RIS' : 'BibTeX'}`);
|
|
783
|
+
return link;
|
|
784
|
+
});
|
|
785
|
+
if (exports.length) {
|
|
786
|
+
const list = doc.createElement('div');
|
|
787
|
+
list.className = 'mg-cite-this__exports';
|
|
788
|
+
list.append(...exports);
|
|
789
|
+
dialogContent.append(list);
|
|
790
|
+
}
|
|
791
|
+
dialog.className = 'mg-cite-this__dialog';
|
|
792
|
+
dialog.setAttribute('aria-labelledby', title.id);
|
|
793
|
+
dialog.append(header, dialogContent);
|
|
794
|
+
// The default layout mirrors ShareButtons: a label, the citation as a
|
|
795
|
+
// copyable field, and a quiet link to the full dialog. The compact
|
|
796
|
+
// variant is the dialog trigger alone.
|
|
797
|
+
const panel = !element.classList.contains('mg-cite-this--compact');
|
|
798
|
+
element.dataset.mgCiteThisLayout = panel ? 'default' : 'compact';
|
|
799
|
+
const authored = ['role', 'aria-labelledby'].map(name => [
|
|
800
|
+
name,
|
|
801
|
+
element.getAttribute(name),
|
|
802
|
+
]);
|
|
803
|
+
let wrapper;
|
|
804
|
+
let label;
|
|
805
|
+
let preview;
|
|
806
|
+
let previewText;
|
|
807
|
+
let previewStatus;
|
|
808
|
+
if (panel) {
|
|
809
|
+
wrapper = doc.createElement('div');
|
|
810
|
+
wrapper.className = 'mg-cite-this__panel';
|
|
811
|
+
label = doc.createElement('p');
|
|
812
|
+
label.id = `${dialogId}-label`;
|
|
813
|
+
label.className = 'mg-cite-this__header';
|
|
814
|
+
label.textContent = trigger.textContent;
|
|
815
|
+
element.setAttribute('role', 'group');
|
|
816
|
+
element.setAttribute('aria-labelledby', label.id);
|
|
817
|
+
|
|
818
|
+
preview = doc.createElement('button');
|
|
819
|
+
preview.type = 'button';
|
|
820
|
+
preview.className = 'mg-cite-this__preview';
|
|
821
|
+
// The visible citation stays in the accessible name (WCAG 2.5.3); the
|
|
822
|
+
// hidden prefix says what pressing it does.
|
|
823
|
+
const previewAction = doc.createElement('span');
|
|
824
|
+
previewAction.className = 'mg-u-sr-only';
|
|
825
|
+
previewAction.textContent = `${
|
|
826
|
+
element.dataset.mgCiteThisCopyLabel || t.copy
|
|
827
|
+
}: `;
|
|
828
|
+
const previewIcon = doc.createElement('span');
|
|
829
|
+
previewIcon.className = 'mg-icon mg-icon-file-alt';
|
|
830
|
+
previewIcon.setAttribute('aria-hidden', 'true');
|
|
831
|
+
previewText = doc.createElement('span');
|
|
832
|
+
previewText.className = 'mg-cite-this__preview-text';
|
|
833
|
+
renderCitation(previewText, citation, false);
|
|
834
|
+
const copyIcon = doc.createElement('span');
|
|
835
|
+
copyIcon.className = 'mg-icon mg-icon-clone';
|
|
836
|
+
copyIcon.setAttribute('aria-hidden', 'true');
|
|
837
|
+
preview.append(previewIcon, previewAction, previewText, copyIcon);
|
|
838
|
+
track(preview, 'Copy citation (field)');
|
|
839
|
+
|
|
840
|
+
previewStatus = doc.createElement('span');
|
|
841
|
+
previewStatus.className = 'mg-u-sr-only';
|
|
842
|
+
previewStatus.setAttribute('role', 'status');
|
|
843
|
+
previewStatus.setAttribute('aria-live', 'polite');
|
|
844
|
+
|
|
845
|
+
trigger.textContent = element.dataset.mgCiteThisMoreLabel || t.more;
|
|
846
|
+
trigger.classList.add('mg-cite-this__more');
|
|
847
|
+
track(trigger, 'All citation options');
|
|
848
|
+
wrapper.append(label, preview, previewStatus, trigger);
|
|
849
|
+
element.append(wrapper, dialog);
|
|
850
|
+
} else {
|
|
851
|
+
track(trigger, 'Open');
|
|
852
|
+
element.append(trigger, dialog);
|
|
853
|
+
}
|
|
854
|
+
fallback.hidden = true;
|
|
855
|
+
|
|
856
|
+
let statusTimer;
|
|
857
|
+
let previewTimer;
|
|
858
|
+
// `trigger` is a person opening the dialog; `copy-error` is the dialog
|
|
859
|
+
// opening itself after a failed copy from the field.
|
|
860
|
+
let returnFocus = trigger;
|
|
861
|
+
const openDialog = (placement, opener = trigger) => {
|
|
862
|
+
returnFocus = opener;
|
|
863
|
+
dialog.showModal();
|
|
864
|
+
title.focus();
|
|
865
|
+
emit(element, 'open', citation, { placement });
|
|
866
|
+
};
|
|
867
|
+
const onOpen = () => openDialog('trigger');
|
|
868
|
+
const onClose = () => {
|
|
869
|
+
clearTimeout(statusTimer);
|
|
870
|
+
if (status) status.textContent = '';
|
|
871
|
+
returnFocus.focus();
|
|
872
|
+
};
|
|
873
|
+
const closeDialog = () => dialog.close();
|
|
874
|
+
const announce = (message, delay) => {
|
|
875
|
+
if (status) status.textContent = message;
|
|
876
|
+
clearTimeout(statusTimer);
|
|
877
|
+
statusTimer = setTimeout(() => {
|
|
878
|
+
if (status) status.textContent = '';
|
|
879
|
+
}, delay);
|
|
880
|
+
};
|
|
881
|
+
const copyText = async (text, copiedLabel, failedLabel, eventName) => {
|
|
882
|
+
try {
|
|
883
|
+
await navigator.clipboard.writeText(text);
|
|
884
|
+
announce(copiedLabel, 3000);
|
|
885
|
+
emit(element, eventName, citation, { placement: 'dialog' });
|
|
886
|
+
} catch {
|
|
887
|
+
announce(failedLabel, 5000);
|
|
888
|
+
emit(element, 'copy-error', citation, {
|
|
889
|
+
target: eventName === 'copy' ? 'citation' : 'url',
|
|
890
|
+
placement: 'dialog',
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const onCopy = () =>
|
|
895
|
+
copyText(
|
|
896
|
+
citation.text,
|
|
897
|
+
element.dataset.mgCiteThisCopiedLabel || t.copied,
|
|
898
|
+
element.dataset.mgCiteThisFailedLabel || t.failed,
|
|
899
|
+
'copy'
|
|
900
|
+
);
|
|
901
|
+
// The citation's own locator, not a shortlink, so the copied link lasts.
|
|
902
|
+
const onCopyUrl = () =>
|
|
903
|
+
copyText(
|
|
904
|
+
citation.url,
|
|
905
|
+
element.dataset.mgCiteThisUrlCopiedLabel || t.urlCopied,
|
|
906
|
+
element.dataset.mgCiteThisUrlFailedLabel || t.urlFailed,
|
|
907
|
+
'copy-url'
|
|
908
|
+
);
|
|
909
|
+
const onPreviewCopy = async () => {
|
|
910
|
+
const message = element.dataset.mgCiteThisCopiedLabel || t.copied;
|
|
911
|
+
try {
|
|
912
|
+
await navigator.clipboard.writeText(citation.text);
|
|
913
|
+
emit(element, 'copy', citation, { placement: 'field' });
|
|
914
|
+
} catch {
|
|
915
|
+
// The field is a button, so open the dialog where the citation is
|
|
916
|
+
// selectable text and explain there.
|
|
917
|
+
emit(element, 'copy-error', citation, {
|
|
918
|
+
target: 'citation',
|
|
919
|
+
placement: 'field',
|
|
920
|
+
});
|
|
921
|
+
openDialog('copy-error', preview);
|
|
922
|
+
announce(element.dataset.mgCiteThisFailedLabel || t.failed, 5000);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
previewText.textContent = message;
|
|
926
|
+
previewStatus.textContent = message;
|
|
927
|
+
clearTimeout(previewTimer);
|
|
928
|
+
previewTimer = setTimeout(() => {
|
|
929
|
+
renderCitation(previewText, citation, false);
|
|
930
|
+
previewStatus.textContent = '';
|
|
931
|
+
}, 3000);
|
|
932
|
+
};
|
|
933
|
+
const onDialogClick = event => {
|
|
934
|
+
const format = event.target.closest('[data-mg-cite-this-format]')?.dataset
|
|
935
|
+
.mgCiteThisFormat;
|
|
936
|
+
if (format) {
|
|
937
|
+
emit(element, 'export', citation, { format, placement: 'dialog' });
|
|
938
|
+
} else if (event.target.closest('[data-mg-cite-this-text] a')) {
|
|
939
|
+
emit(element, 'visit', citation, { placement: 'dialog' });
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
trigger.addEventListener('click', onOpen);
|
|
943
|
+
dialogContent.addEventListener('click', onDialogClick);
|
|
944
|
+
close.addEventListener('click', closeDialog);
|
|
945
|
+
dialog.addEventListener('close', onClose);
|
|
946
|
+
copy?.addEventListener('click', onCopy);
|
|
947
|
+
copyUrl?.addEventListener('click', onCopyUrl);
|
|
948
|
+
preview?.addEventListener('click', onPreviewCopy);
|
|
949
|
+
instances.set(element, () => {
|
|
950
|
+
// Close first, while the close handler still returns focus.
|
|
951
|
+
if (dialog.open) dialog.close();
|
|
952
|
+
clearTimeout(statusTimer);
|
|
953
|
+
clearTimeout(previewTimer);
|
|
954
|
+
trigger.removeEventListener('click', onOpen);
|
|
955
|
+
close.removeEventListener('click', closeDialog);
|
|
956
|
+
dialog.removeEventListener('close', onClose);
|
|
957
|
+
copy?.removeEventListener('click', onCopy);
|
|
958
|
+
copyUrl?.removeEventListener('click', onCopyUrl);
|
|
959
|
+
dialogContent.removeEventListener('click', onDialogClick);
|
|
960
|
+
preview?.removeEventListener('click', onPreviewCopy);
|
|
961
|
+
wrapper?.remove();
|
|
962
|
+
if (regionSet) element.removeAttribute(REGION);
|
|
963
|
+
for (const [name, value] of authored) {
|
|
964
|
+
if (value === null) element.removeAttribute(name);
|
|
965
|
+
else element.setAttribute(name, value);
|
|
966
|
+
}
|
|
967
|
+
trigger.remove();
|
|
968
|
+
dialog.remove();
|
|
969
|
+
if (generated) fallback.remove();
|
|
970
|
+
else fallback.hidden = false;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Remove listeners before replacing content in a client-side navigation. */
|
|
976
|
+
export function mgCiteThisDestroy(scope = document) {
|
|
977
|
+
for (const element of elementsIn(scope)) {
|
|
978
|
+
if (!instances.has(element)) continue;
|
|
979
|
+
instances.get(element)();
|
|
980
|
+
instances.delete(element);
|
|
981
|
+
delete element.dataset.mgCiteThisReady;
|
|
982
|
+
delete element.dataset.mgCiteThisLayout;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (typeof document !== 'undefined') {
|
|
987
|
+
if (document.readyState === 'loading') {
|
|
988
|
+
document.addEventListener('DOMContentLoaded', () => mgCiteThis(), {
|
|
989
|
+
once: true,
|
|
990
|
+
});
|
|
991
|
+
} else {
|
|
992
|
+
mgCiteThis();
|
|
993
|
+
}
|
|
994
|
+
}
|