dragoman 0.5.18

Server for scholarly metadata in commonmeta format stored in a SQLite database.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
import katex from 'katex'
import LANGUAGE_NAMES from './languages.json'
import FOS_TERMS from './fos.json'

export const TYPE_LABELS = {
  'de-DE': {
    Article: 'Artikel', Audiovisual: 'Audiovisuell', BlogPost: 'Blogbeitrag',
    Book: 'Buch', BookChapter: 'Buchkapitel', BookSeries: 'Buchreihe',
    Collection: 'Sammlung', Component: 'Komponente', ComputationalNotebook: 'Computational Notebook',
    DataPaper: 'Datenpapier', Dataset: 'Datensatz', Dissertation: 'Dissertation',
    Document: 'Dokument', Entry: 'Eintrag', Event: 'Veranstaltung',
    Figure: 'Abbildung', Grant: 'Förderantrag', Image: 'Bild',
    Instrument: 'Instrument', InteractiveResource: 'Interaktive Ressource',
    Journal: 'Zeitschrift', JournalArticle: 'Zeitschriftenartikel',
    LegalDocument: 'Rechtsdokument', Manuscript: 'Manuskript', Map: 'Karte',
    Model: 'Modell', Other: 'Sonstiges', OutputManagementPlan: 'Datenmanagementplan',
    Patent: 'Patent', PeerReview: 'Fachgutachten', Performance: 'Aufführung',
    PersonalCommunication: 'Persönliche Mitteilung', PhysicalObject: 'Physisches Objekt',
    Poster: 'Poster', Presentation: 'Präsentation', Preprint: 'Preprint',
    ProceedingsArticle: 'Konferenzbeitrag', Report: 'Bericht', Service: 'Dienst',
    Software: 'Software', Sound: 'Ton', Standard: 'Norm',
    StudyRegistration: 'Studienregistrierung', WebPage: 'Webseite',
  },
  'fr-FR': {
    Article: 'Article', Audiovisual: 'Audiovisuel', BlogPost: 'Article de blog',
    Book: 'Livre', BookChapter: 'Chapitre de livre', BookSeries: 'Série de livres',
    Collection: 'Collection', Component: 'Composant', ComputationalNotebook: 'Carnet de calcul',
    DataPaper: 'Article de données', Dataset: 'Ensemble de données', Dissertation: 'Thèse',
    Document: 'Document', Entry: 'Entrée', Event: 'Événement',
    Figure: 'Figure', Grant: 'Subvention', Image: 'Image',
    Instrument: 'Instrument', InteractiveResource: 'Ressource interactive',
    Journal: 'Revue', JournalArticle: 'Article de revue',
    LegalDocument: 'Document juridique', Manuscript: 'Manuscrit', Map: 'Carte',
    Model: 'Modèle', Other: 'Autre', OutputManagementPlan: 'Plan de gestion des données',
    Patent: 'Brevet', PeerReview: 'Évaluation par les pairs', Performance: 'Représentation',
    PersonalCommunication: 'Communication personnelle', PhysicalObject: 'Objet physique',
    Poster: 'Affiche', Presentation: 'Présentation', Preprint: 'Préimpression',
    ProceedingsArticle: 'Article de conférence', Report: 'Rapport', Service: 'Service',
    Software: 'Logiciel', Sound: 'Son', Standard: 'Norme',
    StudyRegistration: "Enregistrement d'étude", WebPage: 'Page web',
  },
  'es-ES': {
    Article: 'Artículo', Audiovisual: 'Audiovisual', BlogPost: 'Entrada de blog',
    Book: 'Libro', BookChapter: 'Capítulo de libro', BookSeries: 'Serie de libros',
    Collection: 'Colección', Component: 'Componente', ComputationalNotebook: 'Cuaderno computacional',
    DataPaper: 'Artículo de datos', Dataset: 'Conjunto de datos', Dissertation: 'Tesis',
    Document: 'Documento', Entry: 'Entrada', Event: 'Evento',
    Figure: 'Figura', Grant: 'Subvención', Image: 'Imagen',
    Instrument: 'Instrumento', InteractiveResource: 'Recurso interactivo',
    Journal: 'Revista', JournalArticle: 'Artículo de revista',
    LegalDocument: 'Documento legal', Manuscript: 'Manuscrito', Map: 'Mapa',
    Model: 'Modelo', Other: 'Otro', OutputManagementPlan: 'Plan de gestión de datos',
    Patent: 'Patente', PeerReview: 'Revisión por pares', Performance: 'Actuación',
    PersonalCommunication: 'Comunicación personal', PhysicalObject: 'Objeto físico',
    Poster: 'Póster', Presentation: 'Presentación', Preprint: 'Preimpresión',
    ProceedingsArticle: 'Artículo de conferencia', Report: 'Informe', Service: 'Servicio',
    Software: 'Software', Sound: 'Sonido', Standard: 'Norma',
    StudyRegistration: 'Registro de estudio', WebPage: 'Página web',
  },
  'it-IT': {
    Article: 'Articolo', Audiovisual: 'Audiovisivo', BlogPost: 'Articolo di blog',
    Book: 'Libro', BookChapter: 'Capitolo di libro', BookSeries: 'Serie di libri',
    Collection: 'Raccolta', Component: 'Componente', ComputationalNotebook: 'Quaderno computazionale',
    DataPaper: 'Articolo di dati', Dataset: 'Set di dati', Dissertation: 'Tesi',
    Document: 'Documento', Entry: 'Voce', Event: 'Evento',
    Figure: 'Figura', Grant: 'Finanziamento', Image: 'Immagine',
    Instrument: 'Strumento', InteractiveResource: 'Risorsa interattiva',
    Journal: 'Rivista', JournalArticle: 'Articolo di rivista',
    LegalDocument: 'Documento legale', Manuscript: 'Manoscritto', Map: 'Mappa',
    Model: 'Modello', Other: 'Altro', OutputManagementPlan: 'Piano di gestione dei dati',
    Patent: 'Brevetto', PeerReview: 'Revisione paritaria', Performance: 'Rappresentazione',
    PersonalCommunication: 'Comunicazione personale', PhysicalObject: 'Oggetto fisico',
    Poster: 'Poster', Presentation: 'Presentazione', Preprint: 'Preprint',
    ProceedingsArticle: 'Atti di conferenza', Report: 'Rapporto', Service: 'Servizio',
    Software: 'Software', Sound: 'Suono', Standard: 'Norma',
    StudyRegistration: 'Registrazione dello studio', WebPage: 'Pagina web',
  },
  'ja-JP': {
    Article: '論文', Audiovisual: '映像・音声', BlogPost: 'ブログ記事',
    Book: '図書', BookChapter: '書籍の章', BookSeries: '叢書',
    Collection: 'コレクション', Component: 'コンポーネント', ComputationalNotebook: '計算ノートブック',
    DataPaper: 'データ論文', Dataset: 'データセット', Dissertation: '学位論文',
    Document: '文書', Entry: '項目', Event: 'イベント',
    Figure: '図', Grant: '研究助成', Image: '画像',
    Instrument: '機器', InteractiveResource: 'インタラクティブリソース',
    Journal: '学術誌', JournalArticle: '学術論文',
    LegalDocument: '法的文書', Manuscript: '原稿', Map: '地図',
    Model: 'モデル', Other: 'その他', OutputManagementPlan: 'データ管理計画',
    Patent: '特許', PeerReview: '査読', Performance: 'パフォーマンス',
    PersonalCommunication: '個人通信', PhysicalObject: '物理的対象',
    Poster: 'ポスター', Presentation: 'プレゼンテーション', Preprint: 'プレプリント',
    ProceedingsArticle: '会議論文', Report: '報告書', Service: 'サービス',
    Software: 'ソフトウェア', Sound: '音声', Standard: '規格',
    StudyRegistration: '研究登録', WebPage: 'ウェブページ',
  },
  'ko-KR': {
    Article: '논문', Audiovisual: '시청각 자료', BlogPost: '블로그 게시물',
    Book: '도서', BookChapter: '책 챕터', BookSeries: '도서 시리즈',
    Collection: '컬렉션', Component: '컴포넌트', ComputationalNotebook: '계산 노트북',
    DataPaper: '데이터 논문', Dataset: '데이터셋', Dissertation: '학위논문',
    Document: '문서', Entry: '항목', Event: '이벤트',
    Figure: '그림', Grant: '연구 지원금', Image: '이미지',
    Instrument: '기기', InteractiveResource: '대화형 자료',
    Journal: '학술지', JournalArticle: '학술 논문',
    LegalDocument: '법률 문서', Manuscript: '원고', Map: '지도',
    Model: '모델', Other: '기타', OutputManagementPlan: '데이터 관리 계획',
    Patent: '특허', PeerReview: '동료 심사', Performance: '공연',
    PersonalCommunication: '개인 통신', PhysicalObject: '물리적 객체',
    Poster: '포스터', Presentation: '발표', Preprint: '프리프린트',
    ProceedingsArticle: '학술대회 논문', Report: '보고서', Service: '서비스',
    Software: '소프트웨어', Sound: '음성', Standard: '표준',
    StudyRegistration: '연구 등록', WebPage: '웹 페이지',
  },
  'nl-NL': {
    Article: 'Artikel', Audiovisual: 'Audiovisueel', BlogPost: 'Blogbericht',
    Book: 'Boek', BookChapter: 'Boekhoofdstuk', BookSeries: 'Boekreeks',
    Collection: 'Collectie', Component: 'Component', ComputationalNotebook: 'Berekeningsnotebook',
    DataPaper: 'Datapaper', Dataset: 'Dataset', Dissertation: 'Proefschrift',
    Document: 'Document', Entry: 'Invoer', Event: 'Evenement',
    Figure: 'Figuur', Grant: 'Subsidie', Image: 'Afbeelding',
    Instrument: 'Instrument', InteractiveResource: 'Interactieve bron',
    Journal: 'Tijdschrift', JournalArticle: 'Tijdschriftartikel',
    LegalDocument: 'Juridisch document', Manuscript: 'Manuscript', Map: 'Kaart',
    Model: 'Model', Other: 'Overig', OutputManagementPlan: 'Data-managementplan',
    Patent: 'Octrooi', PeerReview: 'Collegiale toetsing', Performance: 'Uitvoering',
    PersonalCommunication: 'Persoonlijke communicatie', PhysicalObject: 'Fysiek object',
    Poster: 'Poster', Presentation: 'Presentatie', Preprint: 'Preprint',
    ProceedingsArticle: 'Congresartikel', Report: 'Rapport', Service: 'Dienst',
    Software: 'Software', Sound: 'Geluid', Standard: 'Norm',
    StudyRegistration: 'Studieregistratie', WebPage: 'Webpagina',
  },
  'pt-BR': {
    Article: 'Artigo', Audiovisual: 'Audiovisual', BlogPost: 'Postagem de blog',
    Book: 'Livro', BookChapter: 'Capítulo de livro', BookSeries: 'Série de livros',
    Collection: 'Coleção', Component: 'Componente', ComputationalNotebook: 'Caderno computacional',
    DataPaper: 'Artigo de dados', Dataset: 'Conjunto de dados', Dissertation: 'Dissertação',
    Document: 'Documento', Entry: 'Entrada', Event: 'Evento',
    Figure: 'Figura', Grant: 'Financiamento de pesquisa', Image: 'Imagem',
    Instrument: 'Instrumento', InteractiveResource: 'Recurso interativo',
    Journal: 'Periódico', JournalArticle: 'Artigo de periódico',
    LegalDocument: 'Documento legal', Manuscript: 'Manuscrito', Map: 'Mapa',
    Model: 'Modelo', Other: 'Outro', OutputManagementPlan: 'Plano de gestão de dados',
    Patent: 'Patente', PeerReview: 'Revisão por pares', Performance: 'Apresentação artística',
    PersonalCommunication: 'Comunicação pessoal', PhysicalObject: 'Objeto físico',
    Poster: 'Pôster', Presentation: 'Apresentação', Preprint: 'Preprint',
    ProceedingsArticle: 'Artigo de conferência', Report: 'Relatório', Service: 'Serviço',
    Software: 'Software', Sound: 'Som', Standard: 'Norma',
    StudyRegistration: 'Registro de estudo', WebPage: 'Página web',
  },
  'sv-SE': {
    Article: 'Artikel', Audiovisual: 'Audiovisuellt material', BlogPost: 'Blogginlägg',
    Book: 'Bok', BookChapter: 'Bokkapitel', BookSeries: 'Bokserie',
    Collection: 'Samling', Component: 'Komponent', ComputationalNotebook: 'Beräkningsnotatbok',
    DataPaper: 'Datapaper', Dataset: 'Dataset', Dissertation: 'Avhandling',
    Document: 'Dokument', Entry: 'Post', Event: 'Evenemang',
    Figure: 'Figur', Grant: 'Anslag', Image: 'Bild',
    Instrument: 'Instrument', InteractiveResource: 'Interaktiv resurs',
    Journal: 'Tidskrift', JournalArticle: 'Tidskriftsartikel',
    LegalDocument: 'Juridiskt dokument', Manuscript: 'Manuskript', Map: 'Karta',
    Model: 'Modell', Other: 'Övrigt', OutputManagementPlan: 'Datahanteringsplan',
    Patent: 'Patent', PeerReview: 'Kollegial granskning', Performance: 'Föreställning',
    PersonalCommunication: 'Personlig kommunikation', PhysicalObject: 'Fysiskt objekt',
    Poster: 'Poster', Presentation: 'Presentation', Preprint: 'Preprint',
    ProceedingsArticle: 'Konferensartikel', Report: 'Rapport', Service: 'Tjänst',
    Software: 'Programvara', Sound: 'Ljud', Standard: 'Standard',
    StudyRegistration: 'Studieregistrering', WebPage: 'Webbsida',
  },
  'zh-CN': {
    Article: '文章', Audiovisual: '视听资料', BlogPost: '博客文章',
    Book: '图书', BookChapter: '书目章节', BookSeries: '丛书',
    Collection: '集合', Component: '组件', ComputationalNotebook: '计算笔记本',
    DataPaper: '数据论文', Dataset: '数据集', Dissertation: '学位论文',
    Document: '文档', Entry: '条目', Event: '活动',
    Figure: '图表', Grant: '科研经费', Image: '图像',
    Instrument: '仪器', InteractiveResource: '交互式资源',
    Journal: '期刊', JournalArticle: '期刊文章',
    LegalDocument: '法律文件', Manuscript: '手稿', Map: '地图',
    Model: '模型', Other: '其他', OutputManagementPlan: '数据管理计划',
    Patent: '专利', PeerReview: '同行评审', Performance: '表演',
    PersonalCommunication: '个人通信', PhysicalObject: '实体对象',
    Poster: '海报', Presentation: '演示文稿', Preprint: '预印本',
    ProceedingsArticle: '会议论文', Report: '报告', Service: '服务',
    Software: '软件', Sound: '音频', Standard: '标准',
    StudyRegistration: '研究注册', WebPage: '网页',
  },
}

