@undrr/undrr-mangrove 2.0.1 → 2.1.1

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.
Files changed (98) hide show
  1. package/README.md +1 -1
  2. package/components/BookCard.js +1 -1
  3. package/components/Breadcrumbs.js +1 -1
  4. package/components/Checkbox.js +1 -1
  5. package/components/Chips.js +1 -1
  6. package/components/CopyButton.js +1 -1
  7. package/components/CtaButton.js +1 -1
  8. package/components/Drawer.js +2 -2
  9. package/components/EmbedContainer.js +1 -1
  10. package/components/Footer.js +1 -1
  11. package/components/FormAction.js +1 -1
  12. package/components/FormErrorSummary.js +1 -1
  13. package/components/FormGroup.js +1 -1
  14. package/components/FullWidth.js +1 -1
  15. package/components/Gallery.js +2 -2
  16. package/components/Hero.js +2 -2
  17. package/components/HighlightBox.js +1 -1
  18. package/components/HorizontalBookCard.js +1 -1
  19. package/components/HorizontalCard.js +1 -1
  20. package/components/IconCard.js +1 -1
  21. package/components/Legend.js +1 -1
  22. package/components/Loader.js +2 -2
  23. package/components/MegaMenu.js +2 -2
  24. package/components/Notice.js +1 -1
  25. package/components/PageHeader.js +1 -1
  26. package/components/Pager.js +1 -1
  27. package/components/ProgressBar.js +2 -2
  28. package/components/QuoteHighlight.js +1 -1
  29. package/components/Radio.js +1 -1
  30. package/components/Range.js +1 -1
  31. package/components/ScrollContainer.js +1 -1
  32. package/components/SegmentedControl.js +1 -1
  33. package/components/Select.js +1 -1
  34. package/components/ServiceNotice.js +1 -1
  35. package/components/ShareButtons.js +1 -1
  36. package/components/ShowMore.js +2 -2
  37. package/components/SkipLink.js +1 -1
  38. package/components/StatsCard.js +1 -1
  39. package/components/SyndicationSearchWidget.js +2 -2
  40. package/components/Tab.js +1 -1
  41. package/components/TextCta.js +1 -1
  42. package/components/TextInput.js +1 -1
  43. package/components/Textarea.js +1 -1
  44. package/components/Tree.js +1 -1
  45. package/components/UserFeedback.js +1 -1
  46. package/components/VerticalCard.js +1 -1
  47. package/components/hydrate.js +2 -2
  48. package/css/style-all.css +41 -42
  49. package/css/style-all.css.map +1 -1
  50. package/css/style-delta.css +41 -42
  51. package/css/style-delta.css.map +1 -1
  52. package/css/style-gutenberg.css +4 -4
  53. package/css/style-gutenberg.css.map +1 -1
  54. package/css/style-irp.css +41 -42
  55. package/css/style-irp.css.map +1 -1
  56. package/css/style-mcr.css +41 -42
  57. package/css/style-mcr.css.map +1 -1
  58. package/css/style-preventionweb.css +41 -42
  59. package/css/style-preventionweb.css.map +1 -1
  60. package/css/style.css +41 -42
  61. package/css/style.css.map +1 -1
  62. package/docs/EDITORIAL-MANUAL.md +1 -1
  63. package/docs/llms-motion.txt +125 -0
  64. package/error-pages/401.html +1 -1
  65. package/error-pages/403.html +1 -1
  66. package/error-pages/404.html +1 -1
  67. package/error-pages/429.html +1 -1
  68. package/error-pages/500.html +1 -1
  69. package/error-pages/502.html +1 -1
  70. package/error-pages/503.html +1 -1
  71. package/error-pages/504.html +1 -1
  72. package/error-pages/5xx.html +1 -1
  73. package/error-pages/challenge.html +1 -1
  74. package/error-pages/managed-challenge.html +1 -1
  75. package/js/cite-this.js +685 -43
  76. package/js/on-this-page-nav.js +80 -1
  77. package/js/show-more.js +149 -21
  78. package/package.json +1 -1
  79. package/scss/Atom/Images/AuthorImage/author-image.scss +9 -2
  80. package/scss/Atom/ReachElement/Details/details.scss +4 -2
  81. package/scss/Components/Cards/Card/card.scss +8 -2
  82. package/scss/Components/Cards/StatsCard/stats-card.scss +8 -1
  83. package/scss/Components/CiteThis/cite-this.scss +162 -9
  84. package/scss/Components/Gallery/gallery.scss +40 -15
  85. package/scss/Components/Hero/hero.scss +165 -0
  86. package/scss/Components/MegaMenu/mega-menu.scss +42 -121
  87. package/scss/Components/Navigation/Drawer/drawer.scss +10 -0
  88. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +108 -3
  89. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +258 -127
  90. package/scss/Components/Tab/tab.scss +2 -1
  91. package/scss/Patterns/RichContent/rich-content.scss +177 -0
  92. package/scss/Patterns/_shared/pattern-demo.scss +5 -0
  93. package/scss/Utilities/Loader/loader.scss +89 -11
  94. package/scss/Utilities/ShowMore/show-more.scss +6 -0
  95. package/scss/assets/scss/_components.scss +3 -0
  96. package/scss/assets/scss/_foundational.scss +23 -8
  97. package/scss/assets/scss/_mixins.scss +5 -0
  98. package/scss/assets/scss/_variables.scss +1 -1
