<script>
import { Button } from '$lib/components/ui/button/index.js'
import { Input } from '$lib/components/ui/input/index.js'
import { Select } from '$lib/components/ui/select/index.js'
import { Card, CardContent } from '$lib/components/ui/card/index.js'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '$lib/components/ui/table/index.js'
import { setupI18n } from '$lib/i18n.js'
import { _, locale } from '@sveltia/i18n'
import Icon from '@iconify/svelte'
import orcidSvg from './assets/orcid.svg'
import rorSvg from './assets/ror.svg'
import 'katex/dist/katex.min.css'
import WorkCard from './WorkCard.svelte'
import OrgCard from './OrgCard.svelte'
import PersonCard from './PersonCard.svelte'
import { Pagination } from '$lib/components/ui/pagination/index.js'
import { typeLabel } from '$lib/bib-utils.js'
// ── Entity page init (set by server via window.__DRAGOMAN_INIT__) ─────────
const _initData = typeof window !== 'undefined' ? (window.__DRAGOMAN_INIT__ ?? null) : null
const isEntityPage = Boolean(_initData)
// ── App config ────────────────────────────────────────────────────────────
let isDark = $state(document.documentElement.classList.contains('dark'))
function toggleDark() {
isDark = !isDark
document.documentElement.classList.toggle('dark', isDark)
localStorage.setItem('app-dark', String(isDark))
}
// ── Bibliography ──────────────────────────────────────────────────────────
let bibDoi = $state('')
let bibStyle = $state(localStorage.getItem('dragoman-style') ?? 'apa')
const initialLocale = localStorage.getItem('dragoman-locale') ?? (() => {
const supported = ['en-US','de-DE','fr-FR','es-ES','it-IT','ja-JP','ko-KR','nl-NL','pt-BR','sv-SE','zh-CN']
const lang = navigator.language
return supported.find(v => v === lang)
?? supported.find(v => v.startsWith(lang.split('-')[0] + '-'))
?? 'en-US'
})()
setupI18n(initialLocale)
let bibLocale = $state(initialLocale)
let bibliography = $state([])
let citations = $state([])
const PAGE_SIZE = 10
const MAX_SAVED = 50
let refPage = $state(1)
let citePage = $state(1)
let worksPage = $state(1)
let savedPage = $state(1)
let bibLoading = $state(false)
let bibFetchingRemote = $state(false)
let bibError = $state('')
let _remoteTimer = null
let randomLoading = $state(false)
let hasDb = $state(false)
fetch('/status').then(r => r.json()).then(d => { hasDb = d.db }).catch(() => {})
const isLocalhost = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
let copiedCiteId = $state(null)
let copiedPreIdx = $state(null)
let addedId = $state(null)
let dragId = $state(null)
let dropPosition = $state(null)
let savedDragHandleActive = $state(false)
let savedItems = $state((() => {
try { return JSON.parse(localStorage.getItem('dragoman-saved') ?? '[]') }
catch { return [] }
})())
$effect(() => { localStorage.setItem('dragoman-saved', JSON.stringify(savedItems)) })
$effect(() => { localStorage.setItem('dragoman-style', bibStyle) })
$effect(() => { localStorage.setItem('dragoman-locale', bibLocale); locale.set(bibLocale) })
// ── Export ────────────────────────────────────────────────────────────────
let expFormat = $state('commonmeta')
let expError = $state('')
let expLoading = $state(false)
let expCopied = $state(false)
// ── Saved items export ────────────────────────────────────────────────────
let savedExpFormat = $state('commonmeta')
let savedExpLoading = $state(false)
let savedExpError = $state('')
let savedExpCopied = $state(false)
const savedExpFormats = [
{ value: 'commonmeta', label: 'Commonmeta' },
]
// ── Style picker ──────────────────────────────────────────────────────────
let stylePickerOpen = $state(false)
let styleSearch = $state('')
// ── Data ──────────────────────────────────────────────────────────────────
const commonStyles = [
{ value: 'apa', label: 'American Psychological Association 7th edition' },
{ value: 'chicago-author-date', label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
{ value: 'harvard-cite-them-right', label: 'Cite Them Right 12th Edition (Harvard)' },
{ value: 'ieee', label: 'IEEE' },
{ value: 'modern-language-association', label: 'MLA Handbook 9th Edition' },
{ value: 'vancouver', label: 'Vancouver' },
]
const allCitationStyles = [
{ value: 'apa', label: 'American Psychological Association 7th edition' },
{ value: 'alphanumeric', label: 'Alphanumeric' },
{ value: 'american-anthropological-association', label: 'American Anthropological Association' },
{ value: 'american-chemical-society', label: 'American Chemical Society' },
{ value: 'american-geophysical-union', label: 'American Geophysical Union' },
{ value: 'american-institute-of-aeronautics-and-astronautics', label: 'American Institute of Aeronautics and Astronautics' },
{ value: 'american-institute-of-physics', label: 'American Institute of Physics' },
{ value: 'american-medical-association', label: 'American Medical Association' },
{ value: 'american-meteorological-society', label: 'American Meteorological Society' },
{ value: 'american-physics-society', label: 'American Physical Society' },
{ value: 'american-physiological-society', label: 'American Physiological Society' },
{ value: 'american-political-science-association', label: 'American Political Science Association' },
{ value: 'american-society-for-microbiology', label: 'American Society for Microbiology' },
{ value: 'american-society-of-civil-engineers', label: 'American Society of Civil Engineers' },
{ value: 'american-society-of-mechanical-engineers', label: 'American Society of Mechanical Engineers' },
{ value: 'american-sociological-association', label: 'American Sociological Association' },
{ value: 'angewandte-chemie', label: 'Angewandte Chemie International Edition' },
{ value: 'annual-reviews', label: 'Annual Reviews' },
{ value: 'annual-reviews-author-date', label: 'Annual Reviews (Author-Date)' },
{ value: 'associacao-brasileira-de-normas-tecnicas', label: 'Associação Brasileira de Normas Técnicas' },
{ value: 'association-for-computing-machinery', label: 'Association for Computing Machinery' },
{ value: 'biomed-central', label: 'BioMed Central' },
{ value: 'bmj', label: 'BMJ' },
{ value: 'bristol-university-press', label: 'Bristol University Press' },
{ value: 'cell', label: 'Cell' },
{ value: 'chicago-author-date', label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
{ value: 'chicago-notes', label: 'Chicago Manual of Style 18th Edition (Notes & Bibliography)' },
{ value: 'chicago-shortened-notes', label: 'Chicago Manual of Style 18th Edition (Shortened Notes)' },
{ value: 'copernicus', label: 'Copernicus Publications' },
{ value: 'council-of-science-editors', label: 'Council of Science Editors (Citation-Sequence)' },
{ value: 'council-of-science-editors-author-date', label: 'Council of Science Editors (Name-Year)' },
{ value: 'current-opinion', label: 'Current Opinion' },
{ value: 'deutsche-gesellschaft-für-psychologie', label: 'Deutsche Gesellschaft für Psychologie (Deutsch)' },
{ value: 'deutsche-sprache', label: 'Deutsche Sprache (Deutsch)' },
{ value: 'elsevier-harvard', label: 'Elsevier (Harvard)' },
{ value: 'elsevier-vancouver', label: 'Elsevier (Vancouver)' },
{ value: 'elsevier-with-titles', label: 'Elsevier (Numeric, With Titles)' },
{ value: 'frontiers', label: 'Frontiers' },
{ value: 'future-medicine', label: 'Future Medicine' },
{ value: 'future-science', label: 'Future Science Group' },
{ value: 'gb-7714-2005-numeric', label: 'GB/T 7714-2005 (Numeric)' },
{ value: 'gb-7714-2015-author-date', label: 'GB/T 7714-2015 (Author-Date)' },
{ value: 'gb-7714-2015-note', label: 'GB/T 7714-2015 (Note)' },
{ value: 'gb-7714-2015-numeric', label: 'GB/T 7714-2015 (Numeric)' },
{ value: 'gost-r-705-2008-numeric', label: 'GOST R 7.0.5-2008 (Numeric)' },
{ value: 'harvard-cite-them-right', label: 'Cite Them Right 12th Edition (Harvard)' },
{ value: 'ieee', label: 'IEEE' },
{ value: 'institute-of-physics-numeric', label: 'Institute of Physics (Numeric)' },
{ value: 'iso-690-author-date', label: 'ISO 690 (Author-Date)' },
{ value: 'iso-690-numeric', label: 'ISO 690 (Numeric)' },
{ value: 'karger', label: 'Karger' },
{ value: 'mary-ann-liebert-vancouver', label: 'Mary Ann Liebert (Vancouver)' },
{ value: 'modern-humanities-research-association', label: 'MHRA Style Guide 4th Edition (Notes)' },
{ value: 'mla', label: 'MLA Handbook 9th Edition' },
{ value: 'multidisciplinary-digital-publishing-institute', label: 'Multidisciplinary Digital Publishing Institute' },
{ value: 'nature', label: 'Nature' },
{ value: 'vancouver', label: 'Vancouver' },
{ value: 'vancouver-superscript', label: 'NLM/Vancouver (Superscript)' },
{ value: 'pensoft', label: 'Pensoft Journals' },
{ value: 'plos', label: 'Public Library of Science' },
{ value: 'royal-society-of-chemistry', label: 'Royal Society of Chemistry' },
{ value: 'sage-vancouver', label: 'SAGE (Vancouver)' },
{ value: 'sist02', label: 'SIST02' },
{ value: 'spie', label: 'SPIE' },
{ value: 'springer-basic', label: 'Springer Basic (Numeric)' },
{ value: 'springer-basic-author-date', label: 'Springer Basic (Author-Date)' },
{ value: 'springer-fachzeitschriften-medizin-psychologie', label: 'Springer Fachzeitschriften Medizin Psychologie (Deutsch)' },
{ value: 'springer-humanities-author-date', label: 'Springer Humanities (Author-Date)' },
{ value: 'springer-lecture-notes-in-computer-science', label: 'Springer Lecture Notes in Computer Science' },
{ value: 'springer-mathphys', label: 'Springer MathPhys (Numeric)' },
{ value: 'springer-socpsych-author-date', label: 'Springer SocPsych (Author-Date)' },
{ value: 'springer-vancouver', label: 'Springer (Vancouver)' },
{ value: 'taylor-and-francis-chicago-author-date', label: 'Taylor & Francis (Chicago Author-Date)' },
{ value: 'taylor-and-francis-national-library-of-medicine', label: 'Taylor & Francis (NLM/Vancouver)' },
{ value: 'the-institution-of-engineering-and-technology', label: 'Institution of Engineering and Technology' },
{ value: 'the-lancet', label: 'The Lancet' },
{ value: 'thieme', label: 'Thieme' },
{ value: 'trends', label: 'Trends' },
{ value: 'turabian-author-date', label: 'Chicago Manual of Style 17th Edition (Author-Date)' },
{ value: 'turabian-fullnote-8', label: 'Chicago Manual of Style 17th Edition (Notes & Bibliography)' },
]
const locales = [
{ value: 'en-US', label: 'English (US)' },
{ value: 'de-DE', label: 'Deutsch' },
{ value: 'fr-FR', label: 'Français' },
{ value: 'es-ES', label: 'Español' },
{ value: 'it-IT', label: 'Italiano' },
{ value: 'ja-JP', label: '日本語' },
{ value: 'ko-KR', label: '한국어' },
{ value: 'nl-NL', label: 'Nederlands' },
{ value: 'pt-BR', label: 'Português (Brasil)' },
{ value: 'sv-SE', label: 'Svenska' },
{ value: 'zh-CN', label: '中文(简体)' },
]
const expFormats = [
{ value: 'bibtex', label: 'BibTeX' },
{ value: 'commonmeta', label: 'Commonmeta' },
{ value: 'crossref', label: 'Crossref' },
{ value: 'crossref_xml', label: 'Crossref XML' },
{ value: 'csl', label: 'CSL-JSON' },
{ value: 'datacite', label: 'DataCite JSON' },
{ value: 'datacite_xml', label: 'DataCite XML' },
{ value: 'citation', i18n: 'format.citation' },
{ value: 'inveniordm', label: 'InvenioRDM' },
{ value: 'ris', label: 'RIS' },
{ value: 'schemaorg', label: 'Schema.org' },
]
// ── Helpers ───────────────────────────────────────────────────────────────
function cleanDoi(raw) {
return raw.trim().replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
}
// Returns { type, id } for the given raw identifier string, or null if empty.
// Supports CURIEs (openalex:, doi:, pmid:, pmcid:, arxiv:) and full URLs.
function parseIdentifier(raw) {
const s = raw.trim()
if (!s) return null
// Self-referential URL: strip own origin or canonical commonmeta.org
let selfPath = null
if (s.startsWith(location.origin + '/')) selfPath = s.slice(location.origin.length + 1)
else { const m = s.match(/^https?:\/\/commonmeta\.org\/(.+)/); if (m) selfPath = m[1] }
if (selfPath) return parseIdentifier(selfPath.replace(/^(pmid|pmcid|arxiv|openalex)\//, '$1:'))
// OpenAlex: openalex:Wxxx or https://openalex.org/[works/]Wxxx or bare Wxxx
const oaM = s.match(/^(?:openalex:|https?:\/\/openalex\.org\/(?:works\/)?)?(W\d+)$/i)
if (oaM) return { type: 'openalex', id: oaM[1].toUpperCase() }
// PMID: pmid:xxx or https://pubmed.ncbi.nlm.nih.gov/xxx
const pmidM = s.match(/^(?:pmid:|https?:\/\/pubmed\.ncbi\.nlm\.nih\.gov\/)(\d+)\/?$/)
if (pmidM) return { type: 'pmid', id: pmidM[1] }
// PMCID: pmcid:PMCxxx or https://www.ncbi.nlm.nih.gov/pmc/articles/PMCxxx
const pmcM = s.match(/^(?:pmcid:|https?:\/\/(?:www\.)?ncbi\.nlm\.nih\.gov\/pmc\/articles\/)(PMC\d+)\/?$/i)
if (pmcM) return { type: 'pmcid', id: pmcM[1].toUpperCase() }
// arXiv: arxiv:XXXX.XXXXX or https://arxiv.org/abs/XXXX.XXXXX
const arxivM = s.match(/^(?:arxiv:|https?:\/\/arxiv\.org\/(?:abs|pdf)\/)(\d{4}\.\d{4,}(?:v\d+)?)\/?$/i)
if (arxivM) return { type: 'arxiv', id: arxivM[1] }
// ROR: ror:0xxxxxxx or https://ror.org/0xxxxxxx
const rorM = s.match(/^(?:ror:|https?:\/\/ror\.org\/)([a-z0-9]+)\/?$/i)
if (rorM) return { type: 'ror', id: rorM[1] }
// ORCID: bare XXXX-XXXX-XXXX-XXXZ, orcid:..., or https://orcid.org/...
const orcidM = s.match(/^(?:orcid:|https?:\/\/orcid\.org\/)?(\d{4}-\d{4}-\d{4}-\d{3}[\dX])\/?$/i)
if (orcidM) return { type: 'orcid', id: orcidM[1] }
// DOI: doi:xxx or URL or bare 10.xxx
const doi = s.replace(/^doi:/i, '').replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
return { type: 'doi', id: doi }
}
function stripHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent ?? ''
}
function parseOrgMeta(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
if (!d?.id?.startsWith('https://ror.org/')) return null
return {
entityType: 'Organization',
id: d.id ?? '',
name: d.name ?? d.title ?? '',
additional_names: [d.acronym, ...(d.additional_names ?? [])].filter(Boolean),
country: d.country ?? '',
location: d.location ?? null,
urls: (d.urls ?? []).filter(u => u?.url),
identifiers: (d.identifiers ?? []),
types: d.types ?? [],
relations: d.relations ?? [],
}
} catch { return null }
}
function entityId(cmData) {
try { const d = JSON.parse(cmData); return (Array.isArray(d) ? d[0] : d)?.id ?? '' }
catch { return '' }
}
function countWorksFor(id) {
if (!id) return 0
return savedItems.filter(wi => detectEntityType(wi.data) === 'Work' && wi.data.includes(id)).length
}
function detectEntityType(cmData) {
try {
const raw = JSON.parse(cmData)
const d = Array.isArray(raw) ? raw[0] : raw
if (d?.id?.startsWith('https://ror.org/')) return 'Organization'
if (d?.id?.startsWith('https://orcid.org/') || d?.given_name || d?.family_name) return 'Person'
return 'Work'
} catch { return 'Work' }
}
function bibGroups() {
const groups = []
bibliography.forEach((entry, i) => {
const type = detectEntityType(entry.data)
if (type === 'Work' && groups.length > 0 && groups[groups.length - 1].type === 'Work') {
groups[groups.length - 1].entries.push({ entry, idx: i })
} else {
groups.push({ type, entries: [{ entry, idx: i }] })
}
})
return groups
}
function parseMeta(cmData) {
try {
const d = JSON.parse(cmData)
// Build raw author list (no affiliation indices yet)
const rawAuthors = (d.contributors ?? [])
.filter(c => !c.roles?.length || c.roles.includes('Author'))
.map(c => {
if (c.type === 'Person' && c.person) {
const p = c.person
const name = [p.given_name, p.family_name].filter(Boolean).join(' ')
if (!name) return null
return { name, orcid: p.id?.startsWith('https://orcid.org/') ? p.id : '', rawAffs: p.affiliations ?? [] }
}
const name = c.organization?.name ?? ''
return name ? { name, orcid: '', rawAffs: [] } : null
})
.filter(Boolean)
// Truncate to display set: first 19 + last if > 20
const truncated = rawAuthors.length > 20
const displayRaw = truncated
? [...rawAuthors.slice(0, 19), rawAuthors[rawAuthors.length - 1]]
: rawAuthors
// Build affiliations only from displayed authors
const affMap = new Map()
const affiliations = []
function affIndex(aff) {
const key = aff.id || aff.name
if (!key) return null
if (!affMap.has(key)) {
affiliations.push({ name: aff.name || aff.id, ror: aff.id?.startsWith('https://ror.org/') ? aff.id : '' })
affMap.set(key, affiliations.length)
}
return affMap.get(key)
}
const toAuthor = r => ({ name: r.name, orcid: r.orcid, affIndices: r.rawAffs.map(affIndex).filter(Boolean) })
const authorList = truncated
? [...displayRaw.slice(0, 19).map(toAuthor), { isEllipsis: true }, toAuthor(displayRaw[displayRaw.length - 1])]
: displayRaw.map(toAuthor)
const funders = (d.funding_references ?? [])
.filter(f => f.funder_name || f.funder_id)
const cid = d.container?.identifier ?? ''
const cidType = (d.container?.identifier_type ?? '').toUpperCase()
const containerUrl = cidType === 'DOI' ? `https://doi.org/${cid}`
: cidType === 'ISSN' ? `https://portal.issn.org/resource/ISSN/${cid}`
: ''
return {
title: d.title ?? '',
type: d.type ?? '',
version: d.version ?? '',
datePublished: d.date_published ?? '',
containerTitle: d.container?.title ?? '',
containerUrl,
authorList,
affiliations,
funders,
description: d.description ?? '',
subjects: (d.subjects ?? []).map(s => s.subject).filter(Boolean),
referenceCount: (d.references ?? []).length,
citationCount: (d.citations ?? []).length,
language: d.language ?? '',
licenseTitle: d.license?.title ?? '',
licenseId: d.license?.id ?? '',
licenseUrl: d.license?.url ?? '',
id: d.id ?? '',
}
} catch { return null }
}
// ── Navigation ────────────────────────────────────────────────────────────
$effect(() => {
function handleNavClick(e) {
const a = e.target.closest('a[href]')
if (!a) return
if (a.target === '_blank') return
let url
try { url = new URL(a.href) } catch { return }
if (url.origin !== location.origin) return
if (url.pathname === location.pathname && url.search === location.search) return
e.preventDefault()
bibFetchingRemote = true
const dest = a.href
requestAnimationFrame(() => requestAnimationFrame(() => { location.href = dest }))
}
document.addEventListener('click', handleNavClick)
return () => document.removeEventListener('click', handleNavClick)
})
function goToId(e) {
e.preventDefault()
const parsed = parseIdentifier(bibDoi.trim())
if (!parsed?.id) return
bibLoading = true
bibFetchingRemote = false
clearTimeout(_remoteTimer)
_remoteTimer = setTimeout(() => { bibFetchingRemote = true }, 300)
const routePrefix = {
openalex: '/openalex',
pmid: '/pmid',
pmcid: '/pmcid',
arxiv: '/arxiv',
}
const prefix = routePrefix[parsed.type] ?? ''
window.location.href = prefix ? `${prefix}/${parsed.id}` : `/${parsed.id}`
}
async function initEntityPage(rawData) {
refPage = 1
citePage = 1
worksPage = 1
const rawArr = Array.isArray(rawData) ? rawData : null
const firstId = rawArr?.[0]?.id ?? ''
const isEntityArray = rawArr && rawArr.length > 0 && (
firstId.startsWith('https://ror.org/') || firstId.startsWith('https://orcid.org/')
)
if (isEntityArray) {
const entityEntry = {
id: crypto.randomUUID(),
doi: rawArr[0].id,
data: JSON.stringify([rawArr[0]]),
url: null,
html: '',
}
const workEntries = rawArr.slice(1, 201).map(work => ({
id: crypto.randomUUID(),
doi: (work.id ?? '').replace(/^https?:\/\/doi\.org\//, ''),
data: JSON.stringify(work),
url: work.url ?? null,
html: '',
}))
bibliography = [entityEntry, ...workEntries]
const workItems = workEntries.map(e => ({ id: e.id, data: e.data }))
if (workItems.length > 0) {
try {
const formatted = await formatItems(workItems, bibStyle, bibLocale)
const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
bibliography = bibliography.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
} catch { /* continue without formatted citations */ }
}
} else {
const cmData = JSON.stringify(rawData)
const entryId = crypto.randomUUID()
const doi = typeof rawData?.id === 'string' && rawData.id.startsWith('https://doi.org/')
? rawData.id.replace('https://doi.org/', '')
: ''
const url = rawData?.url ?? null
let html = ''
try {
const formatted = await formatItems([{ id: entryId, data: cmData }], bibStyle, bibLocale)
html = formatted[0]?.html ?? ''
} catch { /* continue without */ }
const mainEntry = { id: entryId, doi, data: cmData, url, html }
bibliography = [mainEntry]
// Fetch reference work cards (DOIs only, max 200), in parallel
const refDois = (rawData?.references ?? [])
.map(r => typeof r?.id === 'string' && r.id.startsWith('https://doi.org/')
? r.id.replace('https://doi.org/', '') : null)
.filter(Boolean)
.slice(0, 200)
if (refDois.length > 0) {
const settled = await Promise.allSettled(
refDois.map(async rdoi => {
const url = isLocalhost ? `/${rdoi}?format=commonmeta` : `/${rdoi}?format=commonmeta&local_only=true`
const resp = await fetch(url)
if (!resp.ok) return null
return resp.json()
})
)
const refEntries = []
const toFormat = []
for (const res of settled) {
if (res.status !== 'fulfilled' || !res.value) continue
const d = res.value
const rid = crypto.randomUUID()
const rdoi = d?.id?.startsWith('https://doi.org/') ? d.id.replace('https://doi.org/', '') : ''
const rdata = JSON.stringify(d)
refEntries.push({ id: rid, doi: rdoi, data: rdata, url: d?.url ?? null, html: '' })
toFormat.push({ id: rid, data: rdata })
}
if (refEntries.length > 0) {
bibliography = [mainEntry, ...refEntries]
try {
const formatted = await formatItems(toFormat, bibStyle, bibLocale)
const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
bibliography = bibliography.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
} catch { }
}
}
// Fetch citation work cards (DOIs only, max 10), in parallel
const citeDois = (rawData?.citations ?? [])
.map(c => typeof c?.id === 'string' && c.id.startsWith('https://doi.org/')
? c.id.replace('https://doi.org/', '') : null)
.filter(Boolean)
.slice(0, 200)
if (citeDois.length > 0) {
const settled = await Promise.allSettled(
citeDois.map(async cdoi => {
const url = isLocalhost ? `/${cdoi}?format=commonmeta` : `/${cdoi}?format=commonmeta&local_only=true`
const resp = await fetch(url)
if (!resp.ok) return null
return resp.json()
})
)
const citeEntries = []
const toFormat = []
for (const res of settled) {
if (res.status !== 'fulfilled' || !res.value) continue
const d = res.value
const cid = crypto.randomUUID()
const cdoi = d?.id?.startsWith('https://doi.org/') ? d.id.replace('https://doi.org/', '') : ''
const cdata = JSON.stringify(d)
citeEntries.push({ id: cid, doi: cdoi, data: cdata, url: d?.url ?? null, html: '' })
toFormat.push({ id: cid, data: cdata })
}
if (citeEntries.length > 0) {
citations = citeEntries
try {
const formatted = await formatItems(toFormat, bibStyle, bibLocale)
const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
citations = citations.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
} catch { }
}
}
}
}
$effect(() => { if (isEntityPage) initEntityPage(_initData) })
const entityTitle = $derived.by(() => {
if (!isEntityPage || bibliography.length === 0) return ''
try {
const d = JSON.parse(bibliography[0].data)
const obj = Array.isArray(d) ? d[0] : d
return obj?.name || obj?.title || ''
} catch { return '' }
})
async function pickRandom() {
randomLoading = true
bibError = ''
try {
const resp = await fetch('/random')
if (!resp.ok) throw new Error(await resp.text().catch(() => resp.statusText))
const { id } = await resp.json()
bibDoi = id
} catch (err) {
bibError = String(err)
} finally {
randomLoading = false
}
}
async function formatItems(items, style, locale) {
const resp = await fetch('/bibliography', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, style, locale }),
})
if (!resp.ok) {
const text = await resp.text().catch(() => resp.statusText)
throw new Error(`${resp.status}: ${text}`)
}
return (await resp.json()).items
}
function onBibStyleChange(e) {
const val = e.target.value
if (val === '__more__') {
e.target.value = bibStyle
stylePickerOpen = true
styleSearch = ''
return
}
bibStyle = val
if (bibliography.length > 0) reformatBibliography(val, bibLocale)
}
function pickMoreStyle(style) {
bibStyle = style.value
stylePickerOpen = false
styleSearch = ''
if (bibliography.length > 0) reformatBibliography(style.value, bibLocale)
}
function onBibLocaleChange(e) {
const locale = e.target.value
bibLocale = locale
if (bibliography.length > 0) reformatBibliography(bibStyle, locale)
}
async function reformatBibliography(style, locale) {
bibLoading = true
try {
const allItems = [
...bibliography.map(e => ({ id: e.id, data: e.data })),
...citations.map(e => ({ id: e.id, data: e.data })),
]
const formatted = await formatItems(allItems, style, locale)
const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
bibliography = bibliography.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
citations = citations.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
} catch (err) {
bibError = String(err)
} finally {
bibLoading = false
}
}
function onSavedDragStart(e, item) {
dragId = item.id
e.dataTransfer.effectAllowed = 'move'
}
function onSavedDragOver(e, item) {
if (!dragId || dragId === item.id) return
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
dropPosition = { id: item.id, before: e.clientY < rect.top + rect.height / 2 }
}
function onSavedDragLeave(e) {
if (!e.currentTarget.contains(e.relatedTarget)) dropPosition = null
}
function onSavedDrop(e, item) {
e.preventDefault()
if (!dragId || !dropPosition || dragId === item.id) return
const from = savedItems.findIndex(b => b.id === dragId)
const moved = savedItems[from]
const rest = savedItems.filter(b => b.id !== dragId)
const toIdx = rest.findIndex(b => b.id === dropPosition.id)
rest.splice(dropPosition.before ? toIdx : toIdx + 1, 0, moved)
savedItems = rest
dragId = null
dropPosition = null
}
function onSavedDragEnd() {
dragId = null
dropPosition = null
}
function addToSaved(id, data) {
if (!id) return
if (!savedItems.some(item => item.id === id) && savedItems.length < MAX_SAVED) {
savedItems = [...savedItems, { id, data }]
}
addedId = id
setTimeout(() => { addedId = null }, 2000)
}
function removeFromSaved(id) {
savedItems = savedItems.filter(item => item.id !== id)
}
function fetchSavedExport() {
const items = savedItems.map(item => {
try { return JSON.parse(item.data) } catch { return null }
}).filter(Boolean)
return JSON.stringify(items.length === 1 ? items[0] : items, null, 2)
}
async function exportSaved() {
if (!savedItems.length) return
savedExpLoading = true
savedExpError = ''
try {
const content = fetchSavedExport()
const url = URL.createObjectURL(new Blob([content], { type: 'application/json' }))
Object.assign(document.createElement('a'), { href: url, download: `saved.json` }).click()
URL.revokeObjectURL(url)
} catch (err) { savedExpError = String(err) }
finally { savedExpLoading = false }
}
async function copySavedExport() {
if (!savedItems.length) return
savedExpLoading = true
savedExpError = ''
savedExpCopied = false
try {
await navigator.clipboard.writeText(fetchSavedExport())
savedExpCopied = true
setTimeout(() => { savedExpCopied = false }, 2000)
} catch (err) { savedExpError = String(err) }
finally { savedExpLoading = false }
}
function deleteEntry(id) {
bibliography = bibliography.filter(e => e.id !== id)
}
function deleteBibliography() {
bibliography = bibliography.slice(0, 1)
citations = []
}
async function copyCitation(entry) {
await navigator.clipboard.writeText(JSON.stringify(entry.data, null, 2))
copiedCiteId = entry.id
setTimeout(() => { copiedCiteId = null }, 2000)
}
async function copyPre(text, idx) {
await navigator.clipboard.writeText(text)
copiedPreIdx = idx
setTimeout(() => { copiedPreIdx = null }, 2000)
}
// ── Export actions ────────────────────────────────────────────────────────
const expExtensions = { citation: 'txt', bibtex: 'bib', ris: 'ris', csl: 'json', commonmeta: 'json',
crossref: 'json', crossref_xml: 'xml', datacite: 'json', datacite_xml: 'xml',
inveniordm: 'json', schemaorg: 'json' }
const jsonFormats = new Set(['csl', 'commonmeta', 'crossref', 'datacite', 'inveniordm', 'schemaorg'])
async function fetchBibExport(items) {
if (expFormat === 'citation') {
return items.map(e => stripHtml(e.html)).join('\n\n')
}
const results = await Promise.all(
items.map(async entry => {
const resp = await fetch(`/${entry.doi}?format=${expFormat}`)
if (!resp.ok) { const t = await resp.text().catch(() => resp.statusText); throw new Error(`${resp.status}: ${t}`) }
return resp.text()
})
)
return jsonFormats.has(expFormat)
? JSON.stringify(results.map(r => JSON.parse(r)), null, 2)
: results.join('\n\n')
}
async function exportBibliography(items) {
if (!items.length) return
expLoading = true
expError = ''
try {
const combined = await fetchBibExport(items)
const ext = expExtensions[expFormat] ?? 'txt'
const url = URL.createObjectURL(new Blob([combined], { type: 'text/plain' }))
Object.assign(document.createElement('a'), { href: url, download: `bibliography.${ext}` }).click()
URL.revokeObjectURL(url)
} catch (err) { expError = String(err) }
finally { expLoading = false }
}
async function copyBibExport(items) {
if (!items.length) return
expLoading = true
expError = ''
expCopied = false
try {
const combined = await fetchBibExport(items)
await navigator.clipboard.writeText(combined)
expCopied = true
setTimeout(() => { expCopied = false }, 2000)
} catch (err) { expError = String(err) }
finally { expLoading = false }
}
</script>
<div class="min-h-screen flex flex-col bg-background text-foreground antialiased">
<!-- Header -->
<header class="bg-[#f2f2f2] dark:bg-gray-800 shrink-0 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-4xl mx-auto px-6 h-14 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<!-- Lightbulb icon matching python.commonmeta.org -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 text-primary shrink-0">
<path d="M12 .75a8.25 8.25 0 0 0-4.135 15.39c.686.398 1.115 1.143 1.115 1.942V18h5.25v-.008c0-.799.43-1.544 1.115-1.942A8.25 8.25 0 0 0 12 .75Z" />
<path fill-rule="evenodd" d="M9.013 19.9a.75.75 0 0 1 .877-.597 11.319 11.319 0 0 0 4.22 0 .75.75 0 1 1 .28 1.473 12.819 12.819 0 0 1-4.78 0 .75.75 0 0 1-.597-.876ZM9 22.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 9 22.5Z" clip-rule="evenodd" />
</svg>
<a href="/" class="text-base font-bold tracking-tight shrink-0 text-gray-900 dark:text-white hover:underline">Commonmeta</a>
<span class="text-sm text-gray-600 dark:text-gray-400 truncate">{_('app.description')}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onclick={toggleDark}
aria-label="Toggle dark mode"
class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
>
{#if isDark}
<!-- Sun -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
{:else}
<!-- Moon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
{/if}
</button>
<Select bind:value={bibLocale} onchange={onBibLocaleChange}
class="h-7 w-36 text-xs py-0">
{#each locales as l}
<option value={l.value}>{l.label}</option>
{/each}
</Select>
</div>
</div>
</header>
{#if bibFetchingRemote}
<div class="h-1 w-full shrink-0" aria-live="polite">
<div class="fetch-progress h-full bg-primary"></div>
</div>
{/if}
<!-- Main -->
<main class="flex-1 max-w-4xl w-full mx-auto px-6 py-10 space-y-12">
<!-- ── Bibliography ─────────────────────────────────────────────────── -->
<section>
<!-- DOI input card -->
<Card>
<CardContent class="p-5">
<form onsubmit={goToId} class="flex gap-3">
<div class="relative flex-1">
<Input
type="text"
bind:value={bibDoi}
placeholder={_('bibliography.placeholder')}
autocomplete="off"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
class="font-mono w-full {hasDb ? 'pr-9' : ''}"
/>
{#if hasDb}
<button
type="button"
onclick={pickRandom}
disabled={randomLoading}
aria-label={_('bibliography.random')}
class="group absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
>
{#if randomLoading}
<span class="block w-4 h-4 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="7.5" cy="7.5" r="0.5" fill="currentColor"/>
<circle cx="12" cy="7.5" r="0.5" fill="currentColor"/>
<circle cx="16.5" cy="7.5" r="0.5" fill="currentColor"/>
<circle cx="7.5" cy="16.5" r="0.5" fill="currentColor"/>
<circle cx="12" cy="16.5" r="0.5" fill="currentColor"/>
<circle cx="16.5" cy="16.5" r="0.5" fill="currentColor"/>
</svg>
{/if}
<span class="pointer-events-none absolute z-50 whitespace-nowrap rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md bottom-full right-0 mb-2 opacity-0 group-hover:opacity-100 transition-opacity">
{_('bibliography.random')}
</span>
</button>
{/if}
</div>
<Button type="submit" disabled={bibLoading || !bibDoi.trim()}>
{#if bibLoading}
<span class="w-4 h-4 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
{:else}
{_('bibliography.go')}
{/if}
</Button>
</form>
{#if bibError}
<div class="mt-3 px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive" role="alert">
{bibError}
</div>
{/if}
</CardContent>
</Card>
<!-- Bibliography list -->
{#if bibliography.length > 0}
{#snippet bibFooter(items)}
<div class="border-t border-border px-4 py-3 bg-muted flex items-center gap-2 flex-wrap">
<Select bind:value={expFormat} class="w-40 h-8 text-xs py-0">
{#each expFormats as f}
<option value={f.value}>{f.i18n ? _(f.i18n) : f.label}</option>
{/each}
</Select>
{#if expFormat === 'citation'}
<div class="relative flex-1 min-w-36">
<Select value={bibStyle} onchange={onBibStyleChange} class="w-full h-8 text-xs py-0">
{#if !commonStyles.some(s => s.value === bibStyle)}
<option value={bibStyle}>{allCitationStyles.find(s => s.value === bibStyle)?.label ?? bibStyle}</option>
<option disabled>──────────────────────</option>
{/if}
{#each commonStyles as s}
<option value={s.value}>{s.label}</option>
{/each}
<option disabled>──────────────────────</option>
<option value="__more__">More styles…</option>
</Select>
{#if stylePickerOpen}
<div class="fixed inset-0 z-40" role="presentation" onclick={() => { stylePickerOpen = false; styleSearch = '' }}></div>
<div class="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg">
<div class="p-2 border-b border-border">
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={styleSearch}
placeholder="Search styles…"
autofocus
class="w-full text-sm px-3 py-1.5 rounded border border-input bg-background outline-none focus:ring-1 focus:ring-ring"
onkeydown={e => { if (e.key === 'Escape') { stylePickerOpen = false; styleSearch = '' } }}
/>
</div>
<ul class="max-h-72 overflow-y-auto py-1">
{#each allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())) as s}
<li>
<button
type="button"
class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted {s.value === bibStyle ? 'font-semibold text-primary' : ''}"
onclick={() => pickMoreStyle(s)}
>{s.label}</button>
</li>
{/each}
{#if allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())).length === 0}
<li class="px-3 py-2 text-sm text-muted-foreground">No styles found</li>
{/if}
</ul>
</div>
{/if}
</div>
{/if}
<Button variant="default" onclick={() => exportBibliography(items)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
{#if expLoading}
<span class="w-3.5 h-3.5 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{/if}
{_('bibliography.export')}
</Button>
<Button variant="outline" onclick={() => copyBibExport(items)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
{#if expCopied}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{_('bibliography.copied')}
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
{_('bibliography.copy')}
{/if}
</Button>
{#if expError}
<p class="w-full text-xs text-destructive">{expError}</p>
{/if}
</div>
{/snippet}
<div class="mt-4 space-y-2">
{#each bibGroups() as group (group.entries[0].entry.id)}
{#if group.type === 'Work'}
{#if isEntityPage && group.entries[0].idx === 0}
{@const { entry } = group.entries[0]}
{@const meta = parseMeta(entry.data)}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{typeLabel(meta.type, bibLocale)}</h2>
</div>
<ol>
<WorkCard
{entry} {meta} index={undefined}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entry.id)}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)}
/>
</ol>
</div>
{#if group.entries.length > 1}
{@const refEntries = group.entries.slice(1)}
{@const refTotalPages = Math.max(1, Math.ceil(refEntries.length / PAGE_SIZE))}
{@const refSlice = refEntries.slice((refPage - 1) * PAGE_SIZE, refPage * PAGE_SIZE)}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('bibliography.references_heading')}</h2>
</div>
<ol class="divide-y divide-border">
{#each refSlice as { entry, idx } (entry.id)}
{@const meta = parseMeta(entry.data)}
<WorkCard
{entry} {meta} index={idx - 1}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entry.id)}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)}
/>
{/each}
</ol>
{#if refTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={refPage} totalPages={refTotalPages} onpage={p => refPage = p} />
</div>
{/if}
{@render bibFooter(refEntries.map(e => e.entry))}
</div>
{/if}
{:else}
{@const isPaginatedWorks = isEntityPage && group.entries[0].idx > 0}
{@const worksTotalPages = isPaginatedWorks ? Math.max(1, Math.ceil(group.entries.length / PAGE_SIZE)) : 1}
{@const worksSlice = isPaginatedWorks ? group.entries.slice((worksPage - 1) * PAGE_SIZE, worksPage * PAGE_SIZE) : group.entries}
<div class="border border-border rounded-md overflow-hidden">
{#if isPaginatedWorks}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('bibliography.works_heading')}</h2>
</div>
{/if}
<ol class="divide-y divide-border">
{#each worksSlice as { entry, idx } (entry.id)}
{@const meta = parseMeta(entry.data)}
<WorkCard
{entry} {meta} index={idx === 0 ? undefined : idx - 1}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entry.id)}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)}
/>
{/each}
</ol>
{#if worksTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={worksPage} totalPages={worksTotalPages} onpage={p => worksPage = p} />
</div>
{/if}
{@render bibFooter(group.entries.map(e => e.entry))}
</div>
{/if}
{:else if group.type === 'Organization'}
{#each group.entries as { entry, idx } (entry.id)}
{@const org = parseOrgMeta(entry.data)}
{@const worksCount = isEntityPage && idx === 0 ? bibliography.length - 1 : 0}
<div class="border border-border rounded-md overflow-hidden">
{#if isEntityPage && idx === 0}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('entity.organization')}</h2>
</div>
{/if}
<OrgCard {entry} {org} {bibLocale} {addedId} {worksCount}
isSaved={savedItems.some(item => item.id === entry.id)}
ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
onadd={(id, data) => addToSaved(id, data)}
/>
</div>
{/each}
{:else}
{#each group.entries as { entry, idx } (entry.id)}
{@const worksCount = isEntityPage && idx === 0 ? bibliography.length - 1 : 0}
<div class="border border-border rounded-md overflow-hidden">
{#if isEntityPage && idx === 0}
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('entity.person')}</h2>
</div>
{/if}
<PersonCard {entry} {addedId} {bibLocale} {worksCount}
isSaved={savedItems.some(item => item.id === entry.id)}
ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
onadd={(id, data) => addToSaved(id, data)}
/>
</div>
{/each}
{/if}
{/each}
{#if citations.length > 0}
{@const citeTotalPages = Math.max(1, Math.ceil(citations.length / PAGE_SIZE))}
{@const citeSlice = citations.slice((citePage - 1) * PAGE_SIZE, citePage * PAGE_SIZE)}
<div class="border border-border rounded-md overflow-hidden">
<div class="px-5 py-3 border-b border-border bg-muted/50">
<h2 class="text-base font-semibold">{_('bibliography.citations_heading')}</h2>
</div>
<ol class="divide-y divide-border">
{#each citeSlice as entry, idx (entry.id)}
{@const meta = parseMeta(entry.data)}
{@const globalIdx = (citePage - 1) * PAGE_SIZE + idx}
<WorkCard
{entry} {meta} index={globalIdx}
{bibLocale} {copiedCiteId} {addedId}
isSaved={savedItems.some(item => item.id === entry.id)}
oncopy={() => copyCitation(entry)}
ondelete={undefined}
onadd={(id, data) => addToSaved(id, data)}
/>
{/each}
</ol>
{#if citeTotalPages > 1}
<div class="px-5 pb-4">
<Pagination page={citePage} totalPages={citeTotalPages} onpage={p => citePage = p} />
</div>
{/if}
{@render bibFooter(citations)}
</div>
{/if}
</div>
{/if}
<!-- Saved items (homepage only) -->
{#if !isEntityPage && savedItems.length > 0}
{@const savedTotalPages = Math.max(1, Math.ceil(savedItems.length / PAGE_SIZE))}
{@const savedSlice = savedItems.slice((savedPage - 1) * PAGE_SIZE, savedPage * PAGE_SIZE)}
<div class="mt-4 space-y-2">
{#each savedSlice as item, sIdx (item.id)}
{@const sEntry = { id: item.id, doi: item.id.startsWith('https://doi.org/') ? item.id.replace('https://doi.org/', '') : '', data: item.data, url: null, html: '' }}
{@const sType = detectEntityType(item.data)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="border border-border rounded-md overflow-hidden transition-opacity
{dragId === item.id ? 'opacity-30' : ''}
{dropPosition?.id === item.id && dropPosition.before ? 'border-t-2 border-primary' : ''}
{dropPosition?.id === item.id && !dropPosition.before ? 'border-b-2 border-primary' : ''}"
draggable={savedDragHandleActive}
onpointerdown={() => { savedDragHandleActive = false }}
ondragstart={e => { if (!savedDragHandleActive) { e.preventDefault(); return } onSavedDragStart(e, item) }}
ondragover={e => onSavedDragOver(e, item)}
ondragleave={onSavedDragLeave}
ondrop={e => onSavedDrop(e, item)}
ondragend={() => { savedDragHandleActive = false; onSavedDragEnd() }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2">
<div onpointerdown={e => { e.stopPropagation(); savedDragHandleActive = true }}
class="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground/60">
<svg viewBox="0 0 8 14" fill="currentColor" class="w-2 h-3.5">
<circle cx="2" cy="2" r="1.5"/><circle cx="6" cy="2" r="1.5"/>
<circle cx="2" cy="7" r="1.5"/><circle cx="6" cy="7" r="1.5"/>
<circle cx="2" cy="12" r="1.5"/><circle cx="6" cy="12" r="1.5"/>
</svg>
</div>
{#if sType === 'Organization'}
<h2 class="text-base font-semibold">{_('entity.organization')}</h2>
{:else if sType === 'Person'}
<h2 class="text-base font-semibold">{_('entity.person')}</h2>
{:else}
{@const sMeta = parseMeta(item.data)}
<h2 class="text-base font-semibold">{typeLabel(sMeta.type, bibLocale)}</h2>
{/if}
</div>
{#if sType === 'Organization'}
{@const sOrg = parseOrgMeta(item.data)}
<OrgCard entry={sEntry} org={sOrg} {bibLocale} {addedId}
ondelete={() => removeFromSaved(item.id)}
/>
{:else if sType === 'Person'}
<PersonCard entry={sEntry} {addedId} {bibLocale}
ondelete={() => removeFromSaved(item.id)}
/>
{:else}
{@const sMeta = parseMeta(item.data)}
<ol>
<WorkCard entry={sEntry} meta={sMeta}
{bibLocale} {copiedCiteId} {addedId}
oncopy={() => copyCitation(sEntry)}
ondelete={() => removeFromSaved(item.id)}
/>
</ol>
{/if}
</div>
{/each}
{#if savedTotalPages > 1}
<Pagination page={savedPage} totalPages={savedTotalPages} onpage={p => savedPage = p} />
{/if}
<!-- Saved items actions -->
<div class="mt-2 border border-border rounded-md bg-muted px-4 py-3 flex items-center gap-2 flex-wrap">
<Select bind:value={savedExpFormat} class="w-40 h-8 text-xs py-0">
{#each savedExpFormats as f}
<option value={f.value}>{f.label}</option>
{/each}
</Select>
<Button variant="default" onclick={exportSaved} disabled={savedExpLoading} class="h-8 gap-1.5 text-xs px-3">
{#if savedExpLoading}
<span class="w-3.5 h-3.5 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{/if}
{_('bibliography.export')}
</Button>
<Button variant="outline" onclick={copySavedExport} disabled={savedExpLoading} class="h-8 gap-1.5 text-xs px-3">
{#if savedExpCopied}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{_('bibliography.copied')}
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
{_('bibliography.copy')}
{/if}
</Button>
<Button variant="outline" onclick={() => { savedItems = [] }} class="ml-auto h-8 gap-1.5 text-xs px-3 hover:text-destructive hover:border-destructive">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
{_('bibliography.delete')}
</Button>
{#if savedExpError}
<p class="w-full text-xs text-destructive">{savedExpError}</p>
{/if}
</div>
</div>
{/if}
</section>
<!-- ── Docs (homepage only) ─────────────────────────────────────────── -->
{#if !isEntityPage}
<!-- Supported Metadata Formats table -->
<div class="mb-8">
<h2 class="text-xl font-bold text-primary border-b border-border pb-1 mb-4">{_('docs.formats.title')}</h2>
<div class="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Format</TableHead>
<TableHead>Name</TableHead>
<TableHead>Content Type</TableHead>
<TableHead class="text-center">Read</TableHead>
<TableHead class="text-center">Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Commonmeta</TableCell>
<TableCell class="font-mono text-xs">commonmeta</TableCell>
<TableCell class="font-mono text-xs">application/vnd.commonmeta+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="http://en.wikipedia.org/wiki/BibTeX" class="text-primary hover:underline">BibTeX</a></TableCell>
<TableCell class="font-mono text-xs">bibtex</TableCell>
<TableCell class="font-mono text-xs">application/x-bibtex</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://citation-file-format.github.io/" class="text-primary hover:underline">Citation File Format (CFF)</a></TableCell>
<TableCell class="font-mono text-xs">cff</TableCell>
<TableCell class="font-mono text-xs">application/vnd.cff+yaml</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center text-muted-foreground">later</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://codemeta.github.io/" class="text-primary hover:underline">Codemeta</a></TableCell>
<TableCell class="font-mono text-xs">codemeta</TableCell>
<TableCell class="font-mono text-xs">application/vnd.codemeta.ld+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center text-muted-foreground">later</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://api.crossref.org" class="text-primary hover:underline">Crossref</a></TableCell>
<TableCell class="font-mono text-xs">crossref</TableCell>
<TableCell class="font-mono text-xs">application/vnd.crossref+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://www.crossref.org/schema/documentation/unixref1.1/unixref1.1.html" class="text-primary hover:underline">CrossRef XML</a></TableCell>
<TableCell class="font-mono text-xs">crossref_xml</TableCell>
<TableCell class="font-mono text-xs">application/vnd.crossref.unixref+xml</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://citationstyles.org/" class="text-primary hover:underline">CSL-JSON</a></TableCell>
<TableCell class="font-mono text-xs">csl</TableCell>
<TableCell class="font-mono text-xs">application/vnd.citationstyles.csl+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://api.datacite.org/" class="text-primary hover:underline">DataCite</a></TableCell>
<TableCell class="font-mono text-xs">datacite</TableCell>
<TableCell class="font-mono text-xs">application/vnd.datacite.datacite+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://datacite-metadata-schema.readthedocs.io/en/4.7/" class="text-primary hover:underline">DataCite XML</a></TableCell>
<TableCell class="font-mono text-xs">datacite_xml</TableCell>
<TableCell class="font-mono text-xs">application/vnd.datacite.datacite+xml</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://citationstyles.org/" class="text-primary hover:underline">Formatted Citation</a></TableCell>
<TableCell class="font-mono text-xs">citation</TableCell>
<TableCell class="font-mono text-xs">text/x-bibliography</TableCell>
<TableCell class="text-center text-muted-foreground">n/a</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://inveniordm.docs.cern.ch/reference/metadata/" class="text-primary hover:underline">InvenioRDM</a></TableCell>
<TableCell class="font-mono text-xs">inveniordm</TableCell>
<TableCell class="font-mono text-xs">application/vnd.inveniordm.v1+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://www.jsonfeed.org/" class="text-primary hover:underline">JSON Feed</a></TableCell>
<TableCell class="font-mono text-xs">jsonfeed</TableCell>
<TableCell class="font-mono text-xs">application/feed+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center text-muted-foreground">later</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="https://www.openalex.org/" class="text-primary hover:underline">OpenAlex</a></TableCell>
<TableCell class="font-mono text-xs">openalex</TableCell>
<TableCell class="font-mono text-xs">n/a</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center text-muted-foreground">later</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="http://en.wikipedia.org/wiki/RIS_(file_format)" class="text-primary hover:underline">RIS</a></TableCell>
<TableCell class="font-mono text-xs">ris</TableCell>
<TableCell class="font-mono text-xs">application/x-research-info-systems</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
<TableRow>
<TableCell><a href="http://schema.org/" class="text-primary hover:underline">Schema.org (JSON-LD)</a></TableCell>
<TableCell class="font-mono text-xs">schemaorg</TableCell>
<TableCell class="font-mono text-xs">application/vnd.schemaorg.ld+json</TableCell>
<TableCell class="text-center">✓</TableCell>
<TableCell class="text-center">✓</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
{#snippet codeBlock(code, idx)}
<div class="relative group my-3">
<pre class="!my-0"><code>{code}</code></pre>
<button
type="button"
onclick={() => copyPre(code, idx)}
aria-label="Copy"
class="absolute top-1.5 right-1.5 h-7 w-7 flex items-center justify-center rounded
text-muted-foreground hover:text-foreground
opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if copiedPreIdx === idx}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
{/if}
</button>
</div>
{/snippet}
<div class="prose prose-sm prose-slate max-w-none
[&_h2]:text-primary [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-1
[&_a]:text-primary [&_pre]:bg-muted [&_pre]:border [&_pre]:border-border
[&_pre]:text-sm [&_pre]:text-gray-800 dark:[&_pre]:text-gray-200">
<h2>{_('docs.content_negotiation.title')}</h2>
<p>{@html _('docs.content_negotiation.intro')}</p>
<p>{_('docs.content_negotiation.bibtex_label')}</p>
{@render codeBlock(`curl -H "Accept: application/x-bibtex" \\\n https://commonmeta.org/10.1371/journal.pcbi.1000204`, 1)}
<p>{@html _('docs.content_negotiation.format_param_label')}</p>
{@render codeBlock('curl https://commonmeta.org/10.1371/journal.pcbi.1000204?format=bibtex', 2)}
<p>{@html _('docs.content_negotiation.citation_style_label')}</p>
{@render codeBlock(`curl -H "Accept: text/x-bibliography; style=vancouver; locale=de-DE" \\\n https://commonmeta.org/10.1371/journal.pcbi.1000204`, 3)}
<p>{_('docs.content_negotiation.query_params_label')}</p>
{@render codeBlock('curl "https://commonmeta.org/10.1371/journal.pcbi.1000204?format=citation&style=vancouver&locale=de-DE"', 4)}
<p>{@html _('docs.content_negotiation.multiple_types_label')}</p>
{@render codeBlock(`curl -H "Accept: application/vnd.citationstyles.csl+json;q=0.9, application/x-bibtex" \\\n https://commonmeta.org/10.1371/journal.pcbi.1000204`, 5)}
</div>
{/if}
</main>
<!-- Footer -->
<footer class="py-6 text-xs text-gray-700 dark:text-gray-300 leading-5">
<div class="max-w-4xl mx-auto px-6 flex items-center justify-between">
<span class="[&_a]:hover:text-gray-900 dark:[&_a]:hover:text-gray-100 [&_a]:transition-colors">
{@html _('footer.copyright')}
</span>
<span class="flex items-center gap-3">
<a href="mailto:info@front-matter.de" aria-label="Email"
class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</a>
<a href="https://hachyderm.io/@mfenner" target="_blank" rel="noreferrer" aria-label="Mastodon"
class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a4 4 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522q0-1.288.66-2.046c.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764q.662.757.661 2.046z"/>
</svg>
</a>
</span>
</div>
</footer>
</div>
<style>
@keyframes fetch-fill {
from { width: 0%; }
to { width: 100%; }
}
.fetch-progress {
width: 0%;
animation: fetch-fill 1s ease-out forwards;
}
</style>