export function formatCount(n, locale) {
  return new Intl.NumberFormat(locale).format(n)
}

export function typeLabel(type, locale) {
  if (!type) return ''
  return TYPE_LABELS[locale]?.[type] ?? type.replace(/([A-Z])/g, ' $1').trim()
}

// Mirrors $defs/type enum in commonmeta_v1.0rc1.json
export const WORK_TYPES = [
  'Audiovisual', 'Blog', 'BlogPost', 'BlogVolume', 'Book', 'BookChapter',
  'BookPart', 'BookSection', 'BookSeries', 'BookSet', 'Collection', 'Component',
  'ComputationalNotebook', 'Database', 'Dataset', 'Dissertation', 'Document', 'Entry',
  'Event', 'Figure', 'Grant', 'Image', 'Instrument', 'InteractiveResource', 'Journal',
  'JournalArticle', 'JournalIssue', 'JournalVolume', 'LegalDocument', 'Manuscript',
  'Map', 'Model', 'OutputManagementPlan', 'Patent', 'PeerReview', 'Performance',
  'PersonalCommunication', 'PhysicalObject', 'Post', 'Poster', 'Presentation', 'Preprint',
  'Proceedings', 'ProceedingsArticle', 'ProceedingsSeries', 'Report', 'ReportComponent',
  'ReportSeries', 'Review', 'Service', 'Software', 'Sound', 'Standard',
  'StudyRegistration', 'WebPage', 'Workflow', 'Other',
]