package/js/cite-this.js CHANGED
@@ -18,6 +18,152 @@ const WORK_TYPES = new Set([
18
18
  const instances = new WeakMap();
19
19
  let nextDialogId = 0;
20
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
+
21
167
  function firstText(value) {
22
168
  if (Array.isArray(value))
23
169
  return value.map(firstText).filter(Boolean).join(', ');
@@ -103,18 +249,79 @@ function schemaWork(doc, canonical) {
103
249
  );
104
250
  }
105
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
+
106
304
  function publicationYear(value) {
107
305
  const match = String(value || '').match(/^(\d{4})(?:-|$)/);
108
306
  return match ? match[1] : '';
109
307
  }
110
308
 
111
- function formatCitation({ author, title, publisher, year, url, accessed }) {
112
- const lead = author ? `${author}${year ? ` (${year})` : ''}, ` : '';
113
- const titlePart = `“${title}”`;
114
- const source = publisher && publisher !== author ? `, ${publisher}` : '';
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}` : '';
115
316
  const date = !author && year ? ` (${year})` : '';
116
- const access = year ? '' : `, accessed ${accessed}`;
117
- return `${lead}${titlePart}${source}${date}${access}, ${url}.`;
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
+ };
118
325
  }
119
326
 
120
327
  /** Resolve a citation, in editorial → configured → matching JSON-LD → page order. */
@@ -123,8 +330,6 @@ export function resolveCitation(element, now = new Date()) {
123
330
  const canonical = canonicalUrl(doc);
124
331
  const data = element.dataset;
125
332
  const explicit = data.mgCiteThisCitation?.trim();
126
- if (explicit) return { text: explicit, source: 'provided', url: canonical };
127
-
128
333
  const configured = [
129
334
  data.mgCiteThisAuthor,
130
335
  data.mgCiteThisPublisher,
@@ -144,6 +349,9 @@ export function resolveCitation(element, now = new Date()) {
144
349
  data.mgCiteThisSite?.trim() ||
145
350
  doc.querySelector('meta[property="og:site_name"]')?.content?.trim() ||
146
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);
147
355
  const author = data.mgCiteThisAuthor?.trim() || firstText(work?.author);
148
356
  const publisher =
149
357
  data.mgCiteThisPublisher?.trim() || firstText(work?.publisher) || site;
@@ -151,20 +359,227 @@ export function resolveCitation(element, now = new Date()) {
151
359
  const url =
152
360
  cleanUrl(data.mgCiteThisUrl || work?.url || canonical, doc.location.href) ||
153
361
  canonical;
154
- const pageLang = doc.documentElement.lang || 'en-GB';
362
+ const pageLang = languageOf(element);
155
363
  const locale = pageLang === 'en' ? 'en-GB' : pageLang;
156
364
  const accessed = new Intl.DateTimeFormat(locale, {
157
365
  day: 'numeric',
158
366
  month: 'long',
159
367
  year: 'numeric',
368
+ numberingSystem: 'latn',
160
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
+ );
161
395
  return {
162
- text: formatCitation({ author, title, publisher, year, url, accessed }),
396
+ text,
397
+ titleRange,
163
398
  source: configured ? 'metadata' : work ? 'schema' : 'page',
164
399
  url,
400
+ id,
401
+ fields,
165
402
  };
166
403
  }
167
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
+
168
583
  function elementsIn(scope) {
169
584
  if (!scope) return [];
170
585
  if (!scope.querySelectorAll) return Array.from(scope).flatMap(elementsIn);
@@ -174,29 +589,41 @@ function elementsIn(scope) {
174
589
  ];
175
590
  }
176
591
 
177
- function emit(element, name, citation) {
592
+ function emit(element, name, citation, extra = {}) {
178
593
  element.dispatchEvent(
179
594
  new CustomEvent(`mg-cite-this:${name}`, {
180
595
  bubbles: true,
181
596
  detail: {
182
597
  source: citation.source,
598
+ id: citation.id,
183
599
  url: cleanUrl(citation.url, element.ownerDocument.location.href, true),
600
+ pageUrl: canonicalUrl(element.ownerDocument).replace(/\?.*$/, ''),
601
+ layout: element.dataset.mgCiteThisLayout,
602
+ ...extra,
184
603
  },
185
604
  })
186
605
  );
187
606
  }
188
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
+
189
615
  function createFallback(element) {
190
616
  const doc = element.ownerDocument;
617
+ const t = citeThisStrings(languageOf(element));
191
618
  const fallback = doc.createElement('details');
192
619
  fallback.className = 'mg-cite-this__fallback';
193
620
  const summary = doc.createElement('summary');
194
621
  summary.className = 'mg-cite-this__summary';
195
- summary.textContent = element.dataset.mgCiteThisLabel || 'Cite this';
622
+ summary.textContent = element.dataset.mgCiteThisLabel || t.label;
196
623
  const content = doc.createElement('div');
197
624
  content.className = 'mg-cite-this__content';
198
625
  const output = doc.createElement('p');
199
- output.className = 'mg-cite-this__text mg-code';
626
+ output.className = 'mg-cite-this__text';
200
627
  output.dataset.mgCiteThisText = '';
201
628
  const actions = doc.createElement('div');
202
629
  actions.className = 'mg-cite-this__actions';
@@ -204,7 +631,7 @@ function createFallback(element) {
204
631
  copy.type = 'button';
205
632
  copy.className = 'mg-cite-this__copy';
206
633
  copy.dataset.mgCiteThisCopy = '';
207
- copy.textContent = element.dataset.mgCiteThisCopyLabel || 'Copy citation';
634
+ copy.textContent = element.dataset.mgCiteThisCopyLabel || t.copy;
208
635
  copy.hidden = true;
209
636
  const status = doc.createElement('span');
210
637
  status.className = 'mg-cite-this__status';
@@ -221,7 +648,10 @@ function createFallback(element) {
221
648
  /** Initialise citation dialogs within a document or Drupal behaviour scope. */
222
649
  export function mgCiteThis(scope = document) {
223
650
  for (const element of elementsIn(scope)) {
224
- if (instances.has(element)) continue;
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;
225
655
  const existing = element.querySelector('details.mg-cite-this__fallback');
226
656
  const generated = !existing && element.childElementCount === 0;
227
657
  const fallback = generated ? createFallback(element) : existing;
@@ -231,12 +661,57 @@ export function mgCiteThis(scope = document) {
231
661
  if (!fallback || !summary || !content || !output) continue;
232
662
 
233
663
  const doc = element.ownerDocument;
664
+ const t = citeThisStrings(languageOf(element));
234
665
  const citation = resolveCitation(element);
235
- output.textContent = citation.text;
666
+ renderCitation(output, citation);
236
667
  element.dataset.mgCiteThisSource = citation.source;
237
668
  const dialog = doc.createElement('dialog');
669
+ element.dataset.mgCiteThisReady = '';
670
+ const regionSet = !element.hasAttribute(REGION);
671
+ if (regionSet) element.setAttribute(REGION, 'cite-this');
238
672
  if (typeof dialog.showModal !== 'function') {
239
- if (generated) instances.set(element, () => fallback.remove());
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
+ });
240
715
  continue;
241
716
  }
242
717
 
@@ -258,7 +733,7 @@ export function mgCiteThis(scope = document) {
258
733
  close.className = 'mg-icon-button mg-cite-this__close';
259
734
  close.setAttribute(
260
735
  'aria-label',
261
- element.dataset.mgCiteThisCloseLabel || 'Close'
736
+ element.dataset.mgCiteThisCloseLabel || t.close
262
737
  );
263
738
  const closeIcon = doc.createElement('span');
264
739
  closeIcon.className = 'mg-icon mg-icon-close';
@@ -272,59 +747,223 @@ export function mgCiteThis(scope = document) {
272
747
  const dialogContent = content.cloneNode(true);
273
748
  const copy = dialogContent.querySelector('[data-mg-cite-this-copy]');
274
749
  const status = dialogContent.querySelector('[data-mg-cite-this-status]');
275
- if (copy) copy.hidden = false;
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
+ }
276
791
  dialog.className = 'mg-cite-this__dialog';
277
792
  dialog.setAttribute('aria-labelledby', title.id);
278
793
  dialog.append(header, dialogContent);
279
- element.append(trigger, dialog);
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
+ }
280
854
  fallback.hidden = true;
281
855
 
282
856
  let statusTimer;
283
- const onOpen = () => {
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;
284
863
  dialog.showModal();
285
864
  title.focus();
286
- emit(element, 'open', citation);
865
+ emit(element, 'open', citation, { placement });
287
866
  };
867
+ const onOpen = () => openDialog('trigger');
288
868
  const onClose = () => {
289
869
  clearTimeout(statusTimer);
290
870
  if (status) status.textContent = '';
291
- trigger.focus();
871
+ returnFocus.focus();
292
872
  };
293
873
  const closeDialog = () => dialog.close();
294
- const onCopy = async () => {
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;
295
911
  try {
296
912
  await navigator.clipboard.writeText(citation.text);
297
- if (status)
298
- status.textContent =
299
- element.dataset.mgCiteThisCopiedLabel || 'Citation copied';
300
- emit(element, 'copy', citation);
301
- clearTimeout(statusTimer);
302
- statusTimer = setTimeout(() => {
303
- if (status) status.textContent = '';
304
- }, 3000);
913
+ emit(element, 'copy', citation, { placement: 'field' });
305
914
  } catch {
306
- if (status)
307
- status.textContent =
308
- element.dataset.mgCiteThisFailedLabel ||
309
- 'Copy failed. Select the citation and copy it manually.';
310
- emit(element, 'copy-error', citation);
311
- clearTimeout(statusTimer);
312
- statusTimer = setTimeout(() => {
313
- if (status) status.textContent = '';
314
- }, 5000);
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' });
315
940
  }
316
941
  };
317
942
  trigger.addEventListener('click', onOpen);
943
+ dialogContent.addEventListener('click', onDialogClick);
318
944
  close.addEventListener('click', closeDialog);
319
945
  dialog.addEventListener('close', onClose);
320
946
  copy?.addEventListener('click', onCopy);
947
+ copyUrl?.addEventListener('click', onCopyUrl);
948
+ preview?.addEventListener('click', onPreviewCopy);
321
949
  instances.set(element, () => {
950
+ // Close first, while the close handler still returns focus.
951
+ if (dialog.open) dialog.close();
322
952
  clearTimeout(statusTimer);
953
+ clearTimeout(previewTimer);
323
954
  trigger.removeEventListener('click', onOpen);
324
955
  close.removeEventListener('click', closeDialog);
325
956
  dialog.removeEventListener('close', onClose);
326
957
  copy?.removeEventListener('click', onCopy);
327
- if (dialog.open) dialog.close();
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
+ }
328
967
  trigger.remove();
329
968
  dialog.remove();
330
969
  if (generated) fallback.remove();
@@ -336,8 +975,11 @@ export function mgCiteThis(scope = document) {
336
975
  /** Remove listeners before replacing content in a client-side navigation. */
337
976
  export function mgCiteThisDestroy(scope = document) {
338
977
  for (const element of elementsIn(scope)) {
339
- instances.get(element)?.();
978
+ if (!instances.has(element)) continue;
979
+ instances.get(element)();
340
980
  instances.delete(element);
981
+ delete element.dataset.mgCiteThisReady;
982
+ delete element.dataset.mgCiteThisLayout;
341
983
  }
342
984
  }
343
985