/**
 * A `date_published` as a reader reads it: the full date, the month, or the
 * year alone, depending on how much the record knows.
 *
 * Defensive about what it is given, because it is given whatever a publisher
 * deposited. A record may carry a full timestamp where the schema asks for a
 * date — Frontiers and figshare components deposit `2026-07-15T09:01:52Z` —
 * and splitting that on `-` yielded `15T09:01:52Z`, whose `Number` is `NaN`,
 * so `Intl` threw `RangeError: Invalid time value`. Thrown from a card, that
 * took down the whole list it was in: a search for "data" rendered its
 * sidebar, its heading and its pager, and not one of its twenty results.
 * Anything unreadable now degrades to the year, or to nothing.
 */
export function formatDate(dateStr, locale) {
  if (!dateStr) return ''
  // The time is never shown, so it goes before the date is read. An empty
  // segment is not a number — `Number('')` is 0, which turned `2024-` into
  // December 2023 — and a month or day out of range is not one either, since
  // `new Date` would roll `2024-13` into the following year rather than admit
  // it cannot read it.
  const parts = String(dateStr)
    .trim()
    .split('T')[0]
    .split('-')
    .map(part => (part.trim() === '' ? NaN : Number(part)))
  const [year, month, day] = parts
  if (!Number.isInteger(year)) return ''
  const within = (value, max) => Number.isInteger(value) && value >= 1 && value <= max
  const formatted = (options, date) =>
    Number.isNaN(date.getTime())
      ? String(year)
      : new Intl.DateTimeFormat(locale, options).format(date)
  if (parts.length >= 3 && within(month, 12) && within(day, 31)) {
    return formatted(
      { year: 'numeric', month: 'long', day: 'numeric' },
      new Date(year, month - 1, day),
    )
  }
  if (parts.length >= 2 && within(month, 12)) {
    return formatted({ year: 'numeric', month: 'long' }, new Date(year, month - 1, 1))
  }
  return String(year)
}

/**
 * A language code as a reader reads it, in the page's language where that is
 * known and in English where it is not.
 *
 * The browser names most codes, but not all of them: Chrome ships CLDR's
 * *modern* subset, which keeps the languages most pages are written in and
 * hands the code straight back for the rest — there is no name in it for
 * Ndonga, Avaric or Cree. A facet then reads "ng", which tells a reader
 * nothing, so commonmeta's own list answers for whatever the browser will
 * not. English only, which beats a bare code; the common languages never
 * reach it, since the browser named them in the page's own language first.
 */
export function languageLabel(lang, locale) {
  if (!lang) return ''
  const code = String(lang).trim()
  let named = code
  try {
    named = new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code
  } catch {
    // An invalid tag, which Intl throws on rather than naming.
    named = code
  }
  // Handed back unchanged: the browser has no name for this one.
  if (named === code) return LANGUAGE_NAMES[code.toLowerCase()] ?? code
  return named
}

export function getCCIcons(id) {
  if (!id) return []
  const u = id.toUpperCase()
  if (!u.startsWith('CC')) return []
  const icons = ['fa-brands:creative-commons']
  if (u.startsWith('CC0') || u.includes('ZERO')) {
    icons.push('fa-brands:creative-commons-zero')
  } else {
    if (u.includes('-BY'))  icons.push('fa-brands:creative-commons-by')
    if (u.includes('-SA'))  icons.push('fa-brands:creative-commons-sa')
    if (u.includes('-NC'))  icons.push('fa-brands:creative-commons-nc')
    if (u.includes('-ND'))  icons.push('fa-brands:creative-commons-nd')
  }
  return icons
}

const KATEX_MACROS = {
  '\\upalpha': '\\alpha', '\\upbeta': '\\beta', '\\upgamma': '\\gamma',
  '\\updelta': '\\delta', '\\upepsilon': '\\epsilon', '\\upzeta': '\\zeta',
  '\\upeta': '\\eta', '\\uptheta': '\\theta', '\\upiota': '\\iota',
  '\\upkappa': '\\kappa', '\\uplambda': '\\lambda', '\\upmu': '\\mu',
  '\\upnu': '\\nu', '\\upxi': '\\xi', '\\uppi': '\\pi',
  '\\uprho': '\\rho', '\\upsigma': '\\sigma', '\\uptau': '\\tau',
  '\\upupsilon': '\\upsilon', '\\upphi': '\\phi', '\\upchi': '\\chi',
  '\\uppsi': '\\psi', '\\upomega': '\\omega',
}

export function decodeEntities(str) {
  if (!str) return str
  const doc = new DOMParser().parseFromString(str, 'text/html')
  return doc.body.textContent
}

const INLINE_TAGS = new Set(['b', 'i', 'strong', 'em', 'sup', 'sub'])

const ENCODED_TAG_RE = /&lt;(\/?(?:b|i|em|strong|sup|sub))&gt;/gi

function sanitizePlain(text) {
  if (!text) return ''
  // Decode HTML-entity-encoded inline tags (e.g. &lt;sup&gt; → <sup>)
  const html = text.replace(ENCODED_TAG_RE, '<$1>')
  const doc = new DOMParser().parseFromString(html, 'text/html')
  function walk(node) {
    if (node.nodeType === 3) {
      return node.textContent
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    }
    if (node.nodeType !== 1) return ''
    const tag = node.tagName.toLowerCase()
    const children = Array.from(node.childNodes).map(walk).join('')
    return INLINE_TAGS.has(tag) ? `<${tag}>${children}</${tag}>` : children
  }
  return Array.from(doc.body.childNodes).map(walk).join('')
}

export function renderContent(text) {
  if (!text) return ''
  // Collapse JATS XML whitespace and strip MathML fallback single-char lines
  const cleaned = text
    .split('\n')
    .filter(line => { const t = line.trim(); return t.length === 0 || t.length > 2 })
    .join(' ')
    .replace(/\s{2,}/g, ' ')
    .trim()
    .replace(/^(?:A\s+)?bstract\s+/i, '')
  const s = cleaned.replace(/&(\d+);/g, '&#$1;')
  const parts = []
  let i = 0
  while (i < s.length) {
    if (s[i] === '$' && s[i + 1] === '$') {
      const end = s.indexOf('$$', i + 2)
      if (end !== -1) {
        parts.push(katex.renderToString(s.slice(i + 2, end), { displayMode: false, throwOnError: false, macros: KATEX_MACROS }))
        i = end + 2
        continue
      }
    }
    if (s[i] === '$') {
      const end = s.indexOf('$', i + 1)
      if (end !== -1) {
        parts.push(katex.renderToString(s.slice(i + 1, end), { displayMode: false, throwOnError: false, macros: KATEX_MACROS }))
        i = end + 1
        continue
      }
    }
    // Search from i+1: when s[i] is an unmatched '$' (e.g. a lone currency sign),
    // indexOf('$', i) returns i, so i never advances and parts grows until it exceeds
    // the max array length (RangeError). Starting at i+1 emits the '$' as plain text.
    const next = s.indexOf('$', i + 1)
    parts.push(sanitizePlain(next === -1 ? s.slice(i) : s.slice(i, next)))
    i = next === -1 ? s.length : next
  }
  return parts.join('')
}

// Maps "Has*" relation types to their plural noun label; all others are
// split at CamelCase boundaries and lowercased after the first character.
const HAS_PLURAL = {
  HasMetadata: 'Metadata', HasPart: 'Parts', HasPreprint: 'Preprints',
  HasReview: 'Reviews', HasTranslation: 'Translations', HasVersion: 'Versions',
  IsReferencedBy: 'Citations',
  // What a publisher said about the work after publishing it. The camelCase
  // humaniser would call these "Is retracted by" and "Has expression of
  // concern"; what the card lists is the notice itself.
  IsRetractedBy: 'Retractions', Retracts: 'Retracted works',
  IsCorrectedBy: 'Corrections', Corrects: 'Corrected works',
  IsWithdrawnBy: 'Withdrawals', Withdraws: 'Withdrawn works',
  IsReinstatedBy: 'Reinstatements', Reinstates: 'Reinstated works',
  HasExpressionOfConcern: 'Expressions of concern',
  IsExpressionOfConcernFor: 'Expression of concern for',
}

export function humanizeRelationType(type) {
  if (HAS_PLURAL[type]) return HAS_PLURAL[type]
  const words = type.replace(/([A-Z])/g, ' $1').trim()
  return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase()
}

// Singular and plural noun labels used in the count summary row on index cards.
const COUNT_LABELS = {
  HasMetadata:         ['metadata',          'metadata'],
  HasPart:             ['part',              'parts'],
  HasPreprint:         ['preprint',          'preprints'],
  HasReview:           ['review',            'reviews'],
  HasTranslation:      ['translation',       'translations'],
  HasVersion:          ['version',           'versions'],
  IsDerivedFrom:       ['derived from',      'derived from'],
  IsIdenticalTo:       ['identical to',      'identical to'],
  IsMetadataFor:       ['metadata for',      'metadata for'],
  IsNewVersionOf:      ['new version of',    'new versions of'],
  IsOriginalFormOf:    ['original form',     'original forms'],
  IsPartOf:            ['part of',           'parts of'],
  IsPreprintOf:        ['preprint',          'preprints'],
  IsPreviousVersionOf: ['previous version',  'previous versions'],
  IsReferencedBy:      ['citation',          'citations'],
  IsReviewOf:          ['review',            'reviews'],
  IsSourceOf:          ['source of',         'sources of'],
  IsSupplementTo:      ['supplement to',     'supplements to'],
  IsSupplementedBy:    ['supplement',        'supplements'],
  IsTranslationOf:     ['translation',       'translations'],
  IsVariantFormOf:     ['variant form',      'variant forms'],
  IsVersionOf:         ['version of',        'versions of'],
  IsRetractedBy:       ['retraction',        'retractions'],
  Retracts:            ['retracted work',    'retracted works'],
  IsCorrectedBy:       ['correction',        'corrections'],
  Corrects:            ['corrected work',    'corrected works'],
  IsWithdrawnBy:       ['withdrawal',        'withdrawals'],
  Withdraws:           ['withdrawn work',    'withdrawn works'],
  IsReinstatedBy:      ['reinstatement',     'reinstatements'],
  Reinstates:          ['reinstated work',   'reinstated works'],
  HasExpressionOfConcern:   ['expression of concern', 'expressions of concern'],
  IsExpressionOfConcernFor: ['work concerned',        'works concerned'],
}

export function countLabel(type, count) {
  const pair = COUNT_LABELS[type]
  if (!pair) return count === 1 ? type : type + 's'
  return count === 1 ? pair[0] : pair[1]
}

// Preferred display order for relation-type cards on entity pages.
export const RELATION_TYPE_ORDER = [
  // First, and deliberately: a retraction or a withdrawal changes how the rest
  // of the record should be read, and these used to sort after everything the
  // list knew — below the citations.
  'IsRetractedBy', 'IsWithdrawnBy', 'HasExpressionOfConcern', 'IsCorrectedBy', 'IsReinstatedBy',
  'Retracts', 'Withdraws', 'IsExpressionOfConcernFor', 'Corrects', 'Reinstates',
  'IsReviewOf', 'HasVersion', 'IsVersionOf', 'IsNewVersionOf', 'IsPreviousVersionOf',
  'HasPart', 'IsPartOf', 'HasReview', 'IsPreprintOf', 'HasPreprint',
  'IsSupplementTo', 'IsSupplementedBy',
  'IsTranslationOf', 'HasTranslation', 'IsVariantFormOf', 'IsOriginalFormOf',
  'IsDerivedFrom', 'IsSourceOf', 'IsIdenticalTo', 'IsMetadataFor', 'HasMetadata',
  'IsReferencedBy',
]

/**
 * The local path for a pid, carrying `?noredirect`.
 *
 * A bare `/{pid}` resolves to the landing page of record, the way the handle
 * proxy at doi.org does, so every link meant to open this site's own entity
 * page has to ask for the page explicitly. Accepts a resolver URL
 * (`https://doi.org/10.x`, orcid.org, ror.org), an already-bare identifier, or
 * one with a leading slash, and leaves an existing query string alone.
 */
export function entityPath(id) {
  if (!id) return id
  const bare = String(id)
    .replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
    .replace(/^https?:\/\/orcid\.org\//, '')
    .replace(/^https?:\/\/ror\.org\//, '')
    .replace(/^\//, '')
  return bare.includes('?') ? `/${bare}` : `/${bare}?noredirect`
}

// A container record carries no `id`, only `identifiers`. It is bookmarked under
// the one its page path prefers — ISSN, then OpenAlex, DOI, DataCite — spelled
// as a URL like every other bookmark id, so a bookmark link, an export and an
// import can all treat bookmarks alike.
const CONTAINER_ID_URLS = [
  ['ISSN', 'https://portal.issn.org/resource/ISSN/'],
  ['OpenAlex', 'https://openalex.org/'],
  ['DOI', 'https://doi.org/'],
  ['DataCite', 'https://commons.datacite.org/repositories/'],
]

export function containerId(d) {
  for (const [type, base] of CONTAINER_ID_URLS) {
    const value = d?.identifiers?.find(i => i.identifier_type === type)?.identifier
    if (value) return /^https?:\/\//.test(value) ? value : base + value
  }
  return ''
}

/** The id a record is bookmarked under: its own, or a container's. */
export function recordId(d) {
  return d?.id ?? containerId(d)
}

/** A bookmark id as its page path spells it: the bare DOI, ORCID iD, ROR id, ISSN, … */
export function bareBookmarkId(id) {
  const s = String(id)
  for (const [, base] of CONTAINER_ID_URLS) {
    if (s.startsWith(base)) return s.slice(base.length)
  }
  return s
    .replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
    .replace(/^https?:\/\/orcid\.org\//, '')
    .replace(/^https?:\/\/ror\.org\//, '')
}

export function bookmarkPath(id) {
  return id ? entityPath(bareBookmarkId(id)) : id
}

/**
 * The one record a bookmark holds. An entity page keeps its record as a
 * one-element array — the shape `?format=commonmeta` returns for a person,
 * organization or container — and bookmarks saved from its card stored that
 * array as is, so readers unwrap it.
 */
export function bookmarkRecord(data) {
  try {
    const d = typeof data === 'string' ? JSON.parse(data) : data
    return (Array.isArray(d) ? d[0] : d) ?? null
  } catch { return null }
}

/**
 * Whether a subject's scheme is the OECD Fields of Science classification.
 *
 * Every spelling a real store holds: `FOS`, `fos`, `Fields of Science and
 * Technology (FOS)` and Eurostat's `Field of science and technology
 * classification (FOS 2007)`; the OECD document's own number, which its two
 * hosts share, and Eurostat's `fos07`.
 *
 * The scheme is what decides, never the term. The field names are generic
 * enough that other vocabularies use the same words — `Psychology` is a MeSH
 * descriptor — so a keyword list saying `mathematics` is no OECD
 * classification, and treating it as one more than tripled what counted as
 * classified in a real store. Kept in step with commonmeta's `is_fos_scheme`,
 * which is what filled the index's subject facet.
 */
export function isFosScheme(scheme = '', schemeUri = '') {
  const named = (scheme ?? '').trim().toLowerCase()
  const uri = (schemeUri ?? '').trim().toLowerCase()
  return named === 'fos' ||
    named.includes('field of science') ||
    named.includes('fields of science') ||
    uri.includes('38235147') ||
    uri.includes('fos07')
}

/**
 * The FOS term a subject names, as the search should be asked for it: the
 * subject's own text without the `FOS:` prefix some repositories leave in it,
 * so a link reads `?fos=Mathematics` rather than `?fos=FOS%3A+Mathematics`.
 *
 * Case and spelling are folded the rest of the way by the server, against the
 * vocabulary itself — a deposit saying `History and Archaeology` has to find
 * the `History and archaeology` the index facets by, and only the vocabulary
 * knows that.
 */
export function fosTerm(subject = '') {
  return (subject ?? '').trim().replace(/^fos:\s*/i, '').trim()
}

/**
 * The OECD vocabulary, by the code its id ends in — `1` is Natural sciences,
 * `1.1` Mathematics — each term named in the languages the vocabulary names
 * it in. Generated from `commonmeta::vocabularies::fos_subjects`.
 */
export { FOS_TERMS }

/** Every term's English name, folded, against the code it names. */
const FOS_CODE_BY_NAME = new Map(
  Object.entries(FOS_TERMS)
    .flatMap(([code, titles]) => Object.values(titles).map(name => [name.toLowerCase(), code])),
)

/**
 * The vocabulary's code for a subject, however it is spelled, or `''` where it
 * names no OECD term.
 *
 * A code arrives from a facet the index counted and from a link written since;
 * a term arrives from a record's own subjects, from an index built before the
 * facet counted codes, and from a link written then. All of them name the same
 * field, and the code is what says so.
 */
export function fosCode(value = '') {
  const asked = fosTerm(value)
  if (!asked) return ''
  if (Object.hasOwn(FOS_TERMS, asked)) return asked
  return FOS_CODE_BY_NAME.get(asked.toLowerCase()) ?? ''
}

/**
 * A FOS term as a reader reads it: the vocabulary's own wording in the page's
 * language, which it gives for eight of the eleven dragoman speaks, and the
 * English wording for the rest. A code the vocabulary does not hold is left as
 * it came — it is still what the facet counted.
 */
export function fosLabel(code, locale = 'en') {
  const titles = FOS_TERMS[code]
  if (!titles) return code
  const lang = String(locale ?? '').split('-')[0].toLowerCase()
  return titles[lang] ?? titles.en ?? code
}

/**
 * Whether a URL is a machine endpoint rather than the copy a reader opens.
 *
 * Publishers deposit both: Wiley lists
 * `api.wiley.com/onlinelibrary/tdm/v1/articles/…` beside
 * `onlinelibrary.wiley.com/doi/pdf/…`, and Elsevier and Springer do the same
 * through `api.` hosts. The endpoints answer licensed mining clients, not a
 * reader following a link, so they are the last resort rather than the first
 * row.
 */
const isMiningUrl = url =>
  /\/\/api\./i.test(url) ||
  /\/tdm\//i.test(url) ||
  /\/chorus\//i.test(url) ||
  /text-and-data-mining/i.test(url)

/**
 * The one PDF of a work's files, or null when it points at none.
 *
 * A record lists several: the same URL once per content version, a mining
 * endpoint beside the reader's copy, and sometimes two reader copies from two
 * platforms. Only one is worth a row, and it should be the one that opens.
 *
 * Preferred, in order: a copy that is not a mining endpoint, else the first
 * PDF listed. commonmeta's files carry no content version — Crossref's `vor`
 * and `am` are not kept in the record — so the version of record cannot be
 * asked for by name; a reader copy is as close as the data comes, and where
 * two of those remain the record's own order decides.
 */
export function preferredPdf(files = []) {
  const pdfs = (files ?? []).filter(file => {
    const mime = file?.mimeType ?? file?.mime_type ?? ''
    return file?.url && /^application\/pdf\b/i.test(mime)
  })
  if (pdfs.length === 0) return null
  return pdfs.find(file => !isMiningUrl(file.url)) ?? pdfs[0]
}